user13965197
user13965197

Reputation:

How to mask, animate and add shadow to a CAShapeLayer?

How can I apply a shadow to each slice while maintaining the current animation? I have tried putting it on the layer itself, but the shadow cannot be seen. I also tried creating another layer with a clear background, but with a clear background, the shadow does not work.

override func draw(_ rect: CGRect) {
        super.draw(rect)

        layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() }

        let totalValue = data.reduce(0.0) { $0 + $1.value }
        let center = CGPoint(x: rect.midX, y: rect.midY)
        var startAngle: CGFloat = -CGFloat.pi / 2
        var cumulativeDelay: CFTimeInterval = 0

        gapSizeDegrees = data.count == 1 ? 0 : gapSizeDegrees

        data.forEach { value in
            let segmentValue = CGFloat(value.value)
            let color = value.color
            let endAngle = startAngle + (segmentValue / CGFloat(totalValue)) * 2 * .pi

            let outerRadius = rect.width / 2
            let outerStartAngle = startAngle + gapSizeDegrees.toRadians()
            let outerEndAngle = endAngle - gapSizeDegrees.toRadians()

            let path = UIBezierPath(arcCenter: center, radius: outerRadius, startAngle: outerStartAngle, endAngle: outerEndAngle, clockwise: true)
            let innerRadius = outerRadius - strokeWidth
            path.addArc(withCenter: center, radius: innerRadius, startAngle: outerEndAngle - gapSizeDegrees / rect.width, endAngle: outerStartAngle + gapSizeDegrees / rect.width , clockwise: false)
            path.close()

            let shapeLayer = CAShapeLayer()
            shapeLayer.path = path.cgPath
            shapeLayer.fillColor = color.cgColor
            shapeLayer.strokeColor = color.cgColor

            layer.addSublayer(shapeLayer)

            animateSliceFilling(sliceLayer: shapeLayer, center: center, radius: outerRadius, innerRadius: innerRadius, startAngle: startAngle, endAngle: endAngle, delay: cumulativeDelay)

            cumulativeDelay += Consts.Appearence.animationDuration / Double(data.count)

            startAngle = endAngle
        }
    }

    private func animateSliceFilling(sliceLayer: CAShapeLayer, center: CGPoint, radius: CGFloat, innerRadius: CGFloat, startAngle: CGFloat, endAngle: CGFloat, delay: CFTimeInterval) {
        let maskLayer = CAShapeLayer()
        let maskPath = UIBezierPath(arcCenter: center, radius: (radius + innerRadius) / 2, startAngle: startAngle, endAngle: endAngle, clockwise: true)
        maskLayer.path = maskPath.cgPath
        maskLayer.fillColor = UIColor.clear.cgColor
        maskLayer.strokeColor = UIColor.black.cgColor
        maskLayer.lineWidth = strokeWidth
        maskLayer.strokeEnd = 0.0

        sliceLayer.mask = maskLayer

        let animation = CABasicAnimation(keyPath: "strokeEnd")
        animation.fromValue = 0
        animation.toValue = 1
        animation.duration = Consts.Appearence.animationDuration / Double(data.count)
        animation.beginTime = CACurrentMediaTime() + delay
        animation.fillMode = .forwards
        animation.isRemovedOnCompletion = false

        maskLayer.add(animation, forKey: "strokeEndAnimation")
    }

Upvotes: 1

Views: 145

Answers (1)

Rob
Rob

Reputation: 437632

A few observations:

  1. I would put the shadow on its own layer (in case the shadow is greater than the gap angle; you want to make sure that one layer’s shadow does not overlay another layer’s data).

  2. I would create shape layers for the shadow layer; and each of the data layers.

  3. I would then animate the mask on the whole parent layer.

  4. We should not add layers or perform animation from draw(rect:). (This is used when rending your own animation and is for rendering a single frame; but we are relying on CoreGraphics to do the animation for us, so draw(rect:) is simply not the right place to do this animation.

    Using layoutSubviews would be more logical.

  5. As an aside, whenever you do implement draw(rect:) (which we will not do here), never assume that rect represents the full view. It represents what portion of the view that is being drawn. Use bounds, instead.

  6. Personally, I would calculate the delta for each arc, and then inset the start and end angles for each by one half of the gap radius.

  7. I would be inclined to render each arc as a single arc (not an inner in one direction and an outer arc in the other) unless you absolutely needed that behavior (e.g., you wanted to do some corner rounding of the arcs, themselves). It is not critical to this question, but simplifies the code a bit.

  8. I would calculate the radius from the minimum of the width and height.

That yields:

enter image description here

class AnimatedDataView: UIView {
    let strokeWidth: CGFloat = 30
    var gapSizeDegrees: CGFloat = 5
    let shadowRadius: CGFloat = 5

    var data: [Value] = [
        Value(value: 0.25, color: .red),
        Value(value: 0.33, color: .green),
        Value(value: 1 - 0.25 - 0.33, color: .blue)
    ]

    override func layoutSubviews() {
        super.layoutSubviews()

        start()
    }

    func start() {
        let rect = bounds

        layer.mask = nil
        layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() }

        let totalValue = data.reduce(0) { $0 + $1.value }
        let center = CGPoint(x: rect.midX, y: rect.midY)
        var angle: CGFloat = -.pi / 2
        var cumulativeDelay: CFTimeInterval = 0

        gapSizeDegrees = data.count == 1 ? 0 : gapSizeDegrees
        let radius = min(rect.width, rect.height) / 2 - strokeWidth / 2

        let shadowPath = UIBezierPath()

        let shadowLayer = CAShapeLayer()
        shadowLayer.fillColor = UIColor.clear.cgColor
        shadowLayer.strokeColor = UIColor.black.cgColor
        shadowLayer.lineWidth = strokeWidth
        shadowLayer.shadowOpacity = 1
        shadowLayer.shadowRadius = shadowRadius
        layer.addSublayer(shadowLayer)

        data.forEach { value in
            let segmentValue = CGFloat(value.value)
            let color = value.color
            let delta = (segmentValue / CGFloat(totalValue)) * 2 * .pi
            let endAngle = angle + delta - gapSizeDegrees.toRadians() / 2

            let startAngle = angle + gapSizeDegrees.toRadians() / 2

            let path = UIBezierPath(arcCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
            shadowPath.append(path)

            let shapeLayer = CAShapeLayer()
            shapeLayer.path = path.cgPath
            shapeLayer.fillColor = UIColor.clear.cgColor
            shapeLayer.strokeColor = color.cgColor
            shapeLayer.lineWidth = strokeWidth

            layer.addSublayer(shapeLayer)

            cumulativeDelay += Consts.Appearence.animationDuration / Double(data.count)

            angle += delta
        }

        shadowLayer.path = shadowPath.cgPath

        let maskLayer = CAShapeLayer()
        let maskPath = UIBezierPath(arcCenter: center, radius: radius, startAngle: -.pi / 2, endAngle: 3 * .pi / 2, clockwise: true)
        maskLayer.path = maskPath.cgPath
        maskLayer.fillColor = UIColor.clear.cgColor
        maskLayer.strokeColor = UIColor.black.cgColor
        maskLayer.lineWidth = strokeWidth + shadowRadius

        layer.mask = maskLayer

        let animation = CABasicAnimation(keyPath: "strokeEnd")
        animation.fromValue = 0
        animation.toValue = 1
        animation.duration = Consts.Appearence.animationDuration / Double(data.count)
        animation.fillMode = .forwards
        animation.isRemovedOnCompletion = false

        maskLayer.add(animation, forKey: "strokeEndAnimation")
    }
}

If you want the gaps between the arcs to be parallel and you want the corners rounded, you could do something like:

class AnimatedDataView: UIView {
    let arcWidth: CGFloat = 150
    let cornerRadius: CGFloat = 20
    var gapSize: CGFloat = 8
    let shadowRadius: CGFloat = 4

    var data: [Value] = [
        Value(value: 0.25, color: .red),
        Value(value: 0.33, color: .green),
        Value(value: 1 - 0.25 - 0.33, color: .blue)
    ]

    override func layoutSubviews() {
        super.layoutSubviews()

        start()
    }

    func start() {
        layer.mask = nil
        layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() }

        let totalValue = data.reduce(0) { $0 + $1.value }
        let center = CGPoint(x: bounds.midX, y: bounds.midY)
        var angle: CGFloat = -.pi / 2
        
        let gapSize = data.count == 1 ? 0 : self.gapSize
        let radius = (min(bounds.width, bounds.height) - arcWidth) / 2

        let shadowPath = UIBezierPath()

        let shadowLayer = CAShapeLayer()
        shadowLayer.fillColor = UIColor.black.cgColor
        shadowLayer.strokeColor = UIColor.black.cgColor
        shadowLayer.lineWidth = cornerRadius * 2
        shadowLayer.lineJoin = .round
        shadowLayer.shadowOpacity = 1
        shadowLayer.shadowRadius = shadowRadius
        shadowLayer.shadowOffset = .zero
        layer.addSublayer(shadowLayer)

        data.forEach { value in
            let segmentValue = CGFloat(value.value)
            let color = value.color
            let delta = (segmentValue / CGFloat(totalValue)) * 2 * .pi
            let endAngle = angle + delta

            let path = UIBezierPath(center: center, startAngle: angle, endAngle: endAngle, radius: radius, arcWidth: arcWidth, spacing: gapSize, cornerRadius: cornerRadius)
            shadowPath.append(path)

            let shapeLayer = CAShapeLayer()
            shapeLayer.path = path.cgPath
            shapeLayer.fillColor = color.cgColor
            shapeLayer.strokeColor = color.cgColor
            shapeLayer.lineWidth = cornerRadius * 2
            shapeLayer.lineJoin = .round

            layer.addSublayer(shapeLayer)

            angle = endAngle
        }

        shadowLayer.path = shadowPath.cgPath

        let maskStrokeWidth = sqrt(bounds.width * bounds.width + bounds.height * bounds.height)
        let maskLayer = CAShapeLayer()
        maskLayer.path = UIBezierPath(arcCenter: center, radius: maskStrokeWidth / 2, startAngle: -.pi / 2, endAngle: 3 * .pi / 2, clockwise: true).cgPath
        maskLayer.fillColor = UIColor.clear.cgColor
        maskLayer.strokeColor = UIColor.black.cgColor
        maskLayer.lineWidth = maskStrokeWidth

        layer.mask = maskLayer

        let animation = CABasicAnimation(keyPath: "strokeEnd")
        animation.fromValue = 0
        animation.toValue = 1
        animation.duration = Consts.Appearance.animationDuration
        animation.fillMode = .forwards
        animation.isRemovedOnCompletion = false

        maskLayer.add(animation, forKey: "strokeEndAnimation")
    }
}

See first bullet point under Set specific value to round corners of UIBezierPath for a visualization of how I am rendering the rounded corners.

Anyway, that uses this UIBezierPath extension:

extension UIBezierPath {
    /// Create arc path with corner rounding and spacing between arcs.
    ///
    /// Note, this path assumes that the “corner radius” will be achieved by setting the `lineWidth` to
    /// two times the “corner radius”. We're setting that lineWidth here, but if using in a `CAShapeLayer`
    /// you will need to explicitly set that `lineWidth` for that shape layer, too.
    ///
    /// - Parameters:
    ///     - center: Center of the arc.
    ///     - startAngle: The start angle of the arc. Note, this will be inset by 1/2 of the `spacing` value.
    ///     - endAngle: The end angle of the arc. Note, this will be inset by 1/2 of the `spacing` value.
    ///     - clockwise: Should this be rendered clockwise or counter clockwise. Defaults to `true`.
    ///     - radius: The radius of the arc (the midpoint between the outer edge of the arc and the inner edge).
    ///     - arcWidth: The width of this arc.
    ///     - spacing: The spacing between this arc and the next. This arc is inset by half of this spacing at the start and end of the arc. Defaults to zero.
    ///     - cornerRadius: The corner radius of this thick arc. Defaults to zero.
        
    convenience init(
        center: CGPoint,
        startAngle: CGFloat,
        endAngle: CGFloat,
        clockwise: Bool = true,
        radius: CGFloat,
        arcWidth: CGFloat, 
        spacing: CGFloat = 0,
        cornerRadius: CGFloat = 0
    ) {
        let innerRadius = radius - arcWidth / 2 + cornerRadius
        let innerAngularDelta = asin((cornerRadius + spacing / 2) / innerRadius) * (clockwise ? 1 : -1)
        let outerRadius = radius + arcWidth / 2 - cornerRadius
        let outerAngularDelta = asin((cornerRadius + spacing / 2) / outerRadius) * (clockwise ? 1 : -1)
        
        self.init(arcCenter: center, radius: innerRadius, startAngle: startAngle + innerAngularDelta, endAngle: endAngle - innerAngularDelta, clockwise: clockwise)
        
        addArc(withCenter: center, radius: outerRadius, startAngle: endAngle - outerAngularDelta, endAngle: startAngle + outerAngularDelta, clockwise: !clockwise)
        
        lineWidth = cornerRadius * 2
        
        close()
    }
}

That yields:

enter image description here

(Note, I exaggerated the width of these arcs so that the parallel spacing can clearly be seen.)

Upvotes: 0

Related Questions