Swift 3: Animate color fill of arc added to UIBezierPath
Asked Answered
T

5

2

I wish to animate the color fill of a section of a pie chart. I create the pie chart by creating a UIBezierPath() for each piece of the pie and then use the addArc method to specify the size/constraints of the arc. To animate the pie chart segment, I want the color fill of the arc to animate from the center of the circle to the radius end. However, I am having trouble. I heard the strokeEnd keyPath animated from 0 to 1 should work, but there is no animation happening on the arcs (the arcs are just appearing at app launch).

let rad = 2 * Double.pi
let pieCenter: CGPoint = CGPoint(x: frame.width / 2, y: frame.height / 2)
var start: Double = 0
for i in 0...data.count - 1 {
    let size: Double = Double(data[i])! / 100 // the percentege of the circle that the given arc will take
    let end: Double = start + (size * rad)

    let path = UIBezierPath()
    path.move(to: pieCenter)
    path.addArc(withCenter: pieCenter, radius: frame.width / 3, startAngle: CGFloat(start), endAngle: CGFloat(end), clockwise: true)

    start += size * rad

    let lineLayer = CAShapeLayer()
    lineLayer.bounds = self.bounds
    lineLayer.position = self.layer.position
    lineLayer.path = path.cgPath
    lineLayer.strokeColor = UIColor.white.cgColor
    lineLayer.fillColor = colors[i]
    lineLayer.lineWidth = 0

    self.layer.addSublayer(lineLayer)

    let animation = CABasicAnimation(keyPath: "strokeEnd")
    animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
    animation.fillMode = kCAFillModeForwards
    animation.fromValue = pieCenter
    animation.toValue = frame.width / 3 // radius
    animation.duration = 2.5

    lineLayer.add(animation, forKey: nil)
}

I've seen a solution to a similar problem here, but it does not work for the individual arcs.

Thrill answered 27/6, 2017 at 16:9 Comment(3)
Any animation on the fill color would help me out. Right now, animation in the posted code does nothing.Thrill
You want the color to spread from the center of the circle out to the edges? Or does Rob's cross-fade animation meet your needs?Steviestevy
See my answer below. I wanted a clockwise fill animation of the whole pie chart. The solution was to create a mask that was added to each pie slice.Thrill
T
0

In order to ensure a smooth, clockwise animation of the pie chart, you must perform the following steps in order:

  • Create a new parent layer of type CAShapeLayer
  • In a loop each pie chart slice to the parent layer
  • In another loop, iterate through the sublayers (pie slices) of the parent layer and assign each sublayer a mask and animate that mask in the loop
  • Add the parent layer to the main layer: self.layer.addSublayer(parentLayer)

In a nutshell, the code will look like this:

// Code above this line adds the pie chart slices to the parentLayer
for layer in parentLayer.sublayers! {
    // Code in this block creates and animates the same mask for each layer
}

Each animation applied to each pie slice will be a strokeEnd keypath animation from 0 to 1. When creating the mask, be sure its fillColor property is set to UIColor.clear.cgColor.

Thrill answered 28/6, 2017 at 8:30 Comment(0)
V
5

When you animate strokeEnd, that animates the stroke around the path, but not the fill of the path.

If you're looking for just any animation of the fill, easy options include animating the fillColor key path from UIColor.clear.cgColor to the final color. Or animate the opacity key path from 0 to 1.

func addPie(_ animated: Bool = true) {
    shapeLayers.forEach { $0.removeFromSuperlayer() }
    shapeLayers.removeAll()

    guard let dataPoints = dataPoints else { return }

    let center = pieCenter
    let radius = pieRadius
    var startAngle = -CGFloat.pi / 2
    let sum = dataPoints.reduce(0.0) { $0 + $1.value }

    for (index, dataPoint) in dataPoints.enumerated() {
        let endAngle = startAngle + CGFloat(dataPoint.value / sum) * 2 * .pi
        let path = closedArc(at: center, with: radius, start: startAngle, end: endAngle)
        let shape = CAShapeLayer()
        shape.fillColor = dataPoint.color.cgColor
        shape.strokeColor = UIColor.black.cgColor
        shape.lineWidth = lineWidth
        shape.path = path.cgPath
        layer.addSublayer(shape)
        shapeLayers.append(shape)
        shape.frame = bounds

        if animated {
            shape.opacity = 0

            DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) / Double(dataPoints.count)) {
                shape.opacity = 1
                let animation = CABasicAnimation(keyPath: "opacity")
                animation.fromValue = 0
                animation.toValue = 1
                animation.duration = 1
                shape.add(animation, forKey: nil)
            }
        }

        startAngle = endAngle
    }
}

That yields:

enter image description here

The delaying of the animations give it a slightly more dynamic effect.

If you want to get fancy, you can play around with animations of transform of the entire CAShapeLayer. For example, you can scale the pie wedges:

func addPie(_ animated: Bool = true) {
    shapeLayers.forEach { $0.removeFromSuperlayer() }
    shapeLayers.removeAll()

    guard let dataPoints = dataPoints else { return }

    let center = pieCenter
    let radius = pieRadius
    var startAngle = -CGFloat.pi / 2
    let sum = dataPoints.reduce(0.0) { $0 + $1.value }

    for (index, dataPoint) in dataPoints.enumerated() {
        let endAngle = startAngle + CGFloat(dataPoint.value / sum) * 2 * .pi
        let path = closedArc(at: center, with: radius, start: startAngle, end: endAngle)
        let shape = CAShapeLayer()
        shape.fillColor = dataPoint.color.cgColor
        shape.strokeColor = UIColor.black.cgColor
        shape.lineWidth = lineWidth
        shape.path = path.cgPath
        layer.addSublayer(shape)
        shapeLayers.append(shape)
        shape.frame = bounds

        if animated {
            shape.opacity = 0

            DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) / Double(dataPoints.count) + 1) {
                shape.opacity = 1
                let animation = CABasicAnimation(keyPath: "transform")
                animation.fromValue = CATransform3DMakeScale(0, 0, 1)
                animation.toValue = CATransform3DIdentity
                animation.duration = 1
                shape.add(animation, forKey: nil)
            }
        }

        startAngle = endAngle
    }
}

Yielding:

enter image description here

Or you can rotate the pie wedge shape layer about its center angle making it appear to angularly expand:

func addPie(_ animated: Bool = true) {
    shapeLayers.forEach { $0.removeFromSuperlayer() }
    shapeLayers.removeAll()

    guard let dataPoints = dataPoints else { return }

    let center = pieCenter
    let radius = pieRadius
    var startAngle = -CGFloat.pi / 2
    let sum = dataPoints.reduce(0.0) { $0 + $1.value }

    for (index, dataPoint) in dataPoints.enumerated() {
        let endAngle = startAngle + CGFloat(dataPoint.value / sum) * 2 * .pi
        let path = closedArc(at: center, with: radius, start: startAngle, end: endAngle)
        let shape = CAShapeLayer()
        shape.fillColor = dataPoint.color.cgColor
        shape.strokeColor = UIColor.black.cgColor
        shape.lineWidth = lineWidth
        shape.path = path.cgPath
        layer.addSublayer(shape)
        shapeLayers.append(shape)
        shape.frame = bounds

        if animated {
            shape.opacity = 0

            let centerAngle = startAngle + CGFloat(dataPoint.value / sum) * .pi
            let transform = CATransform3DMakeRotation(.pi / 2, cos(centerAngle), sin(centerAngle), 0)

            DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) / Double(dataPoints.count)) {
                shape.opacity = 1
                let animation = CABasicAnimation(keyPath: "transform")
                animation.fromValue = transform
                animation.toValue = CATransform3DIdentity
                animation.duration = 1
                shape.add(animation, forKey: nil)
            }
        }

        startAngle = endAngle
    }
}

That yields:

enter image description here

I'd encourage you to not get too lost in the details of my CAShapeLayer and my model, but rather focus on the CABasicAnimation and the various keyPath values we can animate.

Valrievalry answered 27/6, 2017 at 18:1 Comment(3)
What is lineWidth?Thrill
A random property that holds the width of the stroke. It's not relevant here. Don't get lost in those details. Focus on the CABasicAnimation code, where I animate the transform property from something that rotates .pi / 2 about the middle of the pie wedge back to identity. But see my full code here gist.github.com/robertmryan/17b4c1e0e09afeaca6ad6e183d315e3c.Valrievalry
Clever use of rotation. At first I couldn't figure out how you were getting that "expand outward from the center" look. It's because you're rotating around a radius of the circle centered on each wedge, and since the layers don't show perspective, it looks like the wedges are simply getting wider.Steviestevy
S
1

It sounds like what you are after is a "clock wipe" effect that reveals your graph. If that's the case then there is a simpler way than creating a separate mask layer for each separate wedge of your pie chart. Instead, make each wedge of your graph a sublayer of a single layer, install a mask layer on that super-layer, and run a clock wipe animation on that super layer.

Here is a GIF illustrating a clock wipe animation of a static image:

Clock wipe animation

I wrote a post explaining how to do it, and linking to a Github project demonstrating it:

How do you achieve a "clock wipe"/ radial wipe effect in iOS?

Steviestevy answered 29/6, 2017 at 22:26 Comment(5)
Yes I've seen your solution plenty of times lol. I used a variation of your solution which applied the same layer mask to each pie slice (sublayer).Thrill
Why? Do you want each slice of the pie chart to animate into place at the same time?Steviestevy
No, the pie chart animates just like your image above. If the mask's path is a CAMutablePath arc from 0 to 2PI and animation is strokeEnd, applying that same mask to each sublayer produces the "clock-wipe" effect. Your solution only works if we want to do a "clock-wipe" over one layer with no sublayersThrill
I haven't tried it, but why can't you make all of the sublayers child layers of a layer to which you attach the mask and animate the clock wipe on the parent layer?Steviestevy
Intuitively that’s how you think it would work, but it didn’t work for me until I added different instances of the same mask to each sublayerThrill
T
0

In order to ensure a smooth, clockwise animation of the pie chart, you must perform the following steps in order:

  • Create a new parent layer of type CAShapeLayer
  • In a loop each pie chart slice to the parent layer
  • In another loop, iterate through the sublayers (pie slices) of the parent layer and assign each sublayer a mask and animate that mask in the loop
  • Add the parent layer to the main layer: self.layer.addSublayer(parentLayer)

In a nutshell, the code will look like this:

// Code above this line adds the pie chart slices to the parentLayer
for layer in parentLayer.sublayers! {
    // Code in this block creates and animates the same mask for each layer
}

Each animation applied to each pie slice will be a strokeEnd keypath animation from 0 to 1. When creating the mask, be sure its fillColor property is set to UIColor.clear.cgColor.

Thrill answered 28/6, 2017 at 8:30 Comment(0)
R
0

reduce the arc radius to half and make the line twice as thick as the radius

let path = closedArc(at: center, with: radius * 0.5, start: startAngle, end: endAngle)
lineLayer.lineWidth = radius

set the fillColor to clear

Rhineland answered 24/8, 2017 at 13:0 Comment(0)
D
0

enter image description hereI may be very late to the party. But if anyone are looking for smooth circle Pie chart animation try this:-

In your ViewController class use below

class YourViewController: UIViewController {
@IBOutlet weak var myView: UIView!
let chartView = PieChartView()
    override func viewDidLoad() {
        super.viewDidLoad()
        
        myView.addSubview(chartView)
        chartView.translatesAutoresizingMaskIntoConstraints = false
        chartView.leftAnchor.constraint(equalTo: myView.leftAnchor).isActive = true
        chartView.rightAnchor.constraint(equalTo: myView.rightAnchor).isActive = true
        chartView.topAnchor.constraint(equalTo: myView.topAnchor).isActive = true
        chartView.bottomAnchor.constraint(equalTo: myView.bottomAnchor).isActive = true
        // Do any additional setup after loading the view.
    }

override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        let segments = [Segment(value: 70, color: .systemBlue), Segment(value: 40, color: .systemCyan), Segment(value: 5, color: .systemTeal), Segment(value: 4, color: .systemMint), Segment(value: 5, color: .systemPurple)]
        
        chartView.segments = segments
    }
}

Create below PieChartView class which will animate pieChart as well

class PieChartView: UIView {

    /// An array of structs representing the segments of the pie chart
    var pieSliceLayer = CAShapeLayer()
    var count = 0
    var startAngle = -CGFloat.pi * 0.5
    var segments = [Segment]() {
        didSet {
            setNeedsDisplay() // re-draw view when the values get set
        }
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        isOpaque = false // when overriding drawRect, you must specify this to maintain transparency.
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    override func draw(_ rect: CGRect) {
        addSlices()
      }
    
    
    func addSlices() {
        guard count < segments.count else {return}
        let radius = min(bounds.width, bounds.height) / 2.0 - 20
        let center: CGPoint = CGPoint(x: bounds.midX, y: bounds.midY)
        let valueCount = segments.reduce(0, {$0 + $1.value})
        for value in segments {
            let pieSliceLayer = CAShapeLayer()
            pieSliceLayer.strokeColor = value.color.cgColor
            pieSliceLayer.fillColor = UIColor.clear.cgColor
            pieSliceLayer.lineWidth = radius
            layer.addSublayer(pieSliceLayer)
            let endAngle = CGFloat(value.value) / valueCount * CGFloat.pi * 2.0 + startAngle
            pieSliceLayer.path = UIBezierPath(arcCenter: center, radius: radius/2, startAngle: startAngle, endAngle: endAngle, clockwise: true).cgPath
            startAngle = endAngle
        }
        pieSliceLayer.strokeColor = UIColor.white.cgColor
        pieSliceLayer.fillColor = UIColor.clear.cgColor
        pieSliceLayer.lineWidth = radius
        let startAngle: CGFloat = 3 * .pi / 2
        let endAngle: CGFloat =  -3 * .pi / 2
        pieSliceLayer.path = UIBezierPath(arcCenter: center, radius: radius/2, startAngle: startAngle, endAngle: endAngle, clockwise: false).cgPath
        pieSliceLayer.strokeEnd = 1
        layer.addSublayer(pieSliceLayer)
        startCircleAnimation()
    }
    
    private func startCircleAnimation() {
        pieSliceLayer.strokeEnd = 0
        let animation = CABasicAnimation(keyPath: "strokeEnd")
        animation.fromValue = 1
        animation.toValue = 0
        animation.duration = 1.0
        animation.timingFunction = CAMediaTimingFunction(controlPoints: 0.42, 0.00, 0.09, 1.00)
        pieSliceLayer.add(animation, forKey: nil)
    }
}
Dalmatic answered 10/2, 2022 at 23:20 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.