Search code examples
iosswiftxcodeuiimageviewborder

Create border to UIImageView with sliced


I am creating view like WhatsApp status, where I am getting stories uploaded by user in array.

I want to show border to user profile image with number of array count of storied. something like this in image below.

I want to divide the border of UIImageview as per the array count and when user views the story change the colour of border.

For example, user added 5 stories, so I wanted to show border with 5 slices and if user viewed 2 stories out of 5 then these two slices of border will be changed to gray colour and remaining 3 will be as green colour.

Any help to get this thing done.

thanks in advance.

enter image description here

I tried this, but its not giving me expected result

func setImageViewWithBorder() {
        
        self.img_userProfile.backgroundColor = .clear
        self.img_userProfile.clipsToBounds = true
        
        let maskLayer = CAShapeLayer()
        maskLayer.frame = img_userProfile.frame
        maskLayer.path = UIBezierPath(rect: self.img_userProfile.frame).cgPath
        self.img_userProfile.layer.mask = maskLayer
        
        
        let line = NSNumber(value: Float(self.img_userProfile.frame.width / 2))
        
        let borderLayer = CAShapeLayer()
        borderLayer.path = maskLayer.path
        borderLayer.fillColor = UIColor.clear.cgColor
        borderLayer.strokeColor = UIColor.green.cgColor
        borderLayer.lineDashPattern = [line]
        borderLayer.lineDashPhase = self.img_userProfile.frame.width / 4
        borderLayer.lineWidth = 10
        borderLayer.frame = self.img_userProfile.frame
        self.img_userProfile.layer.addSublayer(borderLayer)
    }

Solution

  • You almost certainly want to use a couple CAShapeLayers, using arcs to create the segments (rather than trying to manipulate the dash pattern):

    • One layer with stroke set to gray
    • One layer with stroke set to green
    • define a "gap" degrees value... I'd start with 10 degrees
    • calculate the arc segment degrees by subtracting the number of gaps from 360 degrees, and dividing that by the number of arcs
    • use a UIBezierPath to draw the "viewed" arcs
    • use a UIBezierPath to draw the "not-viewed" arcs

    Here's a quick example...

    First, since we generally think in terms of degrees, but bezier arcs use radians, we'll use this extension:

    extension FloatingPoint {
        var degreesToRadians: Self { self * .pi / 180 }
        var radiansToDegrees: Self { self * 180 / .pi }
    }
    

    Then, a sample UIView subclass:

    class DashedCircleView: UIView {
        
        var storyCount: Int = 0 {
            didSet { setNeedsLayout() }
        }
        var viewedCount: Int = 0 {
            didSet { setNeedsLayout() }
        }
        
        public var storyColor: UIColor = .systemGreen {
            didSet {
                colorLayer.strokeColor = storyColor.cgColor
            }
        }
        public var viewedColor: UIColor = .gray {
            didSet {
                colorLayer.strokeColor = viewedColor.cgColor
            }
        }
    
        private let grayLayer = CAShapeLayer()
        private let colorLayer = CAShapeLayer()
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        private func commonInit() {
            layer.addSublayer(grayLayer)
            layer.addSublayer(colorLayer)
            grayLayer.strokeColor = viewedColor.cgColor
            colorLayer.strokeColor = storyColor.cgColor
            grayLayer.fillColor = UIColor.clear.cgColor
            colorLayer.fillColor = UIColor.clear.cgColor
            grayLayer.lineWidth = 5
            colorLayer.lineWidth = 5
        }
        
        override func layoutSubviews() {
            super.layoutSubviews()
            
            let cPT: CGPoint = CGPoint(x: bounds.midX, y: bounds.midY)
    
            if storyCount == 0 {
                
                // clear both shape layer paths
                grayLayer.path = nil
                colorLayer.path = nil
                
            } else if storyCount == 1 {
                
                // complete 360 degee arc
                //  so no spaces
                let bez = UIBezierPath()
                let a1: Double = -90
                let a2: Double = 270
                
                bez.addArc(withCenter: cPT, radius: bounds.midX, startAngle: a1.degreesToRadians, endAngle: a2.degreesToRadians, clockwise: true)
                
                if viewedCount == 1 {
                    grayLayer.path = bez.cgPath
                    colorLayer.path = nil
                } else {
                    colorLayer.path = bez.cgPath
                    grayLayer.path = nil
                }
            } else {
                
                let grayBez = UIBezierPath()
                let colorBez = UIBezierPath()
                let trackBez = UIBezierPath()
                
                // space between arcs
                let gapDegrees: Double = 10
                
                let availableDegrees: Double = 360 - Double(storyCount) * gapDegrees
                let arcDegrees: Double = availableDegrees / Double(storyCount)
                
                var a1: Double = -90
    
                for i in 0..<storyCount {
                    if i < viewedCount {
                        grayBez.addArc(withCenter: cPT, radius: bounds.midX, startAngle: a1.degreesToRadians, endAngle: a1.degreesToRadians + arcDegrees.degreesToRadians, clockwise: true)
                    } else {
                        colorBez.addArc(withCenter: cPT, radius: bounds.midX, startAngle: a1.degreesToRadians, endAngle: a1.degreesToRadians + arcDegrees.degreesToRadians, clockwise: true)
                    }
                    
                    // to provide a space (or gap) between arcs, we need to
                    //  move to the start of the next arc
                    //  a "cheap" way to do this is to use a "tracking" bezier path
                    //  and add an arc including the gap degrees
                    //  that will give us the "moveTo" point
                    trackBez.addArc(withCenter: cPT, radius: bounds.midX, startAngle: a1.degreesToRadians, endAngle: (a1 + arcDegrees + gapDegrees).degreesToRadians, clockwise: true)
                    grayBez.move(to: trackBez.currentPoint)
                    colorBez.move(to: trackBez.currentPoint)
    
                    // increment the arc starting degrees
                    a1 += arcDegrees + gapDegrees
                }
                
                colorLayer.path = colorBez.cgPath
                grayLayer.path = grayBez.cgPath
                
            }
            
            grayLayer.lineCap = storyCount > 1 ? .round : .butt
            colorLayer.lineCap = storyCount > 1 ? .round : .butt
            
        }
        
    }
    

    and a sample view controller to demonstrate:

    class CircleTestVC: UIViewController {
        
        var numStories: Int = 0
        var numViewed: Int = 0
        
        let cView = DashedCircleView()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            cView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(cView)
            
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                cView.widthAnchor.constraint(equalToConstant: 160.0),
                cView.heightAnchor.constraint(equalTo: cView.widthAnchor),
                cView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
                cView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
            ])
    
            cView.storyCount = numStories
            cView.viewedCount = numViewed
            
            // let's add a few buttons
            let stackView = UIStackView()
            stackView.spacing = 8
            stackView.distribution = .fillEqually
            ["Add Story", "Add Viewed", "Reset"].forEach { s in
                let b = UIButton()
                b.backgroundColor = .systemRed
                b.setTitle(s, for: [])
                b.setTitleColor(.white, for: .normal)
                b.setTitleColor(.lightGray, for: .highlighted)
                b.addTarget(self, action: #selector(btnTapped(_:)), for: .touchUpInside)
                stackView.addArrangedSubview(b)
            }
            
            stackView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(stackView)
            NSLayoutConstraint.activate([
                stackView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
                stackView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                stackView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            ])
    
        }
        
        @objc func btnTapped(_ sender: UIButton) {
            guard let t = sender.currentTitle else { return }
            switch t {
            case "Add Story":
                addStory()
            case "Add Viewed":
                addViewed()
            default:
                reset()
            }
        }
        
        @objc func addStory() {
            numStories += 1
            cView.storyCount = numStories
        }
        @objc func addViewed() {
            numViewed += 1
            numViewed = min(numViewed, numStories)
            cView.viewedCount = numViewed
        }
        @objc func reset() {
            numStories = 0
            numViewed = 0
            cView.storyCount = numStories
            cView.viewedCount = numViewed
        }
    }
    

    When we run it, it will look like this...

    enter image description here

    after tapping "Add Story" 5 times:

    enter image description here

    then tapping "Add Viewed" 2 times:

    enter image description here

    and after adding a few more stories and a few more views:

    enter image description here