How to Make My Uibezierpath Animated with Cashapelayer

How to make my UIBezierPath animated with CAShapeLayer?

It looks like you're trying to animate within drawRect (indirectly, at least). That doesn't quite make sense. You don't animate within drawRect. The drawRect is used for drawing a single frame. Some animation is done with timers or CADisplayLink that repeatedly calls setNeedsDisplay (which will cause iOS to call your drawRect) during which you might draw the single frame that shows the progress of the animation at that point. But you simply don't have drawRect initiating any animation on its own.

But, since you're using Core Animation's CAShapeLayer and CABasicAnimation, you don't need a custom drawRect at all. Quartz's Core Animation just takes care of everything for you. For example, here is my code for animating the drawing of a UIBezierPath:

#import <QuartzCore/QuartzCore.h>

@interface View ()
@property (nonatomic, weak) CAShapeLayer *pathLayer;
@end

@implementation View

/*
// I'm not doing anything here, so I can comment this out
- (id)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self) {
// Initialization code
}
return self;
}
*/

/*
// Only override drawRect: if you perform custom drawing.
// An empty implementation adversely affects performance during animation.
- (void)drawRect:(CGRect)rect
{
// Drawing code
}
*/

// It doesn't matter what my path is. I could make it anything I wanted.

- (UIBezierPath *)samplePath
{
UIBezierPath *path = [UIBezierPath bezierPath];

// build the path here

return path;
}

- (void)startAnimation
{
if (self.pathLayer == nil)
{
CAShapeLayer *shapeLayer = [CAShapeLayer layer];

shapeLayer.path = [[self samplePath] CGPath];
shapeLayer.strokeColor = [[UIColor grayColor] CGColor];
shapeLayer.fillColor = nil;
shapeLayer.lineWidth = 1.5f;
shapeLayer.lineJoin = kCALineJoinBevel;

[self.layer addSublayer:shapeLayer];

self.pathLayer = shapeLayer;
}

CABasicAnimation *pathAnimation = [CABasicAnimation animationWithKeyPath:@"strokeEnd"];
pathAnimation.duration = 3.0;
pathAnimation.fromValue = @(0.0f);
pathAnimation.toValue = @(1.0f);
[self.pathLayer addAnimation:pathAnimation forKey:@"strokeEnd"];
}

@end

Then, when I want to start drawing the animation, I just call my startAnimation method. I probably don't even need a UIView subclass at all for something as simple as this, since I'm not actually changing any UIView behavior. There are definitely times that you subclass UIView with a custom drawRect implementation, but it's not needed here.

You asked for some references:

  • I would probably start with a review of Apple's Core Animation Programming Guide, if you haven't seen that.
  • For me, it all fell into place when I went through Mike Nachbaur's Core Animation Tutorial Part 4, actually reproducing his demo from scratch. Clearly, you can check out parts 1 through 3, too.

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

Animating multiple UIBezier paths in a single CAShapeLayer

You can definitely use a single CAShapeLayer by combining multiple UIBezierPath using the append(_:) instance method.

The code would looks like something like this:

private func animateSentData(data: WhiteboardData) {

var path = UIBezierPath()
for line in data.lines {
let subPath = UIBezierPath()

for (n, point) in line.pts.enumerated() {
/// Construct your subPath

}
path.append(subPath)
}

let shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath

}

Clarification:

While you can combine multiple path into a single UIBezierPath, you can't animate individual subpaths afterwards.

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.

How do I flip over a UIBezierPath or cgPath thats animated onto the CAShapeLayer?

Using CATransform3D

shapeLayer.transform = CATransform3DMakeScale(1, -1, 1)

Transforming path,

let shapeBounds = shapeLayer.bounds
let mirror = CGAffineTransform(scaleX: 1,
y: -1)
let translate = CGAffineTransform(translationX: 0,
y: shapeBounds.size.height)
let concatenated = mirror.concatenating(translate)

bezierPath.apply(concatenated)

shapeLayer.path = bezierPath.cgPath

Transforming layer,

let shapeFrame = CGRect(x: -120, y: 120, width: 350, height: 350)
let mirrorUpsideDown = CGAffineTransform(scaleX: 1,
y: -1)
shapeLayer.setAffineTransform(mirrorUpsideDown)
shapeLayer.frame = shapeFrame

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

Dash the stroke of UIBezierPath without using the CAShapeLayer and animate this stroke

You should not invoke animations from draw(_:). The draw(_:) is for rendering a single frame.

You say you don't want to use lineDashPattern, but I personally would, using a different shape layer for each pattern. So, for example, here is an animation, stroking one path with no dash pattern, stroking the other with dash pattern, and just triggering the second upon the completion of the first:

struct Stroke {
let start: CGPoint
let end: CGPoint
let lineDashPattern: [NSNumber]?

var length: CGFloat {
return hypot(start.x - end.x, start.y - end.y)
}
}

class CustomView: UIView {

private var strokes: [Stroke]?
private var strokeIndex = 0
private let strokeSpeed = 200.0

func startAnimation() {
strokes = [
Stroke(start: CGPoint(x: bounds.minX, y: bounds.midY),
end: CGPoint(x: bounds.midX, y: bounds.midY),
lineDashPattern: nil),
Stroke(start: CGPoint(x: bounds.midX, y: bounds.midY),
end: CGPoint(x: bounds.maxX, y: bounds.midY),
lineDashPattern: [0, 16])
]
strokeIndex = 0

animateStroke()
}

private func animateStroke() {
guard let strokes = strokes, strokeIndex < strokes.count else { return }

let stroke = strokes[strokeIndex]

let shapeLayer = CAShapeLayer()
shapeLayer.lineCap = kCALineCapRound
shapeLayer.lineDashPattern = strokes[strokeIndex].lineDashPattern
shapeLayer.lineWidth = 8
shapeLayer.strokeColor = UIColor.red.cgColor
layer.addSublayer(shapeLayer)

let path = UIBezierPath()
path.move(to: stroke.start)
path.addLine(to: stroke.end)

shapeLayer.path = path.cgPath

let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = 0
animation.toValue = 1
animation.duration = Double(stroke.length) / strokeSpeed
animation.delegate = self
shapeLayer.add(animation, forKey: nil)
}

}

extension CustomView: CAAnimationDelegate {
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
guard flag else { return }

strokeIndex += 1
animateStroke()
}
}

Sample Image

If you really want to use the draw(_:) approach, you wouldn't use CABasicAnimation, but instead would probably use a CADisplayLink, repeatedly calling setNeedsDisplay(), and having a draw(_:) method that renders the view depending upon how much time has elapsed. But draw(_:) renders a single frame of the animation and should not initiate any CoreAnimation calls.


If you really don't want to use shape layers, you can use the aforementioned CADisplayLink to update the percent complete based upon the elapsed time and desired duration, and draw(_:) only strokes as many of the individual paths as appropriate for any given moment in time:

struct Stroke {
let start: CGPoint
let end: CGPoint
let length: CGFloat // in this case, because we're going call this a lot, let's make this stored property
let lineDashPattern: [CGFloat]?

init(start: CGPoint, end: CGPoint, lineDashPattern: [CGFloat]?) {
self.start = start
self.end = end
self.lineDashPattern = lineDashPattern
self.length = hypot(start.x - end.x, start.y - end.y)
}
}

class CustomView: UIView {

private var strokes: [Stroke]?
private let duration: CGFloat = 3.0
private var start: CFTimeInterval?
private var percentComplete: CGFloat?
private var totalLength: CGFloat?

func startAnimation() {
strokes = [
Stroke(start: CGPoint(x: bounds.minX, y: bounds.midY),
end: CGPoint(x: bounds.midX, y: bounds.midY),
lineDashPattern: nil),
Stroke(start: CGPoint(x: bounds.midX, y: bounds.midY),
end: CGPoint(x: bounds.maxX, y: bounds.midY),
lineDashPattern: [0, 16])
]
totalLength = strokes?.reduce(0.0) { $0 + $1.length }

start = CACurrentMediaTime()
let displayLink = CADisplayLink(target: self, selector: #selector(handleDisplayLink(_:)))
displayLink.add(to: .main, forMode: .commonModes)
}

@objc func handleDisplayLink(_ displayLink: CADisplayLink) {
percentComplete = min(1.0, CGFloat(CACurrentMediaTime() - start!) / duration)
if percentComplete! >= 1.0 {
displayLink.invalidate()
percentComplete = 1
}

setNeedsDisplay()
}

// Note, no animation is in the following routine. This just stroke your series of paths
// until the total percent of the stroked path equals `percentComplete`. The animation is
// achieved above, by updating `percentComplete` and calling `setNeedsDisplay`. This method
// only draws a single frame of the animation.

override func draw(_ rect: CGRect) {
guard let totalLength = totalLength,
let strokes = strokes,
strokes.count > 0,
let percentComplete = percentComplete else { return }

UIColor.red.setStroke()

// Don't get lost in the weeds here; the idea is to simply stroke my paths until the
// percent of the lengths of all of the stroked paths reaches `percentComplete`. Modify
// the below code to match whatever model you use for all of your stroked paths.

var lengthSoFar: CGFloat = 0
var percentSoFar: CGFloat = 0
var strokeIndex = 0
while lengthSoFar / totalLength < percentComplete && strokeIndex < strokes.count {
let stroke = strokes[strokeIndex]
let endLength = lengthSoFar + stroke.length
let endPercent = endLength / totalLength
let percentOfThisStroke = (percentComplete - percentSoFar) / (endPercent - percentSoFar)
var end: CGPoint
if percentOfThisStroke < 1 {
let angle = atan2(stroke.end.y - stroke.start.y, stroke.end.x - stroke.start.x)
let distance = stroke.length * percentOfThisStroke
end = CGPoint(x: stroke.start.x + distance * cos(angle),
y: stroke.start.y + distance * sin(angle))
} else {
end = stroke.end
}
let path = UIBezierPath()
if let pattern = stroke.lineDashPattern {
path.setLineDash(pattern, count: pattern.count, phase: 0)
}
path.lineWidth = 8
path.lineCapStyle = .round
path.move(to: stroke.start)
path.addLine(to: end)
path.stroke()

strokeIndex += 1
lengthSoFar = endLength
percentSoFar = endPercent
}
}
}

This achieves the identical effect as the first code snippet, though likely it isn't going to be anywhere near as efficient.



Related Topics



Leave a reply



Submit