I have an endlessly looping CABasicAnimation of a repeating image tile in my view:
a = [CABasicAnimation animationWithKeyPath:#"position"];
a.timingFunction = [CAMediaTimingFunction
functionWithName:kCAMediaTimingFunctionLinear];
a.fromValue = [NSValue valueWithCGPoint:CGPointMake(0, 0)];
a.toValue = [NSValue valueWithCGPoint:CGPointMake(image.size.width, 0)];
a.repeatCount = HUGE_VALF;
a.duration = 15.0;
[a retain];
I have tried to "pause and resume" the layer animation as described in Technical Q&A QA1673.
When the app enters background, the animation gets removed from the layer.
To compensate I listen to UIApplicationDidEnterBackgroundNotification and call stopAnimation and in response to UIApplicationWillEnterForegroundNotification call startAnimation.
- (void)startAnimation
{
if ([[self.layer animationKeys] count] == 0)
[self.layer addAnimation:a forKey:#"position"];
CFTimeInterval pausedTime = [self.layer timeOffset];
self.layer.speed = 1.0;
self.layer.timeOffset = 0.0;
self.layer.beginTime = 0.0;
CFTimeInterval timeSincePause =
[self.layer convertTime:CACurrentMediaTime() fromLayer:nil] - pausedTime;
self.layer.beginTime = timeSincePause;
}
- (void)stopAnimation
{
CFTimeInterval pausedTime =
[self.layer convertTime:CACurrentMediaTime() fromLayer:nil];
self.layer.speed = 0.0;
self.layer.timeOffset = pausedTime;
}
The problem is that it starts again at the beginning and there is ugly jump from last position, as seen on app snapshot the system took when application did enter background, back to the start of the animation loop.
I can not figure out how to make it start at last position, when I re-add the animation. Frankly, I just don't understand how that code from QA1673 works: in resumeLayer it sets the layer.beginTime twice, which seems redundant. But when I've removed the first set-to-zero, it did not resume the animation where it was paused. This was tested with simple tap gesture recognizer, that did toggle the animation - this is not strictly related to my issues with restoring from background.
What state should I remember before the animation gets removed and how do I restore the animation from that state, when I re-add it later?
Hey I had stumbled upon the same thing in my game, and ended up finding a somewhat different solution than you, which you may like :) I figured I should share the workaround I found...
My case is using UIView/UIImageView animations, but it's basically still CAAnimations at its core... The gist of my method is that I copy/store the current animation on a view, and then let Apple's pause/resume work still, but before resuming I add my animation back on. So let me present this simple example:
Let's say I have a UIView called movingView. The UIView's center is animated via the standard [UIView animateWithDuration...] call. Using the mentioned QA1673 code, it works great pausing/resuming (when not exiting the app)... but regardless, I soon realized that on exit, whether I pause or not, the animation was completely removed... and here I was in your position.
So with this example, here's what I did:
Have a variable in your header file called something like animationViewPosition, of type *CAAnimation**.
When the app exits to background, I do this:
animationViewPosition = [[movingView.layer animationForKey:#"position"] copy]; // I know position is the key in this case...
[self pauseLayer:movingView.layer]; // this is the Apple method from QA1673
Note: Those 2 ^ calls are in a method that is the handler for the UIApplicationDidEnterBackgroundNotification (similar to you)
Note 2: If you don't know what the key is (of your animation), you can loop through the view's layer's 'animationKeys' property and log those out (mid animation presumably).
Now in my UIApplicationWillEnterForegroundNotification handler:
if (animationViewPosition != nil)
{
[movingView.layer addAnimation:animationViewPosition forKey:#"position"]; // re-add the core animation to the view
[animationViewPosition release]; // since we 'copied' earlier
animationViewPosition = nil;
}
[self resumeLayer:movingView.layer]; // Apple's method, which will resume the animation at the position it was at when the app exited
And that's pretty much it! It has worked for me so far :)
You can easily extend it for more animations or views by just repeating those steps for each animation. It even works for pausing/resuming UIImageView animations, ie the standard [imageView startAnimating]. The layer animation key for that (by the way) is "contents".
Listing 1 Pause and Resume animations.
-(void)pauseLayer:(CALayer*)layer
{
CFTimeInterval pausedTime = [layer convertTime:CACurrentMediaTime() fromLayer:nil];
layer.speed = 0.0;
layer.timeOffset = pausedTime;
}
-(void)resumeLayer:(CALayer*)layer
{
CFTimeInterval pausedTime = [layer timeOffset];
layer.speed = 1.0;
layer.timeOffset = 0.0;
layer.beginTime = 0.0;
CFTimeInterval timeSincePause = [layer convertTime:CACurrentMediaTime() fromLayer:nil] - pausedTime;
layer.beginTime = timeSincePause;
}
After quite a lot of searching and talks with iOS development gurus, it appears that QA1673 doesn't help when it comes to pausing, backgrounding, then moving to foreground. My experimentation even shows that delegate methods that fire off from animations, such as animationDidStop become unreliable.
Sometimes they fire, sometimes they don't.
This creates a lot of problems because it means that, not only are you looking at a different screen that you were when you paused, but also the sequence of events currently in motion can be disrupted.
My solution thus far has been as follows:
When the animation starts, I get the start time:
mStartTime = [layer convertTime:CACurrentMediaTime() fromLayer:nil];
When the user hits the pause button, I remove the animation from the CALayer:
[layer removeAnimationForKey:key];
I get the absolute time using CACurrentMediaTime():
CFTimeInterval stopTime = [layer convertTime:CACurrentMediaTime() fromLayer:nil];
Using the mStartTime and stopTime I calculate an offset time:
mTimeOffset = stopTime - mStartTime;
I also set the model values of the object to be that of the presentationLayer. So, my stop method looks like this:
//--------------------------------------------------------------------------------------------------
- (void)stop
{
const CALayer *presentationLayer = layer.presentationLayer;
layer.bounds = presentationLayer.bounds;
layer.opacity = presentationLayer.opacity;
layer.contentsRect = presentationLayer.contentsRect;
layer.position = presentationLayer.position;
[layer removeAnimationForKey:key];
CFTimeInterval stopTime = [layer convertTime:CACurrentMediaTime() fromLayer:nil];
mTimeOffset = stopTime - mStartTime;
}
On resume, I recalculate what's left of the paused animation based upon the mTimeOffset. That's a bit messy because I'm using CAKeyframeAnimation. I figure out what keyframes are outstanding based on the mTimeOffset. Also, I take into account that the pause may have occurred mid frame, e.g. halfway between f1 and f2. That time is deducted from the time of that keyframe.
I then add this animation to the layer afresh:
[layer addAnimation:animationGroup forKey:key];
The other thing to remember is that you will need to check the flag in animationDidStop and only remove the animated layer from the parent with removeFromSuperlayer if the flag is YES. That means that the layer is still visible during the pause.
This method does seem very laborious. It does work though! I'd love to be able to simply do this using QA1673. But at the moment for backgrounding, it doesn't work and this seems to be the only solution.
It's surprising to see that this isn't more straightforward. I created a category, based on cclogg's approach, that should make this a one-liner.
CALayer+MBAnimationPersistence
Simply invoke MB_setCurrentAnimationsPersistent on your layer after setting up the desired animations.
[movingView.layer MB_setCurrentAnimationsPersistent];
Or specify the animations that should be persisted explicitly.
movingView.layer.MB_persistentAnimationKeys = #[#"position"];
I used cclogg's solution but my app was crashing when the animation's view was removed from his superview, added again, and then going to background.
The animation was made infinite by setting animation.repeatCount to Float.infinity.
The solution I had was to set animation.isRemovedOnCompletion to false.
It's very weird that it works because the animation is never completed. If anyone has an explanation, I like to hear it.
Another tip: If you remove the view from its superview. Don't forget to remove the observer by calling NSNotificationCenter.defaultCenter().removeObserver(...).
I write a Swift 4.2 version extension based on #cclogg and #Matej Bukovinski answers. All you need is to call layer.makeAnimationsPersistent()
Full Gist here: CALayer+AnimationPlayback.swift, CALayer+PersistentAnimations.swift
Core part:
public extension CALayer {
static private var persistentHelperKey = "CALayer.LayerPersistentHelper"
public func makeAnimationsPersistent() {
var object = objc_getAssociatedObject(self, &CALayer.persistentHelperKey)
if object == nil {
object = LayerPersistentHelper(with: self)
let nonatomic = objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC
objc_setAssociatedObject(self, &CALayer.persistentHelperKey, object, nonatomic)
}
}
}
public class LayerPersistentHelper {
private var persistentAnimations: [String: CAAnimation] = [:]
private var persistentSpeed: Float = 0.0
private weak var layer: CALayer?
public init(with layer: CALayer) {
self.layer = layer
addNotificationObservers()
}
deinit {
removeNotificationObservers()
}
}
private extension LayerPersistentHelper {
func addNotificationObservers() {
let center = NotificationCenter.default
let enterForeground = UIApplication.willEnterForegroundNotification
let enterBackground = UIApplication.didEnterBackgroundNotification
center.addObserver(self, selector: #selector(didBecomeActive), name: enterForeground, object: nil)
center.addObserver(self, selector: #selector(willResignActive), name: enterBackground, object: nil)
}
func removeNotificationObservers() {
NotificationCenter.default.removeObserver(self)
}
func persistAnimations(with keys: [String]?) {
guard let layer = self.layer else { return }
keys?.forEach { (key) in
if let animation = layer.animation(forKey: key) {
persistentAnimations[key] = animation
}
}
}
func restoreAnimations(with keys: [String]?) {
guard let layer = self.layer else { return }
keys?.forEach { (key) in
if let animation = persistentAnimations[key] {
layer.add(animation, forKey: key)
}
}
}
}
#objc extension LayerPersistentHelper {
func didBecomeActive() {
guard let layer = self.layer else { return }
restoreAnimations(with: Array(persistentAnimations.keys))
persistentAnimations.removeAll()
if persistentSpeed == 1.0 { // if layer was playing before background, resume it
layer.resumeAnimations()
}
}
func willResignActive() {
guard let layer = self.layer else { return }
persistentSpeed = layer.speed
layer.speed = 1.0 // in case layer was paused from outside, set speed to 1.0 to get all animations
persistAnimations(with: layer.animationKeys())
layer.speed = persistentSpeed // restore original speed
layer.pauseAnimations()
}
}
Just in case anyone needs a Swift 3 solution for this problem:
All you have to do is to subclass your animated view from this class.
It always persist and resume all animations on it's layer.
class ViewWithPersistentAnimations : UIView {
private var persistentAnimations: [String: CAAnimation] = [:]
private var persistentSpeed: Float = 0.0
override init(frame: CGRect) {
super.init(frame: frame)
self.commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.commonInit()
}
func commonInit() {
NotificationCenter.default.addObserver(self, selector: #selector(didBecomeActive), name: NSNotification.Name.UIApplicationWillEnterForeground, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(willResignActive), name: NSNotification.Name.UIApplicationDidEnterBackground, object: nil)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
func didBecomeActive() {
self.restoreAnimations(withKeys: Array(self.persistentAnimations.keys))
self.persistentAnimations.removeAll()
if self.persistentSpeed == 1.0 { //if layer was plaiyng before backgorund, resume it
self.layer.resume()
}
}
func willResignActive() {
self.persistentSpeed = self.layer.speed
self.layer.speed = 1.0 //in case layer was paused from outside, set speed to 1.0 to get all animations
self.persistAnimations(withKeys: self.layer.animationKeys())
self.layer.speed = self.persistentSpeed //restore original speed
self.layer.pause()
}
func persistAnimations(withKeys: [String]?) {
withKeys?.forEach({ (key) in
if let animation = self.layer.animation(forKey: key) {
self.persistentAnimations[key] = animation
}
})
}
func restoreAnimations(withKeys: [String]?) {
withKeys?.forEach { key in
if let persistentAnimation = self.persistentAnimations[key] {
self.layer.add(persistentAnimation, forKey: key)
}
}
}
}
extension CALayer {
func pause() {
if self.isPaused() == false {
let pausedTime: CFTimeInterval = self.convertTime(CACurrentMediaTime(), from: nil)
self.speed = 0.0
self.timeOffset = pausedTime
}
}
func isPaused() -> Bool {
return self.speed == 0.0
}
func resume() {
let pausedTime: CFTimeInterval = self.timeOffset
self.speed = 1.0
self.timeOffset = 0.0
self.beginTime = 0.0
let timeSincePause: CFTimeInterval = self.convertTime(CACurrentMediaTime(), from: nil) - pausedTime
self.beginTime = timeSincePause
}
}
On Gist:
https://gist.github.com/grzegorzkrukowski/a5ed8b38bec548f9620bb95665c06128
I was able to restore the animation (but not the animation position) by saving a copy of the current animation and adding it back on resume. I called startAnimation on load and when entering the foreground and pause when entering the background.
- (void) startAnimation {
// On first call, setup our ivar
if (!self.myAnimation) {
self.myAnimation = [CABasicAnimation animationWithKeyPath:#"transform"];
/*
Finish setting up myAnimation
*/
}
// Add the animation to the layer if it hasn't been or got removed
if (![self.layer animationForKey:#"myAnimation"]) {
[self.layer addAnimation:self.spinAnimation forKey:#"myAnimation"];
}
}
- (void) pauseAnimation {
// Save the current state of the animation
// when we call startAnimation again, this saved animation will be added/restored
self.myAnimation = [[self.layer animationForKey:#"myAnimation"] copy];
}
I use cclogg's solution to great effect. I also wanted to share some additional info that might help someone else, because it frustrated me for a while.
In my app I have a number of animations, some that loop forever, some that run only once and are spawned randomly. cclogg's solution worked for me, but when I added some code to
- (void)animationDidStop:(CAAnimation *)theAnimation finished:(BOOL)flag
in order to do something when only the one-time animations were finished, this code would trigger when I resumed my app (using cclogg's solution) whenever those specific one-time animations were running when it was paused. So I added a flag (a member variable of my custom UIImageView class) and set it to YES in the section where you resume all the layer animations (resumeLayer in cclogg's, analogous to Apple solution QA1673) to keep this from happening. I do this for every UIImageView that is resuming. Then, in the animationDidStop method, only run the one-time animation handling code when that flag is NO. If it's YES, ignore the handling code. Switch the flag back to NO either way. That way when the animation truly finishes, your handling code will run. So like this:
- (void)animationDidStop:(CAAnimation *)theAnimation finished:(BOOL)flag
if (!resumeFlag) {
// do something now that the animation is finished for reals
}
resumeFlag = NO;
}
Hope that helps someone.
I was recognizing the gesture state like so:
// Perform action depending on the state
switch gesture.state {
case .changed:
// Some action
case .ended:
// Another action
// Ignore any other state
default:
break
}
All I needed to do was change the .ended case to .ended, .cancelled.
iOS will remove all animations when view disappears from the visible area (not only when app goes into background). To fix it I created custom CALayer subclass and overrided 2 methods so the system doesn't remove animations - removeAnimation and removeAllAnimations:
class CustomCALayer: CALayer {
override func removeAnimation(forKey key: String) {
// prevent iOS to clear animation when view is not visible
}
override func removeAllAnimations() {
// prevent iOS to clear animation when view is not visible
}
func forceRemoveAnimation(forKey key: String) {
super.removeAnimation(forKey: key)
}
}
In the view where you want this layer to be used as main layer override layerClass property:
override class var layerClass: AnyClass {
return CustomCALayer.self
}
To pause and resume animation:
extension CALayer {
func pause() {
guard self.isPaused() == false else {
return
}
let pausedTime: CFTimeInterval = self.convertTime(CACurrentMediaTime(), from: nil)
self.speed = 0.0
self.timeOffset = pausedTime
}
func resume() {
guard self.isPaused() else {
return
}
let pausedTime: CFTimeInterval = self.timeOffset
self.speed = 1.0
self.timeOffset = 0.0
self.beginTime = 0.0
self.beginTime = self.convertTime(CACurrentMediaTime(), from: nil) - pausedTime
}
func isPaused() -> Bool {
return self.speed == 0.0
}
}
Related
I have a CAEmitterLayer instance that I want to pause and then resume multiple times.
I have found various ways to do this using two CAEmitterLayer extension functions:
public func pause() {
speed = 0.0 // Freeze existing cells.
timeOffset = convertTime(CACurrentMediaTime(), from: self)
lifetime = 0.0 // Stop creating new cells.
}
and
public func resume() {
speed = 1.0
beginTime = convertTime(CACurrentMediaTime(), from: self) - timeOffset
timeOffset = 0.0
lifetime = 1.0
}
The first occasion of using emitterLayer.pause() and emitterLayer.resume() works perfectly.
However, from the second occasion onwards, whenever I use emitterLayer.pause(), the emitterCells jump slightly forward in time.
Can anybody out there help me resolve this jumping problem, please?
I needed to adjust the timeOffset in the pause() method. This is a working extension for pausing and resuming a CAEmitterLayer instance:
extension CAEmitterLayer {
/**
Pauses a CAEmitterLayer.
*/
public func pause() {
speed = 0.0 // Freeze the CAEmitterCells.
timeOffset = convertTime(CACurrentMediaTime(), from: self) - beginTime
lifetime = 0.0 // Produce no new CAEmitterCells.
}
/**
Resumes a paused CAEmitterLayer.
*/
public func resume() {
speed = 1.0 // Unfreeze the CAEmitterCells.
beginTime = convertTime(CACurrentMediaTime(), from: self) - timeOffset
timeOffset = 0.0
lifetime = 1.0 // Produce CAEmitterCells at previous rate.
}
}
Use as:
var emitterLayer = CAEmitterLayer()
/// Configure as required
emitterLayer.pause()
emitterLayer.resume()
I have a start/stop button and an image view which I want to rotate.
When I press the button I want the to start rotating and when I press the button again the image should stop rotating. I am currently using an UIView animation, but I haven't figured out a way to stop the view animations.
I want the image to rotate, but when the animation stops the image shouldn't go back to the starting position, but instead continue the animation.
var isTapped = true
#IBAction func startStopButtonTapped(_ sender: Any) {
ruotate()
isTapped = !isTapped
}
func ruotate() {
if isTapped {
UIView.animate(withDuration: 5, delay: 0, options: .repeat, animations: { () -> Void in
self.imageWood.transform = self.imageWood.transform.rotated(by: CGFloat(M_PI_2))
}, completion: { finished in
ruotate()
})
} }
It is my code, but it doesn't work like I aspect.
Swift 3.x
Start Animation
let rotationAnimation : CABasicAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotationAnimation.toValue = NSNumber(value: .pi * 2.0)
rotationAnimation.duration = 0.5;
rotationAnimation.isCumulative = true;
rotationAnimation.repeatCount = .infinity;
self.imageWood?.layer.add(rotationAnimation, forKey: "rotationAnimation")
Stop animation
self.imageWood?.layer.removeAnimation(forKey: "rotationAnimation")
Swift 2.x
Start Animation
let rotationAnimation : CABasicAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotationAnimation.toValue = NSNumber(double: M_PI * 2.0)
rotationAnimation.duration = 1;
rotationAnimation.cumulative = true;
rotationAnimation.repeatCount = .infinity;
self.imageWood?.layer.addAnimation(rotationAnimation, forKey: "rotationAnimation")
Stop animation
self.imageWood?.layer.removeAnimation(forKey: "rotationAnimation")
If you use UIView Animation the OS still creates one or more CAAnimation objects. Thus, to stop a UIView animation you can still use:
myView.layer.removeAllAnimations()
Or if you create the animation using a CAAnimation on a layer:
myLayer.removeAllAnimations()
In either case, you can capture the current state of the animation and set that as the final state before removing the animation. If you're doing an animation on a view's transform, like in this question, that code might look like this:
func stopAnimationForView(_ myView: UIView) {
//Get the current transform from the layer's presentation layer
//(The presentation layer has the state of the "in flight" animation)
let transform = myView.layer.presentationLayer.transform
//Set the layer's transform to the current state of the transform
//from the "in-flight" animation
myView.layer.transform = transform
//Now remove the animation
//and the view's layer will keep the current rotation
myView.layer.removeAllAnimations()
}
If you're animating a property other than the transform you'd need to change the code above.
using your existing code you can achieve it the following way
var isTapped = true
#IBAction func startStopButtonTapped(_ sender: Any) {
ruotate()
isTapped = !isTapped
}
func ruotate() {
if isTapped {
let rotationAnimation : CABasicAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotationAnimation.toValue = NSNumber(value: Double.pi * 2.0)
rotationAnimation.duration = 1;
rotationAnimation.isCumulative = true;
rotationAnimation.repeatCount = HUGE;
self.imageWood?.layer.add(rotationAnimation, forKey: "rotationAnimation")
}else{
self.imageWood?.layer.removeAnimation(forKey: "rotationAnimation")
}
}
Apple added a new class, UIViewPropertyAnimator, to iOS 10. A UIViewPropertyAnimator allows you to easily create UIView-based animations that can be paused, reversed, and scrubbed back and forth.
If you refactor your code to use a A UIViewPropertyAnimator you should be able to pause and resume your animation.
I have a sample project on Github that demonstrates using a UIViewPropertyAnimator. I suggest taking a look at that.
Your code make the image rotate for 2PI angle, but if you click on the button while the rotation is not ended, the animation will finish before stop, that's why it comes to the initial position.
You should use CABasicAnimation to use a rotation that you can stop at anytime keeping the last position.
I have a subclassed UIView that we can call CircleView. CircleView automatically sets a corner radius to half of its width in order for it to be a circle.
The problem is that when "CircleView" is resized by an AutoLayout constraint... for example on a device rotation... it distorts badly until the resize takes place because the "cornerRadius" property has to catch up, and the OS only sends a single "bounds" change to the view's frame.
I was wondering if anyone had a good, clear strategy for implementing "CircleView" in a way that won't distort in such instances, but will still mask its contents to the shape of a circle and allow for a border to exist around said UIView.
UPDATE: If your deployment target is iOS 11 or later:
Starting in iOS 11, UIKit will animate cornerRadius if you update it inside an animation block. Just set your view's layer.cornerRadius in a UIView animation block, or (to handle interface orientation changes), set it in layoutSubviews or viewDidLayoutSubviews.
ORIGINAL: If your deployment target is older than iOS 11:
So you want this:
(I turned on Debug > Slow Animations to make the smoothness easier to see.)
Side rant, feel free to skip this paragraph: This turns out to be a lot harder than it should be, because the iOS SDK doesn't make the parameters (duration, timing curve) of the autorotation animation available in a convenient way. You can (I think) get at them by overriding -viewWillTransitionToSize:withTransitionCoordinator: on your view controller to call -animateAlongsideTransition:completion: on the transition coordinator, and in the callback you pass, get the transitionDuration and completionCurve from the UIViewControllerTransitionCoordinatorContext. And then you need to pass that information down to your CircleView, which has to save it (because it hasn't been resized yet!) and later when it receives layoutSubviews, it can use it to create a CABasicAnimation for cornerRadius with those saved animation parameters. And don't accidentally create an animation when it's not an animated resize… End of side rant.
Wow, that sounds like a ton of work, and you have to involve the view controller. Here's another approach that's entirely implemented inside CircleView. It works now (in iOS 9) but I can't guarantee it'll always work in the future, because it makes two assumptions that could theoretically be wrong in the future.
Here's the approach: override -actionForLayer:forKey: in CircleView to return an action that, when run, installs an animation for cornerRadius.
These are the two assumptions:
bounds.origin and bounds.size get separate animations. (This is true now but presumably a future iOS could use a single animation for bounds. It would be easy enough to check for a bounds animation if no bounds.size animation were found.)
The bounds.size animation is added to the layer before Core Animation asks for the cornerRadius action.
Given these assumptions, when Core Animation asks for the cornerRadius action, we can get the bounds.size animation from the layer, copy it, and modify the copy to animate cornerRadius instead. The copy has the same animation parameters as the original (unless we modify them), so it has the correct duration and timing curve.
Here's the start of CircleView:
class CircleView: UIView {
override func layoutSubviews() {
super.layoutSubviews()
updateCornerRadius()
}
private func updateCornerRadius() {
layer.cornerRadius = min(bounds.width, bounds.height) / 2
}
Note that the view's bounds are set before the view receives layoutSubviews, and therefore before we update cornerRadius. This is why the bounds.size animation is installed before the cornerRadius animation is requested. Each property's animations are installed inside the property's setter.
When we set cornerRadius, Core Animation asks us for a CAAction to run for it:
override func action(for layer: CALayer, forKey event: String) -> CAAction? {
if event == "cornerRadius" {
if let boundsAnimation = layer.animation(forKey: "bounds.size") as? CABasicAnimation {
let animation = boundsAnimation.copy() as! CABasicAnimation
animation.keyPath = "cornerRadius"
let action = Action()
action.pendingAnimation = animation
action.priorCornerRadius = layer.cornerRadius
return action
}
}
return super.action(for: layer, forKey: event)
}
In the code above, if we're asked for an action for cornerRadius, we look for a CABasicAnimation on bounds.size. If we find one, we copy it, change the key path to cornerRadius, and save it away in a custom CAAction (of class Action, which I will show below). We also save the current value of the cornerRadius property, because Core Animation calls actionForLayer:forKey: before updating the property.
After actionForLayer:forKey: returns, Core Animation updates the cornerRadius property of the layer. Then it runs the action by sending it runActionForKey:object:arguments:. The job of the action is to install whatever animations are appropriate. Here's the custom subclass of CAAction, which I've nested inside CircleView:
private class Action: NSObject, CAAction {
var pendingAnimation: CABasicAnimation?
var priorCornerRadius: CGFloat = 0
public func run(forKey event: String, object anObject: Any, arguments dict: [AnyHashable : Any]?) {
if let layer = anObject as? CALayer, let pendingAnimation = pendingAnimation {
if pendingAnimation.isAdditive {
pendingAnimation.fromValue = priorCornerRadius - layer.cornerRadius
pendingAnimation.toValue = 0
} else {
pendingAnimation.fromValue = priorCornerRadius
pendingAnimation.toValue = layer.cornerRadius
}
layer.add(pendingAnimation, forKey: "cornerRadius")
}
}
}
} // end of CircleView
The runActionForKey:object:arguments: method sets the fromValue and toValue properties of the animation and then adds the animation to the layer. There's a complication: UIKit uses “additive” animations, because they work better if you start another animation on a property while an earlier animation is still running. So our action checks for that.
If the animation is additive, it sets fromValue to the difference between the old and new corner radii, and sets toValue to zero. Since the layer's cornerRadius property has already been updated by the time the animation is running, adding that fromValue at the start of the animation makes it look like the old corner radius, and adding the toValue of zero at the end of the animation makes it look like the new corner radius.
If the animation is not additive (which doesn't happen if UIKit created the animation, as far as I know), then it just sets the fromValue and toValue in the obvious way.
Here's the whole file for your convenience:
import UIKit
class CircleView: UIView {
override func layoutSubviews() {
super.layoutSubviews()
updateCornerRadius()
}
private func updateCornerRadius() {
layer.cornerRadius = min(bounds.width, bounds.height) / 2
}
override func action(for layer: CALayer, forKey event: String) -> CAAction? {
if event == "cornerRadius" {
if let boundsAnimation = layer.animation(forKey: "bounds.size") as? CABasicAnimation {
let animation = boundsAnimation.copy() as! CABasicAnimation
animation.keyPath = "cornerRadius"
let action = Action()
action.pendingAnimation = animation
action.priorCornerRadius = layer.cornerRadius
return action
}
}
return super.action(for: layer, forKey: event)
}
private class Action: NSObject, CAAction {
var pendingAnimation: CABasicAnimation?
var priorCornerRadius: CGFloat = 0
public func run(forKey event: String, object anObject: Any, arguments dict: [AnyHashable : Any]?) {
if let layer = anObject as? CALayer, let pendingAnimation = pendingAnimation {
if pendingAnimation.isAdditive {
pendingAnimation.fromValue = priorCornerRadius - layer.cornerRadius
pendingAnimation.toValue = 0
} else {
pendingAnimation.fromValue = priorCornerRadius
pendingAnimation.toValue = layer.cornerRadius
}
layer.add(pendingAnimation, forKey: "cornerRadius")
}
}
}
} // end of CircleView
My answer was inspired by this answer by Simon.
This answer builds upon the earlier answer by rob mayoff. Basically, I implemented it for our project and it worked just fine on the iPhone (iOS 9 and 10), but the issue remained on iPad (iOS 9 or 10).
Debugging, I found that the if statement:
if let boundsAnimation = layer.animation(forKey: "bounds.size") as? CABasicAnimation {
always failed on iPad. It looks like the animations are built in a different sequence on iPad than iPhone. Looking back at the original answer by Simon, it seems that sequencing has changed before. So I combined both answers giving me something like this:
override func action(for layer: CALayer, forKey event: String) -> CAAction? {
let buildAction: (CABasicAnimation) -> Action = { boundsAnimation in
let animation = boundsAnimation.copy() as! CABasicAnimation
animation.keyPath = "cornerRadius"
let action = Action()
action.pendingAnimation = animation
action.priorCornerRadius = layer.cornerRadius
return action
}
if event == "cornerRadius" {
if let boundsAnimation = layer.animation(forKey: "bounds.size") as? CABasicAnimation {
return buildAction(boundsAnimation)
} else if let boundsAnimation = self.action(for: layer, forKey: "bounds") as? CABasicAnimation {
return buildAction(boundsAnimation)
}
}
return super.action(for: layer, forKey: event)
}
By combining both answers, it seems to work properly on both iPhone and iPad under iOS 9 and 10. I haven't really tested further, and don't know enough about CoreAnimation to fully understand this change.
In iOS 10 you don't need to create a CAAction, it works just creating a CABasicAnimation and provide this in your action(for layer:, for key:) -> CAAction? function (See Swift example):
private var currentBoundsAnimation: CABasicAnimation? {
return layer.animation(forKey: "bounds.size") as? CABasicAnimation ?? layer.animation(forKey: "bounds") as? CABasicAnimation
}
override public var bounds: CGRect {
didSet {
layer.cornerRadius = min(bounds.width, bounds.height) / 2
}
}
override public func action(for layer: CALayer, forKey event: String) -> CAAction? {
if(event == "cornerRadius"), let boundsAnimation = currentBoundsAnimation {
let animation = CABasicAnimation(keyPath: "cornerRadius")
animation.duration = boundsAnimation.duration
animation.timingFunction = boundsAnimation.timingFunction
return animation
}
return super.action(for: layer, forKey: event)
}
Instead of overriding the bounds property you can also override the layoutSubviews:
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = min(bounds.width, bounds.height) / 2
}
This works magically because the CABasicAnimation infers the missing from and to values from the model and presentation layers. To set the timing correctly you need the private currentBoundsAnimation property to get the current animations ("bounds" for iPad and "bounds.size" for iPhone) which where added on device rotation.
These translation answers usually go Objective-c ==> Swift, but in case there are any more stubborn Objective-c authors left, here's #Rob's answer translated...
// see https://stackoverflow.com/a/35714554/294949
#import "RoundView.h"
#interface Action : NSObject<CAAction>
#property(strong,nonatomic) CABasicAnimation *pendingAnimation;
#property(assign,nonatomic) CGFloat priorCornerRadius;
#end
#implementation Action
- (void)runActionForKey:(NSString *)event object:(id)anObject
arguments:(nullable NSDictionary *)dict {
if ([anObject isKindOfClass:[CALayer self]]) {
CALayer *layer = (CALayer *)anObject;
if (self.pendingAnimation.isAdditive) {
self.pendingAnimation.fromValue = #(self.priorCornerRadius - layer.cornerRadius);
self.pendingAnimation.toValue = #(0);
} else {
self.pendingAnimation.fromValue = #(self.priorCornerRadius);
self.pendingAnimation.toValue = #(layer.cornerRadius);
}
[layer addAnimation:self.pendingAnimation forKey:#"cornerRadius"];
}
}
#end
#interface RoundView ()
#property(weak,nonatomic) UIImageView *imageView;
#end
#implementation RoundView
- (void)layoutSubviews {
[super layoutSubviews];
[self updateCornerRadius];
}
- (void)updateCornerRadius {
self.layer.cornerRadius = MIN(self.bounds.size.width, self.bounds.size.height)/2.0;
}
- (id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)event {
if ([event isEqualToString:#"cornerRadius"]) {
CABasicAnimation *boundsAnimation = (CABasicAnimation *)[self.layer animationForKey:#"bounds.size"];
CABasicAnimation *animation = [boundsAnimation copy];
animation.keyPath = #"cornerRadius";
Action *action = [[Action alloc] init];
action.pendingAnimation = animation;
action.priorCornerRadius = layer.cornerRadius;
return action;
}
return [super actionForLayer:layer forKey:event];;
}
#end
I would suggest not using a corner radius, but instead using a CAShapeLayer as a mask for your view's content layer.
You'd install a filled 360° arc CGPath as the shape of the shape layer and set it as the mask of your layer's view.
You could then either animate a new scale transform for the mask layer, or animate a change to the radius of the path. Both methods should stay round, although the scale transform might not give you a clean shape at smaller pixel sizes.
The timing would be the tricky part (getting the animation of the mask layer to happen in lockstep with the bounds animation.)
Update: The NSTimer approach works now, but comes with a huge performance hit. The question is now narrowed down to an approach without NSTimers.
I'm trying to animate a 'Press and hold' interactive animation. After following a load of SO answers, I've mostly followed the approach in Controlling Animation Timing by #david-rönnqvist. And it works, if I use a Slider to pass the layer.timeOffset.
However, I can't seem to find a good way to continuously update the same animation on a press and hold gesture. The animation either doesn't start, only shows the beginning frame or at some points finishes and refuses to start again.
Can anyone help with achieving the following effect, without the horrible NSTimer approach I'm currently experimenting with?
On user press, animation starts, circle fills up.
While user holds (not necessarily moving the finger), the animation should continue until the end and stay on that frame.
When user lifts finger, the animation should reverse, so the circle is empties again.
If the user lifts his finger during the animation or presses down again during the reverse, the animation should respond accordingly and either fill or empty from the current frame.
Here's a Github repo with my current efforts.
As mentioned, the following code works well. It's triggered by a slider and does its job great.
func animationTimeOffsetToPercentage(percentage: Double) {
if fillShapeLayer == nil {
fillShapeLayer = constructFillShapeLayer()
}
guard let fillAnimationLayer = fillShapeLayer, let _ = fillAnimationLayer.animationForKey("animation") else {
print("Animation not found")
return
}
let timeOffset = maximumDuration * percentage
print("Set animation to percentage \(percentage) with timeOffset: \(timeOffset)")
fillAnimationLayer.timeOffset = timeOffset
}
However, the following approach with NSTimers works, but has an incredible performance hit. I'm looking for an approach which doesn't use the NSTimer.
func beginAnimation() {
if fillShapeLayer == nil {
fillShapeLayer = constructFillShapeLayer()
}
animationTimer?.invalidate()
animationTimer = NSTimer.schedule(interval: 0.1, repeats: true, block: { [unowned self] () -> Void in
if self.layer.timeOffset >= 1.0 {
self.layer.timeOffset = self.maximumDuration
}
else {
self.layer.timeOffset += 0.1
}
})
}
func reverseAnimation() {
guard let fillAnimationLayer = fillShapeLayer, let _ = fillAnimationLayer.animationForKey("animation") else {
print("Animation not found")
return
}
animationTimer?.invalidate()
animationTimer = NSTimer.schedule(interval: 0.1, repeats: true, block: { [unowned self] () -> Void in
if self.layer.timeOffset <= 0.0 {
self.layer.timeOffset = 0.0
}
else {
self.layer.timeOffset -= 0.1
}
})
}
When you use slider you use fillAnimationLayer layer for animation
fillAnimationLayer.timeOffset = timeOffset
However, in beginAnimation and reverseAnimation functions you are using self.layer.
Try to replace self.layer.timeOffset with self.fillShapeLayer!.timeOffset in your timer blocks.
The solution is two-fold;
Make sure the animation doesn't remove itself on completion and keeps its final frame. Easily accomplished with the following lines of code;
animation.fillMode = kCAFillModeForwards
animation.removedOnCompletion = false
The hard part; you have to remove the original animation and start a new, fresh reverse animation that begins at the correct point. Doing this, gives me the following code;
func setAnimation(layer: CAShapeLayer, startPath: AnyObject, endPath: AnyObject, duration: Double)
{
// Always create a new animation.
let animation: CABasicAnimation = CABasicAnimation(keyPath: "path")
if let currentAnimation = layer.animationForKey("animation") as? CABasicAnimation {
// If an animation exists, reverse it.
animation.fromValue = currentAnimation.toValue
animation.toValue = currentAnimation.fromValue
let pauseTime = layer.convertTime(CACurrentMediaTime(), fromLayer: nil)
// For the timeSinceStart, we take the minimum from the duration or the time passed.
// If not, holding the animation longer than its duration would cause a delay in the reverse animation.
let timeSinceStart = min(pauseTime - startTime, currentAnimation.duration)
// Now convert for the reverse animation.
let reversePauseTime = currentAnimation.duration - timeSinceStart
animation.beginTime = pauseTime - reversePauseTime
// Remove the old animation
layer.removeAnimationForKey("animation")
// Reset startTime, to be when the reverse WOULD HAVE started.
startTime = animation.beginTime
}
else {
// This happens when there is no current animation happening.
startTime = layer.convertTime(CACurrentMediaTime(), fromLayer: nil)
animation.fromValue = startPath
animation.toValue = endPath
}
animation.duration = duration
animation.fillMode = kCAFillModeForwards
animation.removedOnCompletion = false
layer.addAnimation(animation, forKey: "animation")
}
This Apple article explains how to do a proper pause and resume animation, which is converted to use with the reverse animation.
I saw a lot of answers about this topic, but can not solve my particular case.
Here is a video with complete animation and logging timeline
class AnimationDelegate: UIView {
deinit {
print("AnimationDelegate deinitialized")
}
override func animationDidStart(anim: CAAnimation) {
print("animationDidStart")
}
override func animationDidStop(anim: CAAnimation, finished flag: Bool) {
print("animationDidStop")
}
func play() {
let line_1 = CAShapeLayer()
...
let animation = CABasicAnimation()
animation.delegate = self
animation.duration = 3000
animation.repeatCount = 1
animation.keyPath = "strokeEnd"
animation.fromValue = 0
animation.toValue = 360
layer.addSublayer(line_1)
line_1.addAnimation(animation, forKey: "StrokeAnimation")
}
}
let delegate = AnimationDelegate(frame: container.bounds)
container.addSubview(delegate)
delegate.play()
The problem is animationDidStart called, but animationDidStop not.
How this is possible?
The animationDidStop will get called, had you waited longer. But this is, certainly, not the behavior you expected.
I gather that you would like to let the animation run for 3 seconds instead of 3000; and once completed, receive a notification from the delegate callback, which is animationDidStop.
So, instead, you would need to change:
animation.duration = 3
and:
animation.toValue = 1
Thereby, you would see that both animationDidStart and animationDidStop will get called accordingly.
Neither animationDidStart nor animationDidStop worked.
I try to work this lines in a view controller :
let delegate = AnimationDelegate(frame: container.bounds)
container.addSubview(delegate)
delegate.play()
Anitmation worked but override methods didn't work.