带有 CAShapelayer 的圆弧圆环图 - 底层的边界是可见的

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

我用 CAShapeLayers 圆弧画了一个圆环图。我画它 是把一个放在另一个上面,但问题是下面的层边缘是可见的。

绘图代码如下

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)

我做错了什么?

更新

尝试过的代码

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

但它只掩盖了内边缘,外边缘仍有边缘

对我来说,看起来边缘是抗锯齿的,导致像素有些透明。然后可以通过叠加层的 'blurred' 边缘看到背景的橙色。 您是否尝试过使叠加层不透明?

layer.Opaque = true; //C#

另一种方法可能是在橙色边缘的顶部绘制一个带有背景色的细圆。这应该可行,但这不是最漂亮的方法。

您看到的条纹是因为您在同一位置绘制了两次完全相同的形状,而 alpha 合成(通常实现的)并非旨在处理该问题。 Porter and Duff's paper, “Compositing Digital Images”,里面介绍了alpha compositing,讨论问题:

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.

当它说“哑光”时,基本上意味着透明度。当它说“uncorrelated pictures”时,是指两张透明区域没有特殊关系的图片。但是你的情况,你的两张图片确实有一个特殊的关系:图片在完全相同的区域是透明的!

这是重现您的问题的 self-contained 测试:

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

这是显示问题的屏幕部分:

解决此问题的一种方法是将颜色绘制到环的边缘之外,然后使用蒙版将它们夹在环的形状上。

我将更改我的代码,而不是绘制一个红色圆环,并在其顶部绘制一个灰色圆环的一部分,我绘制一个红色圆盘,并在其顶部绘制一个灰色楔形:

如果放大,您可以看到这仍然显示灰色楔形边缘处的红色条纹。所以诀窍是使用 ring-shaped 掩码来获得最终形状。这是面具的形状,在先前图像的顶部以白色绘制:

请注意,蒙版远离有问题的边缘区域。当我将蒙版用作蒙版而不是绘制它时,我得到了最终的完美结果:

下面是绘制完美版的代码:

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
}

请注意,掩码适用于 view 中的所有内容,因此(在您的情况下)您可能需要将所有图层移动到子视图中没有其他内容因此可以安全屏蔽。

更新

看看你的 playground,问题(仍然)是你正在绘制两个具有完全相同的 partially-transparent 边缘的形状。你不能那样做。解决办法是把彩色的形状画大一点,让它们在甜甜圈的边缘都完全不透明,然后用图层蒙版把它们裁剪成甜甜圈的形状。

我修好了你的游乐场。请注意,在我的版本中,每个彩色部分的 lineWidthdonutThickness + 10,而遮罩的 lineWidth 仅为 donutThickness。结果如下:

这里是游乐场:

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)) { [=12=] + .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 { [=12=].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)]
}