I'm implementing a custom pull-to-refresh component.
Create CALayer with a spinner animation, than add this layer to UICollectionView. Layer's speed is zero (self.loaderLayer.speed = 0.0f;) and layer animation is managed with timeOffset in scrollViewDidScroll:. Problem goes here, because I also want to show a loader always in the center of pulling space, so I change layer's position in scrollViewDidScroll: like this:
[self.loaderLayer setPosition:CGPointMake(CGRectGetMidX(scrollView.bounds), scrollView.contentOffset.y / 2)];
But nothing happens with layer position (calling [self.loaderLayer setNeedsDisplay] doesn't help). I understand that it's because zero speed. And currently I found the way which works (but I don't like that):
self.loaderLayer.speed = 1.0f;
[self.loaderLayer setPosition:CGPointMake(CGRectGetMidX(scrollView.bounds), scrollView.contentOffset.y / 2)];
self.loaderLayer.speed = 0.0f;
How could I change a position for a paused layer right? What am I missing?
All regards to #David for the reference. I just summarize it as an answer.
CoreAnimation works with two kinds of animations (transactions): explicit and implicit. When you see Animatable word in property documentation, it means that each time you set this property, CoreAnimation will animate this property changes implicitly with system default duration (default is 1/4 second). Under hood CALayer has actions for these properties and calling -actionForKey returns such action (implicit animation).
So in my case, when I change a layer position, CoreAnimation implicitly try animating this changes. Because layer is paused (speed is zero) and animation has default duration, we don't see this changes visually.
And answer is to disable implicit animations (disable calling layer -actionForKey). To do that we call [CATransaction setDisableActions:YES].
OR
We can mark this animation as immediate (by setting it's duration to zero) with calling [CATransaction setAnimationDuration:0.0];.
These flags/changes are per thread based and work for all transactions in specific thread until next run loop. So if we want to apply them for a concrete transaction, we wrap code with [CATransaction begin]; ... [CATransaction commit]; section.
In my case final code looks
[CATransaction begin];
[CATransaction setDisableActions:YES];
[self.loaderLayer setPosition:CGPointMake(CGRectGetMidX(scrollView.bounds), scrollView.contentOffset.y / 2)];
self.loaderLayer.transform = CATransform3DMakeScale(scaleFactor, scaleFactor, 1);
[CATransaction commit];
And it works perfectly!
If I apply transformation for a view, then movement of the view on iPad Air happens with lags. It looks like implicit animations in CALayer.
I've created test project. It should be executed on iPad simulator.
This is code that I use for apply transformations:
CGAffineTransform transform = CGAffineTransformIdentity;
if (_transformSwitch.on)
{
transform = CGAffineTransformRotate(CGAffineTransformMakeScale(2.0, 2.0), M_PI / 3.0);
}
self.frameView.transform = transform;
This is code that I use for apply movement:
CGPoint transition = [_panRecognizer translationInView:self.view];
CGPoint newCenter = CGPointMake(_startCenter.x + transition.x, _startCenter.y + transition.y);
self.frameView.center = newCenter;
How I can move the view with applied transformations and avoid the animations?
UPD
I've found a solution for a moving by timer invocation, but if I'm moving frame by a finger, I've have problem with the animation.
I set a center of the view with wrapping it with [CATransaction begin] [CATransaction commit]:
[CATransaction begin];
[CATransaction setValue:#(YES) forKey:kCATransactionDisableActions];
_destinationIndicatorView.center = center;
self.frameView.center = center;
[CATransaction commit];
I've found strange solution, but I want to know why is it a worked solution?
I added to this method setNeedsDisplay, and it's solved my problem. (If I add any view over my screen with drawRect method, and call setNeedsDisplay on it view, it's also solved my problem):
[CATransaction begin];
[CATransaction setValue:#(YES) forKey:kCATransactionDisableActions];
_destinationIndicatorView.center = center;
[self.frameView setNeedsDisplay];
self.frameView.center = center;
[CATransaction commit];
I've updated my project.
UPD2
This lag happens only on iPad with retina display (for example iPad Air). I've created small video to illustrate the problem: https://yadi.sk/i/rAneCEFmjjEej
I think you could wrap the changes in this code:
[CATransaction begin];
[CATransaction disableActions];
// Your changes to the UI elements...
[CATransaction commit];
Alright, I've played around with your project and found 2 things that do not what you expected they would do:
The 1st one is drawRect: on FrameView:
- (void)drawRect:(CGRect)rect
{
[super drawRect:rect];
}
From Apple:
The default implementation of this method does nothing. Subclasses
that use technologies such as Core Graphics and UIKit to draw their
view’s content should override this method and implement their drawing
code there. You do not need to override this method if your view sets
its content in other ways. For example, you do not need to override
this method if your view just displays a background color or if your
view sets its content directly using the underlying layer object.
The 2nd one is the wrong (it's right according to the doc, see below) return value of the overridden - (id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)event on FrameView: you return nil (as described in the CALayerDelegate reference) while CoreAnimation, in fact, expects an instance of NSNull (as might be assumed when reading CALayer class reference). Your actions led to an unintended result as there's a collision in the documentation. I'm talking about this and this.
P.S. As a result, I made it working without using CATransaction. Unfortunately, I don't have a retina iPad by hand, so please check out my changes to your GitHub project (I will create a pull request containing all my changes) and tell if it resolves both the unintended animation (assuming resolved) problem and the lag of dragging (need to check for performance issues on a real iPad).
[self setTransform:CGAffineTransformIdentity];
[self setFrame:CGRectMake(o.x, o.y, width, self.frame.size.height)];
if(width != 0)
{
self.layer.anchorPoint=CGPointMake((wsCollectionView_CellSize/2)/width, 0.5);
}
[self setTransform:CGAffineTransformMakeRotation(angle)];
Im using this code to change the look of my view, however when this code is executed i can see little blinking near the anchor point, like its being adjusted, i think this is because when i set new frame, then new anchor point is set and it is being redrawn.
So im guessing what i need is to execute this at the same time or those properties must be set simultaneously. What is the way to achieve that?
or may be there is a way to set anchor in points and it will be constant?
The anchorPoint of a layer is an animatable property. This means, if you just set it, then the layer will animate to the new value by default. This animation is likely to generate the flickering you are seeing as the anchor point fights against the transforms you are making.
To prevent this, you need to make the update inside a CATransaction with actions disabled:
[CATransaction begin];
[CATransaction setDisableActions:YES];
layer.anchorPoint = ...
[CATransaction commit];
This will immediately update the anchor point of your layer.
Normally, updating the anchor point also updates the frame of the view as well, so you'd normally want to set the frame after you've set the anchor point! unless this is already taken into account in the code above.
In my prototype app there are around 100 CALayers at different but fixed positions with the same small image as the content. The only thing necessary now is to toggle the hidden property repeatedly and very quickly.
This works, but it's noticably slower than with my previous approach using UIImage's drawAtPoint: method in drawRect.
I want a strobe-like look, no transitions. That's why I'm using hidden and not opacity, but still, it kind of looks like there's a fade and that tells me it's slow.
With the drawAtPoint:-approach it looked good, but it was heavy on the CPU.
Fo this reason I rewrote it using CALayer and now I'm puzzled why it is that slow.
Can you give me advice how to investigate this?
With Instruments I didn't gain any insight. It tells me it's rendering at 59-60 FPS but visibly it's much slower.
It seems like there's a delay between the (touch) events and the hiding or showing of the layers taking effect.
That's how I'm initializing the layers:
layers[i] = [CALayer layer];
layers[i].frame = frameForLayer(i);
layers[i].contents = (__bridge id)image;
[layers[i] setContentsScale:scale];
layers[i].hidden = YES;
[[self layer] addSublayer:layers[i]];
All that in the awakeFromNib in my main view.
Later, only the hidden properties are modified, the rest stays.
EDIT:
Instead of just the someLayer.hidden = NO, I'm now writing
[CATransaction begin];
[CATransaction setValue:(id)kCFBooleanTrue
forKey:kCATransactionDisableActions];
someLayer.hidden = NO;
[CATransaction commit];
try doing the above code in a CATransaction block and set the animation duration like so:
[CATransaction setValue:[NSNumber numberWithInt:0] forKey:kCATransactionAnimationDuration];
you may also need to do disable transitions like so:
[CATransaction setValue:(id)kCFBooleanTrue forKey:kCATransactionDisableActions];
I believe CALayers have a default 'animations' for when you set their contents.
I am experimenting with Key Frame animation of the position of a UIImageView object moving along a bezier path. This pic shows the initial state before animation. The blue line is the path - initially moving straight up, the light green box is the initial bounding box or the image, and the dark green "ghost" is the image that I am moving:
When I kick off the animation with rotationMode set to nil, the image keeps the same orientation all the way through the path as expected.
But when I kick off the animation with rotationMode set to kCAAnimationRotateAuto, the image immediately rotates 90 degrees anti-clockwise and keeps this orientation all the way through the path. When it reaches the end of the path it redraws in the correct orientation (well it actually shows the UIImageView that I repositioned in the final location)
I was naively expecting that the rotationMode would orientate the image to the tangent of the path and not to the normal, especially when the Apple docs for the CAKeyframeAnimation rotationMode state
Determines whether objects animating along the path rotate to match the path tangent.
So what is the solution here? Do I have to pre-rotate the image by 90 degrees clockwise? Or is there something that I am missing?
Thanks for your help.
Edit 2nd March
I added a rotation step before the path animation using an Affine rotation like:
theImage.transform = CGAffineTransformRotate(theImage.transform,90.0*M_PI/180);
and then after the path animation, resetting the rotation with:
theImage.transform = CGAffineTransformIdentity;
This makes the image follow the path in the expected manner. However I am now running into a different problem of the image flickering. I am already looking for a solution to the flickering issue in this SO question:
iOS CAKeyFrameAnimation scaling flickers at animation end
So now I don't know if I have made things better or worse!
Edit March 12
While Caleb pointed out that yes, I did have to pre rotate my image, Rob provided an awesome
package of code that almost completely solved my problems. The only thing that Rob didn't do was compensating for my assets being drawn with a vertical rather than horizontal orientation, thus still requiring to preRotate them by 90 degrees before doing the animation. But hey, its only fair that I have to do some of the work to get things running.
So my slight changes to Rob's solution to suite my requirements are:
When I add the UIView, I pre Rotate it to counter the inherent rotation added by setting the rotationMode:
theImage.transform = CGAffineTransformMakeRotation(90*M_PI/180.0);
I need to keep that rotation at the end of the animation, so instead of just blasting the view's transform with a new scale factor after the completion block is defined, I build the scale based on the current transform:
theImage.transform = CGAffineTransformScale(theImage.transform, scaleFactor, scaleFactor);
And that's all I had to do to get my image to follow the path as I expected!
Edit March 22
I have just uploaded to GitHub a demo project that shows off the moving of an object along a bezier path. The code can be found at PathMove
I also wrote about it in my blog at Moving objects along a bezier path in iOS
The issue here is that Core Animation's autorotation keeps the horizontal axis of the view parallel to the path's tangent. That's just how it works.
If you want your view's vertical axis to follow the path's tangent instead, rotating the contents of the view as you're currently doing is the reasonable thing to do.
Here's what you need to know to eliminate the flicker:
As Caleb sort of pointed out, Core Animation rotates your layer so that its positive X axis lies along the tangent of your path. You need to make your image's “natural” orientation work with that. So, supposing that's a green spaceship in your example images, you need the spaceship to point to the right when it doesn't have rotation applied to it.
Setting a transform that includes rotation interferes with the rotation applied by `kCAAnimationRotateAuto'. You need to remove the rotation from your transform before applying the animation.
Of course that means you need to reapply the transformation when the animation completes. And of course you want to do that without seeing any flicker in the appearance of the image. That's not hard, but there some secret sauce involved, which I explain below.
You presumably want your spaceship to start out pointing along the tangent of the path, even when the spaceship is sitting still having not been animated yet. If your spaceship image is pointing to the right, but your path goes up, then you need to set the transform of the image to include a 90° rotation. But perhaps you don't want to hardcode that rotation - instead you want to look at the path and figure out its starting tangent.
I'll show some of the important code here. You can find my test project on github. You may find some use in downloading it and trying it out. Just tap on the green “spaceship” to see the animation.
So, in my test project, I have connected my UIImageView to an action named animate:. When you touch it, the image moves along half of a figure 8 and doubles in size. When you touch it again, the image moves along the other half of the figure 8 (back to the starting position), and returns to its original size. Both animations use kCAAnimationRotateAuto, so the image points along the tangent of the path.
Here's the start of animate:, where I figure out what path, scale, and destination point the image should end up at:
- (IBAction)animate:(id)sender {
UIImageView* theImage = self.imageView;
UIBezierPath *path = _isReset ? _path0 : _path1;
CGFloat newScale = 3 - _currentScale;
CGPoint destination = [path currentPoint];
So, the first thing I need to do is remove any rotation from the image's transform, since as I mentioned, it will interfere with kCAAnimationRotateAuto:
// Strip off the image's rotation, because it interferes with `kCAAnimationRotateAuto`.
theImage.transform = CGAffineTransformMakeScale(_currentScale, _currentScale);
Next, I go into a UIView animation block so that the system will apply animations to the image view:
[UIView animateWithDuration:3 animations:^{
I create the keyframe animation for the position and set a couple of its properties:
// Prepare my own keypath animation for the layer position.
// The layer position is the same as the view center.
CAKeyframeAnimation *positionAnimation = [CAKeyframeAnimation animationWithKeyPath:#"position"];
positionAnimation.path = path.CGPath;
positionAnimation.rotationMode = kCAAnimationRotateAuto;
Next is the secret sauce for preventing flicker at the end of the animation. Recall that animations do not effect the properties of the “model layer“ that you attach them to (theImage.layer in this case). Instead, they update the properties of the “presentation layer“, which reflects what's actually on the screen.
So first I set removedOnCompletion to NO for the keyframe animation. This means the animation will stay attached to the model layer when the animation is complete, which means I can access the presentation layer. I get the transform from the presentation layer, remove the animation, and apply the transform to the model layer. Since this is all happening on the main thread, these property changes all happen in one screen refresh cycle, so there's no flicker.
positionAnimation.removedOnCompletion = NO;
[CATransaction setCompletionBlock:^{
CGAffineTransform finalTransform = [theImage.layer.presentationLayer affineTransform];
[theImage.layer removeAnimationForKey:positionAnimation.keyPath];
theImage.transform = finalTransform;
}];
Now that I've set up the completion block, I can actually change the view properties. The system will automatically attach animations to the layer when I do this.
// UIView will add animations for both of these changes.
theImage.transform = CGAffineTransformMakeScale(newScale, newScale);
theImage.center = destination;
I copy some key properties from the automatically-added position animation to my keyframe animation:
// Copy properties from UIView's animation.
CAAnimation *autoAnimation = [theImage.layer animationForKey:positionAnimation.keyPath];
positionAnimation.duration = autoAnimation.duration;
positionAnimation.fillMode = autoAnimation.fillMode;
and finally I replace the automatically-added position animation with the keyframe animation:
// Replace UIView's animation with my animation.
[theImage.layer addAnimation:positionAnimation forKey:positionAnimation.keyPath];
}];
Double-finally I update my instance variables to reflect the change to the image view:
_currentScale = newScale;
_isReset = !_isReset;
}
That's it for animating the image view with no flicker.
And now, as Steve Jobs would say, One Last Thing. When I load the view, I need to set the transform of the image view so that it's rotated to point along the tangent of the first path that I will use to animate it. I do that in a method named reset:
- (void)reset {
self.imageView.center = _path1.currentPoint;
self.imageView.transform = CGAffineTransformMakeRotation(startRadiansForPath(_path0));
_currentScale = 1;
_isReset = YES;
}
Of course, the tricky bit is hidden in that startRadiansForPath function. It's really not that hard. I use the CGPathApply function to process the elements of the path, picking out the first two points that actually form a subpath, and I compute the angle of the line formed by those two points. (A curved path section is either a quadratic or cubic bezier spline, and those splines have the property that the tangent at the first point of the spline is the line from the first point to the next control point.)
I'm just going to dump the code here without explanation, for posterity:
typedef struct {
CGPoint p0;
CGPoint p1;
CGPoint firstPointOfCurrentSubpath;
CGPoint currentPoint;
BOOL p0p1AreSet : 1;
} PathState;
static inline void updateStateWithMoveElement(PathState *state, CGPathElement const *element) {
state->currentPoint = element->points[0];
state->firstPointOfCurrentSubpath = state->currentPoint;
}
static inline void updateStateWithPoints(PathState *state, CGPoint p1, CGPoint currentPoint) {
if (!state->p0p1AreSet) {
state->p0 = state->currentPoint;
state->p1 = p1;
state->p0p1AreSet = YES;
}
state->currentPoint = currentPoint;
}
static inline void updateStateWithPointsElement(PathState *state, CGPathElement const *element, int newCurrentPointIndex) {
updateStateWithPoints(state, element->points[0], element->points[newCurrentPointIndex]);
}
static void updateStateWithCloseElement(PathState *state, CGPathElement const *element) {
updateStateWithPoints(state, state->firstPointOfCurrentSubpath, state->firstPointOfCurrentSubpath);
}
static void updateState(void *info, CGPathElement const *element) {
PathState *state = info;
switch (element->type) {
case kCGPathElementMoveToPoint: return updateStateWithMoveElement(state, element);
case kCGPathElementAddLineToPoint: return updateStateWithPointsElement(state, element, 0);
case kCGPathElementAddQuadCurveToPoint: return updateStateWithPointsElement(state, element, 1);
case kCGPathElementAddCurveToPoint: return updateStateWithPointsElement(state, element, 2);
case kCGPathElementCloseSubpath: return updateStateWithCloseElement(state, element);
}
}
CGFloat startRadiansForPath(UIBezierPath *path) {
PathState state;
memset(&state, 0, sizeof state);
CGPathApply(path.CGPath, &state, updateState);
return atan2f(state.p1.y - state.p0.y, state.p1.x - state.p0.x);
}
Yow mention that you kick off the animation with "rotationMode set to YES", but the documentation states that rotationMode should be set using an NSString...
In particular:
These constants are used by the rotationMode property.
NSString * const kCAAnimationRotateAuto
NSString * const kCAAnimationRotateAutoReverse
Have you tried setting:
keyframe.animationMode = kCAAnimationRotateAuto;
The documentation states:
kCAAnimationRotateAuto: The objects travel on a tangent to the path.