Search code examples
ioscalayercashapelayer

arcs donut chart with CAShapelayer - border of underlaying layers are visible


I draw a donut chart with CAShapeLayers arcs. I draw itenter image description here by putting one on top of another and the problem that underneath layers edges are visible.

code of drawing is following

for (index, item) in values.enumerated() {
            var currentValue = previousValue + item.value
            previousValue = currentValue
            if index == values.count - 1 {
                currentValue = 100
            }

            let layer = CAShapeLayer()
            let path = UIBezierPath()

            let separatorLayer = CAShapeLayer()
            let separatorPath = UIBezierPath()

            let radius: CGFloat = self.frame.width / 2 - lineWidth / 2
            let center: CGPoint = CGPoint(x: self.bounds.width / 2, y: self.bounds.width / 2)

            separatorPath.addArc(withCenter: center, radius: radius, startAngle: percentToRadians(percent: -25), endAngle: percentToRadians(percent: CGFloat(currentValue - 25 + 0.2)), clockwise: true)
            separatorLayer.path = separatorPath.cgPath
            separatorLayer.fillColor = UIColor.clear.cgColor
            separatorLayer.strokeColor = UIColor.white.cgColor
            separatorLayer.lineWidth = lineWidth
            separatorLayer.contentsScale = UIScreen.main.scale
            self.layer.addSublayer(separatorLayer)
            separatorLayer.add(createGraphAnimation(), forKey: nil)
            separatorLayer.zPosition = -(CGFloat)(index)

            path.addArc(withCenter: center, radius: radius, startAngle: percentToRadians(percent: -25), endAngle: percentToRadians(percent: CGFloat(currentValue - 25)), clockwise: true)
            layer.path = path.cgPath
            layer.fillColor = UIColor.clear.cgColor
            layer.strokeColor = item.color.cgColor
            layer.lineWidth = lineWidth
            layer.contentsScale = UIScreen.main.scale
            layer.shouldRasterize = true
            layer.rasterizationScale = UIScreen.main.scale
            layer.allowsEdgeAntialiasing = true
            separatorLayer.addSublayer(layer)
            layer.add(createGraphAnimation(), forKey: nil)
            layer.zPosition = -(CGFloat)(index)

What am I doing wrong ?

UPD

Tried code

let mask = CAShapeLayer()
        mask.frame = CGRect(x: 0, y: 0, width: radius * 2, height: radius * 2)
        mask.fillColor = nil
        mask.strokeColor = UIColor.white.cgColor
        mask.lineWidth = lineWidth * 2
        let maskPath = CGMutablePath()
        maskPath.addArc(center: CGPoint(x: self.radius, y: self.radius), radius: radius, startAngle: 0, endAngle: 2 * .pi, clockwise: true)
        maskPath.closeSubpath()
        mask.path = maskPath
        self.layer.mask = mask

but it masks only inner edges, outer still has fringe


Solution

  • The fringe you're seeing happens because you're drawing exactly the same shape in the same position twice, and alpha compositing (as commonly implemented) is not designed to handle that. Porter and Duff's paper, “Compositing Digital Images”, which introduced alpha compositing, discusses the problem:

    We must remember that our basic assumption about the division of subpixel areas by geometric objects breaks down in the face of input pictures with correlated mattes. When one picture appears twice in a compositing expression, we must take care with our computations of F A and F B. Those listed in the table are correct only for uncorrelated pictures.

    When it says “matte”, it basically means transparency. When it says “uncorrelated pictures”, it means two pictures whose transparent areas have no special relationship. But in your case, your two pictures do have a special relationship: the pictures are transparent in exactly the same areas!

    Here's a self-contained test that reproduces your problem:

    private func badVersion() {
        let center = CGPoint(x: view.bounds.width / 2, y: view.bounds.height / 2)
        let radius: CGFloat = 100
        let ringWidth: CGFloat = 44
    
        let ring = CAShapeLayer()
        ring.frame = view.bounds
        ring.fillColor = nil
        ring.strokeColor = UIColor.red.cgColor
        ring.lineWidth = ringWidth
        let ringPath = CGMutablePath()
        ringPath.addArc(center: center, radius: radius, startAngle: 0, endAngle: 2 * .pi, clockwise: true)
        ringPath.closeSubpath()
        ring.path = ringPath
        view.layer.addSublayer(ring)
    
        let wedge = CAShapeLayer()
        wedge.frame = view.bounds
        wedge.fillColor = nil
        wedge.strokeColor = UIColor.darkGray.cgColor
        wedge.lineWidth = ringWidth
        wedge.lineCap = kCALineCapButt
        let wedgePath = CGMutablePath()
        wedgePath.addArc(center: center, radius: radius, startAngle: 0.1, endAngle: 0.6, clockwise: false)
        wedge.path = wedgePath
        view.layer.addSublayer(wedge)
    }
    

    Here's the part of the screen that shows the problem:

    the problem

    One way to fix this is to draw the colors beyond the edges of the ring, and use a mask to clip them to the ring shape.

    I'll change my code so that instead of drawing a red ring, and part of a gray ring on top of it, I draw a red disc, and a gray wedge on top of it:

    red disc with gray wedge

    If you zoom in, you can see that this still shows the red fringe at the edge of the gray wedge. So the trick is to use a ring-shaped mask to get the final shape. Here's the shape of the mask, drawn in white on top of the prior image:

    white mask

    Note that the mask is well away from the problematic area with the fringe. When I use the mask as a mask instead of drawing it, I get the final, perfect result:

    perfect ring

    Here's the code that draws the perfect version:

    private func goodVersion() {
        let center = CGPoint(x: view.bounds.width / 2, y: view.bounds.height / 2)
        let radius: CGFloat = 100
        let ringWidth: CGFloat = 44
        let slop: CGFloat = 10
    
        let disc = CAShapeLayer()
        disc.frame = view.bounds
        disc.fillColor = UIColor.red.cgColor
        disc.strokeColor = nil
        let ringPath = CGMutablePath()
        ringPath.addArc(center: center, radius: radius + ringWidth / 2 + slop, startAngle: 0, endAngle: 2 * .pi, clockwise: true)
        ringPath.closeSubpath()
        disc.path = ringPath
        view.layer.addSublayer(disc)
    
        let wedge = CAShapeLayer()
        wedge.frame = view.bounds
        wedge.fillColor = UIColor.darkGray.cgColor
        wedge.strokeColor = nil
        let wedgePath = CGMutablePath()
        wedgePath.move(to: center)
        wedgePath.addArc(center: center, radius: radius + ringWidth / 2 + slop, startAngle: 0.1, endAngle: 0.6, clockwise: false)
        wedgePath.closeSubpath()
        wedge.path = wedgePath
        view.layer.addSublayer(wedge)
    
        let mask = CAShapeLayer()
        mask.frame = view.bounds
        mask.fillColor = nil
        mask.strokeColor = UIColor.white.cgColor
        mask.lineWidth = ringWidth
        let maskPath = CGMutablePath()
        maskPath.addArc(center: center, radius: radius, startAngle: 0, endAngle: 2 * .pi, clockwise: true)
        maskPath.closeSubpath()
        mask.path = maskPath
        view.layer.mask = mask
    }
    

    Note that the mask applies to everything in view, so (in your case) you may need to move all of your layers into a subview has no other contents so it's safe to mask.

    UPDATE

    Looking at your playground, the problem is (still) that you're drawing two shapes that have exactly the same partially-transparent edge on top of each other. You can't do that. The solution is to draw the colored shapes larger, so that they are both completely opaque at the edge of the donut, and then use the layer mask to clip them to the donut shape.

    I fixed your playground. Notice how in my version, the lineWidth of each colored section is donutThickness + 10, and the mask's lineWidth is only donutThickness. Here's the result:

    playground output

    Here's the playground:

    import UIKit
    import PlaygroundSupport
    
    class ABDonutChart: UIView {
    
        struct Datum {
            var value: Double
            var color: UIColor
        }
    
        var donutThickness: CGFloat = 20 { didSet { setNeedsLayout() } }
        var separatorValue: Double = 1 { didSet { setNeedsLayout() } }
        var separatorColor: UIColor = .white { didSet { setNeedsLayout() } }
        var data = [Datum]() { didSet { setNeedsLayout() } }
    
        func withAnimation(_ wantAnimation: Bool, do body: () -> ()) {
            let priorFlag = wantAnimation
            self.wantAnimation = true
            defer { self.wantAnimation = priorFlag }
            body()
            layoutIfNeeded()
        }
    
        override func layoutSubviews() {
            super.layoutSubviews()
    
            let bounds = self.bounds
            let center = CGPoint(x: bounds.origin.x + bounds.size.width / 2, y: bounds.origin.y + bounds.size.height / 2)
            let radius = (min(bounds.size.width, bounds.size.height) - donutThickness) / 2
    
            let maskLayer = layer.mask as? CAShapeLayer ?? CAShapeLayer()
            maskLayer.frame = bounds
            maskLayer.fillColor = nil
            maskLayer.strokeColor = UIColor.white.cgColor
            maskLayer.lineWidth = donutThickness
            maskLayer.path = CGPath(ellipseIn: CGRect(x: center.x - radius, y: center.y - radius, width: 2 * radius, height: 2 * radius), transform: nil)
            layer.mask = maskLayer
    
            var spareLayers = segmentLayers
            segmentLayers.removeAll()
    
            let finalSum = data.reduce(Double(0)) { $0 + $1.value + separatorValue }
            var runningSum: Double = 0
    
            let animation = CABasicAnimation(keyPath: "strokeEnd")
            animation.fromValue = 0.0
            animation.toValue = 1.0
            animation.duration = 2
            animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
    
            func addSegmentLayer(color: UIColor, segmentSum: Double) {
                let angleOffset: CGFloat = -0.25 * 2 * .pi
    
                let segmentLayer = spareLayers.popLast() ?? CAShapeLayer()
                segmentLayer.strokeColor = color.cgColor
                segmentLayer.lineWidth = donutThickness + 10
                segmentLayer.lineCap = kCALineCapButt
                segmentLayer.fillColor = nil
    
                let path = CGMutablePath()
                path.addArc(center: center, radius: radius, startAngle: angleOffset, endAngle: CGFloat(segmentSum / finalSum * 2 * .pi) + angleOffset, clockwise: false)
                segmentLayer.path = path
    
                layer.insertSublayer(segmentLayer, at: 0)
                segmentLayers.append(segmentLayer)
    
                if wantAnimation {
                    segmentLayer.add(animation, forKey: animation.keyPath)
                }
            }
    
            for datum in data {
                addSegmentLayer(color: separatorColor, segmentSum: runningSum + separatorValue / 2)
                runningSum += datum.value + separatorValue
                addSegmentLayer(color: datum.color, segmentSum: runningSum - separatorValue / 2)
            }
    
            addSegmentLayer(color: separatorColor, segmentSum: finalSum)
    
            spareLayers.forEach { $0.removeFromSuperlayer() }
        }
    
        private var segmentLayers = [CAShapeLayer]()
        private var wantAnimation = false
    }
    
    let container = UIView()
    container.frame.size = CGSize(width: 300, height: 300)
    container.backgroundColor = .black
    PlaygroundPage.current.liveView = container
    PlaygroundPage.current.needsIndefiniteExecution = true
    
    let m = ABDonutChart(frame: CGRect(x: 0, y: 0, width: 215, height: 215))
    m.center = CGPoint(x: container.bounds.size.width / 2, y: container.bounds.size.height / 2)
    container.addSubview(m)
    
    m.withAnimation(true) {
        m.data = [
            .init(value: 10, color: .red),
            .init(value: 30, color: .blue),
            .init(value: 15, color: .orange),
            .init(value: 40, color: .yellow),
            .init(value: 50, color: .green)]
    }