iosswiftuikitcashapelayer

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

Solution

  • 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.)