Reputation: 2051
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.
Upvotes: 2
Views: 3780
Reputation: 15035
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)
}
}
Upvotes: 0
Reputation: 347
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
Upvotes: 0
Reputation: 438232
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.
Upvotes: 5
Reputation: 2051
In order to ensure a smooth, clockwise animation of the pie chart, you must perform the following steps in order:
CAShapeLayer
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
.
Upvotes: 0
Reputation: 131491
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?
Upvotes: 1