简体   繁体   中英

Swift 3: Animate color fill of arc added to UIBezierPath

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.

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:

在此处输入图片说明

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:

在此处输入图片说明

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:

在此处输入图片说明

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.

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 .

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:

时钟擦除动画

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?

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

在此处输入图像描述 I 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)
    }
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM