Uibezierpath + Cashapelayer - Animate a Circle Filling Up

Animate CAShapeLayer with circle UIBezierPath and CABasicAnimation

You can't easily animate the fill of a UIBezierPath (or at least without introducing weird artifacts except in nicely controlled situations). But you can animate the strokeEnd of a path of the CAShapeLayer. And if you make the line width of the stroked path really wide (i.e. the radius of the final circle), and set the radius of the path to be half of that of the circle, you get something like what you're looking for.

private var circleLayer = CAShapeLayer()

private func configureCircleLayer() {
let radius = min(circleView.bounds.width, circleView.bounds.height) / 2

circleLayer.strokeColor = UIColor(hexCode: "EA535D").cgColor
circleLayer.fillColor = UIColor.clear.cgColor
circleLayer.lineWidth = radius
circleView.layer.addSublayer(circleLayer)

let center = CGPoint(x: circleView.bounds.width/2, y: circleView.bounds.height/2)
let startAngle: CGFloat = -0.25 * 2 * .pi
let endAngle: CGFloat = startAngle + 2 * .pi
circleLayer.path = UIBezierPath(arcCenter: center, radius: radius / 2, startAngle: startAngle, endAngle: endAngle, clockwise: true).cgPath

circleLayer.strokeEnd = 0
}

private func startCircleAnimation() {
circleLayer.strokeEnd = 1
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = 0
animation.toValue = 1
animation.duration = 15
circleLayer.add(animation, forKey: nil)
}

For ultimate control, when doing complex UIBezierPath animations, you can use CADisplayLink, avoiding artifacts that can sometimes result when using CABasicAnimation of the path:

private var circleLayer = CAShapeLayer()
private weak var displayLink: CADisplayLink?
private var startTime: CFTimeInterval!

private func configureCircleLayer() {
circleLayer.fillColor = UIColor(hexCode: "EA535D").cgColor
circleView.layer.addSublayer(circleLayer)
updatePath(percent: 0)
}

private func startCircleAnimation() {
startTime = CACurrentMediaTime()
displayLink = {
let _displayLink = CADisplayLink(target: self, selector: #selector(handleDisplayLink(_:)))
_displayLink.add(to: .current, forMode: .commonModes)
return _displayLink
}()
}

@objc func handleDisplayLink(_ displayLink: CADisplayLink) { // the @objc qualifier needed for Swift 4 @objc inference
let percent = CGFloat(CACurrentMediaTime() - startTime) / 15.0
updatePath(percent: min(percent, 1.0))
if percent > 1.0 {
displayLink.invalidate()
}
}

private func updatePath(percent: CGFloat) {
let w = circleView.bounds.width
let center = CGPoint(x: w/2, y: w/2)
let startAngle: CGFloat = -0.25 * 2 * .pi
let endAngle: CGFloat = startAngle + percent * 2 * .pi
let path = UIBezierPath()
path.move(to: center)
path.addArc(withCenter: center, radius: w/2, startAngle: startAngle, endAngle: endAngle, clockwise: true)
path.close()

circleLayer.path = path.cgPath
}

Then you can do:

override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)

configureCircleLayer()
startCircleAnimation()
}

override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)

displayLink?.invalidate() // to avoid displaylink keeping a reference to dismissed view during animation
}

That yields:

animated circle

Filling circle animation with UIBezierPath and iOS draw rect

I ended up with the following solution:

fileprivate func drawPathCompleted(_ ovalRect: CGRect) {

var size: CGFloat = ovalRect.size.width

var current = size*(1-progress)

var pos: CGFloat = lineWidth/2

while size > current {
let ovalPath = UIBezierPath(ovalIn: CGRect(x: pos, y: pos, width: size, height: size))
ovalPath.lineCapStyle = .round
ovalPath.lineWidth = lineWidth
ovalPath.stroke()
let decr = lineWidth/2
size -= decr
pos += decr/2
}
}

At this commit, line 243:
https://github.com/evertoncunha/EPCSpinnerView/commit/87d968846d92aa97e85ff4c58b6664ad7b03f00b#diff-8dc22814328ed859a00acfcf2909854bR242

Animating a circular UIBezierPath

Using CAShapeLayer is much easier and cleaner. The reason is that CAShapeLayer includes properties strokeStart and strokeEnd. These values range from 0 (the beginning of the path) to 1 (the end of the path) and are animatable.

By changing them you can easily draw any arc of your circle (or any part of an arbitrary path, for that matter.) The properties are animatable, so you can create an animation of a growing/shrinking pie slice or section of a ring shape. It's much easier and more performant than implementing code in drawRect.

Swift 3: Animate color fill of arc added to UIBezierPath

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.

CAShapeLayer + UIBezierPath fill animation is unfilling shape instead of filling?

You are animating the mask from 0 to a finite height. Instead change the position of the mask from bottom to top position.

var animateOutlineFromBottom = CABasicAnimation(keyPath: "position")
animateOutlineFromBottom.fromValue = NSValue(CGPoint: CGPointMake(0, heartRect.height))
animateOutlineFromBottom.toValue = NSValue(CGPoint: CGPointMake(0,0))
animateOutlineFromBottom.duration = 3.0
animateOutlineFromBottom.fillMode = kCAFillModeForwards

// Other code

outlineShape.addAnimation(animateOutlineFromBottom, forKey:"position")

Animating CAShapeLayer path smoothly

You can't use the Bezier path addArc() function to animate an arc and change the arc distance.

The problem is control points. In order for an animation to work smoothly, the starting and ending shape must have the same number and type of control points. Under the covers, the UIBezierPath (and CGPath) objects create arcs approximating a circle by combining Bezier curves (I don't remember if it uses Quadratic or Cubic Bezier curves.) The entire circle is made up of multiple connected Bezier curves ("Bezier" the mathematical spline function, not UIBeizerPath, which is a UIKit function that creates shapes that can include Bezier paths.) I seem to remember a Bezier approximation of a circle is made up of 4 linked cubic Bezier curves. (See this SO answer for a discussion of what that looks like, if you're interested.)

Here is my understanding of how it works. (I might have the details wrong, but it illustrates the problem in any case.) As you move from <= 1/4 of a full circle to > 1/4 of a full circle, the arc function will use first 1 cubic Bezier section, then 2. At the transition from <= 1/2 of a circle to > 1/2 of a circle, it will shift to 3 Bezier curves, and at the transition from <= 3/4 of a circle to > 3/4 of a circle, it will switch to 4 Bezier curves.

The solution:

You are on the right track with using strokeEnd. Always create your shape as the full circle, and set strokeEnd to something less than 1. That will give you a part of a circle, but in a way that you can animate smoothly. (You can animate strokeStart as well.)

I've animated circles just like you describe using CAShapeLayer and strokeEnd (It was a number of years ago, so it was in Objective-C.) I wrote an article here on OS on using the approach to animate a mask on a UIImageView and create a "clock wipe" animation. If you have an image of your full shaded circle you could use that exact approach here. (You should be able to add a mask layer to any UIView's content layer or other layer, and animate that layer as in my clock wipe demo. Let me know if you need help deciphering the Objective-C.

Here is the sample clock wipe animation I created:

Clock wipe animation

Note that you can use this effect to mask any layer, not just an image view.

EDIT: I posted an update to my clock wipe animation question and answer with a Swift version of the project.

You can get to the new repo directly at https://github.com/DuncanMC/ClockWipeSwift.

For your application I would set up the parts of your gauge that you need to animate as a composite of layers. You'd then attach a CAShapeLayer based mask layer to that composite layer and add a circle arc path to that shape layer and animate the strokeEnd as shown in my sample project. My clock wipe animation reveals the image like the sweep of a clock hand from the center of the layer. In your case you'd center the arc on the bottom center of your layer, and only use a half-circle arc in your shape layer. Using a mask that way would give you a sharp-edged crop to your composited layer. you'd lose the round end caps on your red arc. To fix that you'd have to animate the red arc as it's own shape layer (using strokeEnd) and animate the gradient fill's arc strokeEnd separately.

Does animating the fill color of a `UIBezierPath` require a new scan-convert?

First, layoutSubviews() is absolutely NOT the place to put addGestureRecognizer() ... that should be done during initialization.

Second, you'll probably have much better results by using a CAShapeLayer instead of overriding draw().

Give this a try:

class ChessPieceView: UIView {
let shapeLayer = CAShapeLayer()

init(fromFrame frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor.cyan
layer.addSublayer(shapeLayer)
shapeLayer.fillColor = UIColor.red.cgColor
addGestureRecognizer(UITapGestureRecognizer(target: self,
action: #selector(tapPiece(_ :))))
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
let r: CGFloat = 30
let rect = CGRect(x: bounds.midX - r, y: bounds.midY - r, width: r * 2, height: r * 2)
shapeLayer.path = UIBezierPath(ovalIn: rect).cgPath
}
@objc func tapPiece(_ gestureRecognizer : UITapGestureRecognizer ) {
guard gestureRecognizer.view != nil else { return }
if gestureRecognizer.state == .ended {
let animator = UIViewPropertyAnimator(duration: 1.2,
curve: .easeInOut,
animations: {
self.center.x += 100
self.center.y += 200
self.backgroundColor = UIColor.yellow
})
animator.startAnimation()
}
}
}

Edit

Re-reading your question, it looks like you want to animate the fill-color of the circle.

In that case, you can use the shape layer as a mask. This class will draw a red-filled circle... tapping it will animate its position and animate the red-to-yellow color change:

class ChessPieceView: UIView {
let shapeLayer = CAShapeLayer()

init(fromFrame frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor.red
shapeLayer.fillColor = UIColor.black.cgColor
addGestureRecognizer(UITapGestureRecognizer(target: self,
action: #selector(tapPiece(_ :))))
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
let r: CGFloat = 30
let rect = CGRect(x: bounds.midX - r, y: bounds.midY - r, width: r * 2, height: r * 2)
shapeLayer.path = UIBezierPath(ovalIn: rect).cgPath
layer.mask = shapeLayer
}
@objc func tapPiece(_ gestureRecognizer : UITapGestureRecognizer ) {
guard gestureRecognizer.view != nil else { return }
if gestureRecognizer.state == .ended {
let animator = UIViewPropertyAnimator(duration: 1.2,
curve: .easeInOut,
animations: {
self.center.x += 100
self.center.y += 200
self.backgroundColor = UIColor.yellow
})
animator.startAnimation()
}
}
}

Edit 2

This example uses a subview to provide a cyan rectangle with a round red view in the center... each tap will animate the position of the rectangle, and the color of the circle in its center:

class ChessViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let rect = CGRect(origin: CGPoint(x: 50, y: 100),
size: CGSize(width: 100, height: 100))
let chessPieceView = ChessPieceView.init(fromFrame: rect)
self.view.addSubview(chessPieceView)
}
}

// ChessPieceView.swift

class ChessPieceView: UIView {
let circleView = UIView()
let shapeLayer = CAShapeLayer()
var startCenter: CGPoint!

init(fromFrame frame: CGRect) {
super.init(frame: frame)
startCenter = self.center
backgroundColor = UIColor.cyan
addSubview(circleView)
circleView.backgroundColor = .red
addGestureRecognizer(UITapGestureRecognizer(target: self,
action: #selector(tapPiece(_ :))))
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
let r: CGFloat = 30
circleView.frame = CGRect(x: bounds.midX - r, y: bounds.midY - r, width: r * 2, height: r * 2)
circleView.layer.cornerRadius = r
}
@objc func tapPiece(_ gestureRecognizer : UITapGestureRecognizer ) {
guard gestureRecognizer.view != nil else { return }
if gestureRecognizer.state == .ended {
let targetCenter: CGPoint = self.center.x == startCenter.x ? CGPoint(x: startCenter.x + 100, y: startCenter.y + 200) : startCenter
let targetColor: UIColor = self.circleView.backgroundColor == .red ? .yellow : .red
let animator = UIViewPropertyAnimator(duration: 1.2,
curve: .easeInOut,
animations: {
self.center = targetCenter
self.circleView.backgroundColor = targetColor
})
animator.startAnimation()
}
}
}

Result:

Sample Image

Create rotation animation for UIBezierPath

Instead of animating the rotation, animate lineDashPhase property of the CAShapeLayer

extension UIView {
func addDashedCircle() {
let circleLayer = CAShapeLayer()
circleLayer.path = UIBezierPath(arcCenter: CGPoint(x: frame.size.width/2, y: frame.size.height/2),
radius: frame.size.width/2,
startAngle: 0,
endAngle: .pi * 2,
clockwise: true).cgPath
circleLayer.lineWidth = 3.0
circleLayer.strokeColor = UIColor.red.cgColor
circleLayer.fillColor = UIColor.white.cgColor
circleLayer.lineJoin = .round
circleLayer.lineDashPattern = [1,10]

let animation = CABasicAnimation(keyPath: "lineDashPhase")
animation.fromValue = 0
animation.toValue = -11//1+10
animation.duration = 1
animation.repeatCount = .infinity
circleLayer.add(animation, forKey: "line")

layer.addSublayer(circleLayer)
}
}

Sample Image



Related Topics



Leave a reply



Submit