Search code examples
iosswiftuikitcalayer

Multiple CAShapeLayers with .round lineCap are overlapped by each other


I try to combine multiple layers using .round lineCap and a semicircle path:

let layer = CAShapeLayer()
layer.lineWidth = 12
layer.lineCap = .round
layer.strokeColor = color.withAlphaComponent(0.32).cgColor
layer.fillColor = UIColor.clear.cgColor
// angles calculation
let path = UIBezierPath(arcCenter: arcCenter,
                        radius: radius,
                        startAngle: startAngle,
                        endAngle: engAngle,
                        clockwise: true)
layer.path = path.cgPath

trying to achieve the following:

enter image description here

but my layers are overlapped by each other. Is it possible to fix it somehow fast or do I need to implement path calculating with rounded corners manually?

enter image description here


Solution

  • When .lineCap = .round we get a "circle" centered at the endpoint with a radius of 1/2 the line width:

    enter image description here

    So, to get the circle-round end to sit at the endpoint, we can adjust the endpoint by asin(lineWidth * 0.5 / radius):

    enter image description here

    Assuming we're going in a clockwise direction:

    let delta: CGFloat = lineCap == .round ? asin(lineWidth * 0.5 / radius) : 0.0
    
    let path = UIBezierPath(arcCenter: center,
                            radius: radius,
                            startAngle: startDegrees.radians + delta,
                            endAngle: endDegrees.radians - delta,
                            clockwise: true)
    

    So, if we have a series of degrees forming this image with .lineEnd = .butt (the default):

    enter image description here

    we can get this by offsetting the start and end angles:

    enter image description here

    Here's a complete example class:

    class ConnectedArcsView: UIView {
        
        public var segmentDegrees: [CGFloat] = [] {
            didSet {
                var n: Int = 0
                if let subs = layer.sublayers {
                    n = subs.count
                    if n > segmentDegrees.count {
                        // if we already have sublayers,
                        //  remove any extras
                        for _ in 0..<n - segmentDegrees.count {
                            subs.last?.removeFromSuperlayer()
                        }
                    }
                }
                // add sublayers if needed
                while n <  segmentDegrees.count {
                    let l = CAShapeLayer()
                    l.fillColor = UIColor.clear.cgColor
                    layer.addSublayer(l)
                    n += 1
                }
                setNeedsLayout()
            }
        }
        
        // segment colors default: [.red, .green, .blue]
        public var segmentColors: [UIColor] = [.red, .green, .blue] { didSet { setNeedsLayout() } }
        
        // line width default: 12
        public var lineWidth: CGFloat = 12 { didSet { setNeedsLayout() } }
        
        // line cap default: .round
        public var lineCap: CAShapeLayerLineCap = .round  { didSet { setNeedsLayout() } }
        
        override func layoutSubviews() {
            super.layoutSubviews()
            
            guard let subs = layer.sublayers else { return }
            
            let radius = (bounds.size.width - lineWidth) * 0.5
            let center = CGPoint(x: bounds.midX, y: bounds.midY)
            
            // if lineCap == .round
            //  calculate delta for start and end angles
            let delta: CGFloat = lineCap == .round ? asin(lineWidth * 0.5 / radius) : 0.0
            
            // calculate start angle so the "gap" is centered at the bottom
            let totalDegrees: CGFloat = segmentDegrees.reduce(0.0, +)
            var startDegrees: CGFloat = 90.0 + (360.0 - totalDegrees) * 0.5
            
            for i in 0..<segmentDegrees.count {
                let endDegrees = startDegrees + segmentDegrees[i]
                
                guard let shape = subs[i] as? CAShapeLayer else { continue }
                
                shape.lineWidth = lineWidth
                shape.lineCap = lineCap
                shape.strokeColor = segmentColors[i % segmentColors.count].cgColor
                
                let path = UIBezierPath(arcCenter: center,
                                        radius: radius,
                                        startAngle: startDegrees.radians + delta,
                                        endAngle: endDegrees.radians - delta,
                                        clockwise: true)
                
                shape.path = path.cgPath
                
                startDegrees += segmentDegrees[i]
            }
            
        }
    
    }
    

    and an example view controller showing its usage:

    class ExampleViewController: UIViewController {
        
        let testView = ConnectedArcsView()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            view.backgroundColor = .systemBlue
            
            let degrees: [CGFloat] = [
                40, 25, 140, 25, 40
            ]
            
            let colors: [UIColor] = [
                .systemRed, .systemYellow, .systemGreen, .systemYellow, .systemRed
            ] //.map { ($0 as UIColor).withAlphaComponent(0.5) }
            
            testView.segmentDegrees = degrees
            testView.segmentColors = colors
            testView.lineWidth = 12
            
            testView.backgroundColor = .black
            testView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(testView)
            
            // add an info label
            let v = UILabel()
            v.textAlignment = .center
            v.numberOfLines = 0
            v.text = "Tap anywhere to toggle between\n\".round\" and \".butt\""
            v.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(v)
            
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                
                testView.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
                testView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
                testView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
                testView.heightAnchor.constraint(equalTo: testView.widthAnchor),
                
                v.topAnchor.constraint(equalTo: testView.bottomAnchor, constant: 20.0),
                v.widthAnchor.constraint(equalTo: testView.widthAnchor),
                v.centerXAnchor.constraint(equalTo: testView.centerXAnchor),
                
            ])
            
        }
        
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            testView.lineCap = testView.lineCap == .round ? .butt : .round
        }
        
    }
    

    When running, tapping anywhere will toggle between .round and .butt.


    Edit - forgot to include the helper extension:

    extension CGFloat {
        var degrees: CGFloat {
            return self * CGFloat(180) / .pi
        }
        var radians: CGFloat {
            return self * .pi / 180.0
        }
    }
    

    Edit 2 - in response to comment on 11/8/2022...

    This custom ConnectedArcsView class is not yet finished. For example, it's easy to "exceed the bounds" as it were.

    Suppose we use [120, 20, 120] for our degrees, and we have a view width which gives us a circumference of 360-points for our circle (approx 115 points wide).

    With .lineCap = .butt, the 20-degree arc-segment will be 20-points long. Because it's the "top" arc, it will start at 260-degrees and end at 280-degrees.

    With .lineCap = .round, one-half the .lineWidth will be added onto each end of that arc-segment. Suppose the .lineWidth is 8... the arc-segment will now be 28-points long, which is no longer 20-degrees of the circle.

    So, we adjust the endpoints. The actual arc-segment is now 12-points long, the rounded ends are 4-points each bringing the full length of the rounded segment to 20-points. So, still 20-degrees, and the calculated start/end degrees are 264 and 276.

    Change the .lineWidth to 12, and we get arc-segment length: 8 + 6 on each end = 20-points, or 20-degrees, with calculated start/end degrees of 266 and 274.

    So --- what happens if we set .lineWidth = 24?

    Our 20-degree / 20-point arc-segment must have 12-points on each end! So the actual arc-segment is now MINUS 4-points!

    And, the calculated start/end degrees are 272 and 268 -- in other words, it will go almost all the way around the circle.

    enter image description here

    To put this class into a production environment, one would likely modify it to account for that, and probably throw an exception (which would need to be handled).

    In addition, note that if we use these values for our degree-segments:

    let degrees: [CGFloat] = [
        120, 60, 100, 70, 90
    ]
    

    we end up with a total of 440-degrees and the segments will overlap - which is almost certainly not desired.

    So, the values would need to be "normalized," along with (probably) a "gap" value for the bottom.