Alexey K
Alexey K

Reputation: 6723

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

Upvotes: 1

Views: 929

Answers (2)

rob mayoff
rob mayoff

Reputation: 385510

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)]
}

Upvotes: 5

Frederik Theisen
Frederik Theisen

Reputation: 35

To me, it looks like the edges are antialiased resulting in somewhat transparent pixels. The orange of the background can then be seen through the 'blurred' edges of the overlay. Have you tried making the overlaid layers opaque?

layer.Opaque = true; //C#

An alternative way may be to draw a thin circle with the background color on top the orange edges. This should work, but it's not the prettiest method.

Upvotes: 0

Related Questions