is there a nice way to calculate the position of a path (CGPath or UIBezierPath) at a given time (from 0 to 1)?
Using CAShapeLayer for example, one can create an animated stroke end.
I want to know the position of that stroke end at arbitrary times.
Thanks in advance, Adrian
You can definitely base your approach on the CADisplayLink and a tracking layer. However, if you don't mind doing a little bit of math on your own, the solution is not too complicated. Plus, you wont have to depend on setting up a display link and extra layers. In fact, you dont even have to depend on QuartzCore.
The following will work for any CGPathRef. In case of a UIBezierPath, fetch the CGPath property of the same:
- Use
CGPathApply
on the path you want to introspect along with a custom CGPathApplierFunction
function.
- Your
CGPathApplierFunction
will be invoked for each component of that path. The CGPathElement (an argument to the applier) will tell you what kind of a path element it is along with the points that make that element (control points or endpoints).
- You will be given one, two, three and four points for
kCGPathElementMoveToPoint
, kCGPathElementAddLineToPoint
, kCGPathElementAddQuadCurveToPoint
and kCGPathElementAddCurveToPoint
respectively.
- Store these points internally in a representation of your choosing. You only need to use the
CGPathApply
once per path and this step is extremely fast.
Now, onto the math:
- Based on the time you wish to find the position at, say
t
, get the element (more on this later) and its constituent points.
- If the element type is
kCGPathElementMoveToPoint
, its a linear interpolation p0 + t * (p1 - p0)
(for x and y)
- If the element type is
kCGPathElementAddQuadCurveToPoint
, its quadratic ((1 - t) * (1 - t)) * p0 + 2 * (1 - t) * t * p1 + t * t * p2
- If the element type is
kCGPathElementAddCurveToPoint
, its a cubic bezier ((1 - t) * (1 - t) * (1 - t)) * p0 + 3 * (1 - t) * (1 - t) * t * p1 + 3 * (1 - t) * t * t * p2 + t * t * t * p3
Now the question remains, how do you figure out the path element at time t
. You can assume each path element gets an equal time slice or you can calculate the distance of each element and account for the fractional time (the former approach works fine for me). Also, don't forget to add the times for all previous path elements (you dont have to find the interpolations for these).
As I said, this is just for completeness (and likely how Apple figures out this stuff out themselves) and only if you are willing to do the math.
Building on Matt's display link answer, you can track the position of the end point by creating a second "invisible" keyframe animation.
NOTES:
- with this technique you don't need to calculate the position of the end point yourself
- you can use any path shape.
- this was written in the view controller class of a basic iOS Single View Application template in Xcode
We start with 3 properties:
@interface ViewController ()
@property (nonatomic, strong) CADisplayLink *displayLink;
@property (nonatomic, strong) CAShapeLayer *pathLayer;
@property (nonatomic, strong) CALayer *trackingLayer;
@end
The displayLink
will allow us to run code every time the screen updates.
The pathLayer
provides the visuals, the one that we'll animate.
The trackingLayer
provides an invisible layer that we'll use to track the position of the strokeEnd
animation on the pathLayer
.
We open our view controller like so:
@implementation ViewController
- (void)viewDidLoad
{
[super viewDidLoad];
[self createDisplayLink];
[self createPathLayer];
[self createTrackingLayer];
[self startAnimating];
}
...
With the following methods...
We first create the display link and add it to the run loop (as per Matt's code):
-(void)createDisplayLink {
_displayLink = [CADisplayLink
displayLinkWithTarget:self
selector:
@selector(displayLinkDidUpdate:)];
[_displayLink
addToRunLoop:[NSRunLoop mainRunLoop]
forMode:NSDefaultRunLoopMode];
}
We then create the visible layer:
-(void)createPathLayer {
//create and style the path layer
//add it to the root layer of the view controller's view
_pathLayer = [CAShapeLayer layer];
_pathLayer.bounds = CGRectMake(0,0,100,100);
_pathLayer.path = CGPathCreateWithEllipseInRect(_pathLayer.bounds, nil);
_pathLayer.fillColor = [UIColor clearColor].CGColor;
_pathLayer.lineWidth = 5;
_pathLayer.strokeColor = [UIColor blackColor].CGColor;
_pathLayer.position = self.view.center;
[self.view.layer addSublayer:_pathLayer];
}
We then create an "invisible" (i.e. via a frame with no dimensions) layer to track:
-(void)createTrackingLayer {
_trackingLayer = [CALayer layer];
//set the frame (NOT bounds) so that we can see the layer
//uncomment the following two lines to see the tracking layer
//_trackingLayer.frame = CGRectMake(0,0,5,5);
//_trackingLayer.backgroundColor = [UIColor redColor].CGColor;
//we add the blank layer to the PATH LAYER
//so that its coordinates are always in the path layer's coordinate system
[_pathLayer addSublayer:_trackingLayer];
}
We then create a method that grabs the position of the tracking layer:
- (void)displayLinkDidUpdate:(CADisplayLink *)sender {
//grab the presentation layer of the blank layer
CALayer *presentationLayer = [_trackingLayer presentationLayer];
//grab the position of the blank layer
//convert it to the main view's layer coordinate system
CGPoint position = [self.view.layer convertPoint:presentationLayer.position
fromLayer:_trackingLayer];
//print it out, or do something else with it
NSLog(@"%4.2f,%4.2f",position.x,position.y);
}
... and the startAnimating
method:
-(void)startAnimating {
//begin the animation transaction
[CATransaction begin];
//create the stroke animation
CABasicAnimation *strokeEndAnimation = [CABasicAnimation animationWithKeyPath:@"strokeEnd"];
//from 0
strokeEndAnimation.fromValue = @(0);
//to 1
strokeEndAnimation.toValue = @(1);
//1s animation
strokeEndAnimation.duration = 10.0f;
//repeat forever
strokeEndAnimation.repeatCount = HUGE_VAL;
//ease in / out
strokeEndAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
//apply to the pathLayer
[_pathLayer addAnimation:strokeEndAnimation forKey:@"strokeEndAnimation"];
//NOTE: we don't actually TRACK above animation, its there only for visual effect
//begin the follow path animation
CAKeyframeAnimation *followPathAnimation = [CAKeyframeAnimation animationWithKeyPath:@"position"];
//set the path for the keyframe animation
followPathAnimation.path = _pathLayer.path;
//add an array of times that match the NUMBER of points in the path
//for custom paths, you'll need to know the number of points and calc this yourself
//for an ellipse there are 5 points exactly
followPathAnimation.keyTimes = @[@(0),@(0.25),@(0.5),@(0.75),@(1)];
//copy the timing function
followPathAnimation.timingFunction = strokeEndAnimation.timingFunction;
//copy the duration
followPathAnimation.duration = strokeEndAnimation.duration;
//copy the repeat count
followPathAnimation.repeatCount = strokeEndAnimation.repeatCount;
//add the animation to the layer
[_trackingLayer addAnimation:followPathAnimation forKey:@"postionAnimation"];
[CATransaction commit];
}
This technique is pretty useful if you have paths you want to follow, but don't want to be bothered doing the math yourself.
Some of the valuable reasons for this are:
- different/custom paths can be used (not just ellipses...)
- different media timing functions can be used (you don't have to figure out the math yourself for ease in, out, or linear, etc...)
- you can start / stop animating the tracking layer at any time (i.e. doesn't have to run continuously)
- you can start / stop the display link at any time
- converting between different layer coordinates is quite easy, so you can have layer inside layers inside layers and still transfer their coordinates to any other layer
EDIT
Here's a link to a github repo: https://github.com/C4Code/layerTrackPosition
Here's an image of my simulator:
If you keep records of all your points from the beginning, you can calculate the distance between them.
When you want to know at a given time which of those points are being animated on the screen, you can do this:
first, get the current value of the strokeEnd (it's between 0 and 1) like this:
CAShapeLayer *presentationLayer = (CAShapeLayer*)[_pathLayer presentationLayer];
CGFloat strokeValue = [[presentationLayer valueForKey:@"strokeEnd"] floatValue];
then calculate the distance you already drew by now:
CGFloat doneDistance = _allTheDistance * strokeValue;
after this, you have to iterate all your points and calculate the distance between them till you get that doneDistance
This won't tell you exactly where on screen the path is, but the current point that is animated.
Maybe it will help someone.