CAKeyframeAnimation() configured for 'scrubbing', how to smooth transition to stop points? - swift

I'm animating an object (CAShapeLayer) along a bezier path by pausing the animation and setting the timeOffset manually, which moves it to fixed locations. But the movement is jerky. I want to be able to set the next offset and have it interpolate/animate smoothly from spot to spot. This is essentially what I'm doing. What should I do to smooth out the transitions?
let bezierCurve = UIBezierPath(...)
let shapeLayer = getShape()
layer.speed = 0 // pause
.
.
.
let pathAnimation = CAKeyframeAnimation()
pathAnimation.keyPath = "position"
pathAnimation.path = bezierCurve.cgPath
pathAnimation.calculationMode = .paced
pathAnimation.fillMode = .forwards
pathAnimation.duration = 1
pathAnimation.isRemovedOnCompletion = false
pathAnimation.beginTime = CACurrentMediaTime()
shapeLayer.add(pathAnimation, forKey: nil)
layer.addSublayer(shapeLayer)
.
.
layer.timeOffset = pathAnimation.beginTime + n // note: periodically update n

Related

Create flip cart animation between two NSViews [duplicate]

I'm making a card game for mac and I'm using a CABasicAnimation to making the card flip around. It's almost working, but it could be a bit better.
As it works now, the card flips inwards (to the left) - Screenshot 1. When the card has moved "flipped" all the way to the left, I change the image of the NSView and flip the card outwards again - Screenshot 2.
Screenshot 1 (flipping in):
Screenshot 2 (flipping out):
Code for flipping in:
- (void)flipAnimationInwards{
// Animate shadow
NSShadow *dropShadow = [[NSShadow alloc] init];
[dropShadow setShadowOffset:NSMakeSize(0, 1)];
[dropShadow setShadowBlurRadius:15];
[dropShadow setShadowColor:[NSColor colorWithCalibratedWhite:0.0 alpha:0.5]];
[[self animator] setShadow:dropShadow];
// Create CAAnimation
CABasicAnimation* rotationAnimation;
rotationAnimation = [CABasicAnimation animationWithKeyPath:#"transform.rotation.y"];
rotationAnimation.fromValue = [NSNumber numberWithFloat: 0.0];
rotationAnimation.toValue = [NSNumber numberWithFloat: M_PI/2];
rotationAnimation.duration = 3.1;
rotationAnimation.repeatCount = 1.0;
rotationAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseIn];
rotationAnimation.fillMode = kCAFillModeForwards;
rotationAnimation.removedOnCompletion = NO;
[rotationAnimation setValue:#"flipAnimationInwards" forKey:#"flip"];
rotationAnimation.delegate = self;
// Get the layer
CALayer* lr = [self layer];
// Add perspective
CATransform3D mt = CATransform3DIdentity;
mt.m34 = 1.0/-1000;
lr.transform = mt;
// Set z position so the layer will be on top
lr.zPosition = 999;
// Keep cards tilted when flipping
if(self.tiltCard)
self.frameCenterRotation = self.frameCenterRotation;
// Do rotation
[lr addAnimation:rotationAnimation forKey:#"flip"];
}
The code for flipping out:
- (void)flipAnimationOutwards{
// Set correct image
if (self.faceUp){
[self setImage:self.faceImage];
}else{
[self setImage:[NSImage imageNamed:#"Card_Background"]];
}
// Animate shadow
NSShadow *dropShadow = [[NSShadow alloc] init];
[dropShadow setShadowOffset:NSMakeSize(0, 1)];
[dropShadow setShadowBlurRadius:0];
[dropShadow setShadowColor:[NSColor colorWithCalibratedWhite:0.0 alpha:0.0]];
[[self animator] setShadow:dropShadow];
// Create CAAnimation
CABasicAnimation* rotationAnimation;
rotationAnimation = [CABasicAnimation animationWithKeyPath:#"transform.rotation.y"];
rotationAnimation.fromValue = [NSNumber numberWithFloat: M_PI/2];
rotationAnimation.toValue = [NSNumber numberWithFloat: 0.0];
rotationAnimation.duration = 3.1;
rotationAnimation.repeatCount = 1.0;
rotationAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseOut];
rotationAnimation.fillMode = kCAFillModeForwards;
rotationAnimation.removedOnCompletion = YES;
[rotationAnimation setValue:#"flipAnimationOutwards" forKey:#"flip"];
rotationAnimation.delegate = self;
// Get the layer
CALayer* lr = [self layer];
// Add perspective
CATransform3D mt = CATransform3DIdentity;
mt.m34 = 1.0/1000;
lr.transform = mt;
// Set z position so the layer will be on top
lr.zPosition = 999;
// Keep cards tilted when flipping
if(self.tiltCard)
self.frameCenterRotation = self.frameCenterRotation;
// Commit animation
[lr addAnimation:rotationAnimation forKey:#"flip"];
}
The problem:
The flipping out part looks fine. The right side of the card is taller/stretched than the left side, like it's supposed to be.
Flipping in is not perfect though. Here the right side is smaller/stretched, when it should be the left side that is taller/stretched.
How do I make the left side taller/stretched on flipping in, instead of making the right side smaller/stretched?
You ask:
How do I make the left side taller/stretched on flipping in, instead of making the right side smaller/stretched?
You also say that flipping out works fine but flipping in is wrong.
The difference between the two is in the sign of the perspective:
Flipping out code:
CATransform3D mt = CATransform3DIdentity;
mt.m34 = 1.0/1000; // note the lack of a minus sign
lr.transform = mt;
Flipping in code:
CATransform3D mt = CATransform3DIdentity;
mt.m34 = 1.0/-1000; // note the minus sign
lr.transform = mt;
If you want the two to look the same then they should most likely have the same perspective.
In my experience you usually want the negative perspective value (as you have done in the flipping in example). This has to do with the fact that the value represents the position of the "eye" / "camera" / "observer" or whatever you call it.
If you imagine a 3D scene where the position of the eye is (ex, ey, ez) then the perspective part of the transform is:
Assuming that you are looking at right at the world (i.e. not looking at it from the side) the position would be (0, 0, ez) which is the reason why we usually only set m34 (3rd column, 4th row) when adding perspective to a transform.
You can also see that this is how it is used in the Core Animation Programming Guide:
Listing 5-8 Adding a perspective transform to a parent layer
CATransform3D perspective = CATransform3DIdentity;
perspective.m34 = -1.0/eyePosition;
If the rotation looks wrong to you should probably rotate in the other direction (for example changing a rotation from 0 to π into a rotation from 0 to -π or the other way around: changing a rotation from π to 0 into a rotation from -π to 0.
What about adding some scale while you flip the card?
You could even exaggerate it and it would look as if someone lifted the card in order to flip it.
Some code to scale a view inspired by this answer:
CABasicAnimation *scaleAnimation = [CABasicAnimation animationWithKeyPath:#"transform.scale"];
scaleAnimation.fromValue = [NSNumber numberWithFloat:1.0];
scaleAnimation.toValue = [NSNumber numberWithFloat:1.3];
I understand, this is late answer, but I wrote easy usage solution.
FlipTransition
import Cocoa
public final class FlipTransition: NSObject {
private var srcView, dstView: NSView?
private var duration: TimeInterval = 0.3
public func flip(
from srcView: NSView,
to dstView: NSView,
duration: TimeInterval = 0.3
) {
self.duration = duration
self.srcView = srcView
self.dstView = dstView
srcView.isHidden = false
dstView.isHidden = true
// Get super layer
guard let superLayer = srcView.superview?.layer else {
return
}
// Setup super layer 3d perspective
var transform3D = CATransform3DIdentity
transform3D.m34 = -1 / 1000
let translation = CATransform3DMakeTranslation(
superLayer.bounds.midX,
superLayer.bounds.midY,
.zero
)
superLayer.sublayerTransform = CATransform3DConcat(
transform3D,
translation
)
// Set layer anchor & position to center
[srcView, dstView]
.compactMap(\.layer)
.forEach { layer in
layer.anchorPoint = .init(x: 0.5, y: 0.5)
}
// Start src view animation
animate(
srcView,
from: CATransform3DIdentity,
to: CATransform3DMakeRotation(CGFloat.pi / -2, 0, 1, 0)
) { f in
self.startSecondStep()
}
}
private func startSecondStep() {
guard let srcView = srcView, let dstView = dstView else {
return
}
srcView.isHidden = true
dstView.isHidden = false
animate(
dstView,
from: CATransform3DMakeRotation(CGFloat.pi / 2, 0, 1, 0),
to: CATransform3DIdentity
) { f in
self.finish()
}
}
private func finish() {
guard let srcView = srcView, let dstView = dstView else {
return
}
srcView.layer?.removeAllAnimations()
dstView.layer?.removeAllAnimations()
[srcView, dstView]
.compactMap(\.layer)
.forEach { layer in
layer.anchorPoint = .zero
}
srcView.superview?.layer?.sublayerTransform = CATransform3DIdentity
}
// MARK: - Animation Utility
private class AnimationDelegate: NSObject, CAAnimationDelegate {
private let completion: (Bool) -> Void
init(completion: #escaping (Bool) -> Void) {
self.completion = completion
}
func animationDidStart(_ anim: CAAnimation) { }
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
completion(flag)
}
}
private func animate(
_ view: NSView,
from: CATransform3D,
to: CATransform3D,
completion: #escaping (Bool) -> Void
) {
let dstRotation = CABasicAnimation(keyPath: "transform")
dstRotation.fromValue = from
dstRotation.toValue = to
dstRotation.duration = duration / 2
dstRotation.fillMode = .forwards
dstRotation.isRemovedOnCompletion = false
dstRotation.delegate = AnimationDelegate(completion: completion)
view.layer?.add(dstRotation, forKey: "flip")
}
}
Usage
FlipTransition().flip(from: srcView, to: dstView)
View Structure
holderView {
srcView,
dstView
}
dstView initially should be hidden (dstView.isHidden = true). holderView, srcView and dstView should have equal sizes.

Swift5: Stop CABasicAnimation animation, just when the animation is finished

I have 4 edges, one for each corner, with animation. The only thing that the animation does is to vary the alpha of that border. It goes from 0.05 to 1 that alpha.
I am doing this way to the animation:
private func startAnimation(duration: CFTimeInterval) {
let cornerAnimate = CABasicAnimation(keyPath: #keyPath(CALayer.opacity))
cornerAnimate.fromValue = 1
cornerAnimate.toValue = 0.05
cornerAnimate.duration = duration
cornerAnimate.repeatCount = .infinity
cornerAnimate.autoreverses = true
cornerAnimate.isRemovedOnCompletion = false
cornerAnimate.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
corners.forEach { corner in
corner.add(cornerAnimate, forKey: "opacity")
}
}
I have it in .infinity because that's what I want to do. I want the animation to be displayed infinitely, and when I tell it to, at any time, stop it.
But I don't want it to stop abruptly, I want it to stop when the alpha is at 1.0. I mean, when I call the function stopAnimation(), it follows a little bit the animation until it 'finishes that cycle' and when the alpha is at 1.0 then it stops it.
This is what I tried to do, but the animation is still abrupt:
func stopAnimation() {
let endAnimation = CABasicAnimation(keyPath: #keyPath(CALayer.opacity))
var actualOpacity: Double = 0.05
corners.forEach { corner in
actualOpacity = corner.presentation()?.value(forKeyPath: "opacity") as! Double
}
endAnimation.fromValue = actualOpacity
endAnimation.toValue = 1.0
endAnimation.duration = 1.0
corners.forEach { corner in
corner.add(endAnimation, forKey: "end")
corner.removeAnimation(forKey: "opacity")
}
}
It looks like you're building the app in the simulator (since I can see the mouse movement), this appears to be a bug that effects simulators only. I was able to reproduce it in the simulator but not on an actual device.
Run it on a device and you should not be seeing that glitch.

How to smoothly finish infinity animation

I have infinity CABasicAnimation which actually simulate pulsating by increasing and decreasing scale:
scaleAnimation.fromValue = 0.5
scaleAnimation.toValue = 1.0
scaleAnimation.duration = 0.8
scaleAnimation.autoreverses = true
scaleAnimation.repeatCount = .greatestFiniteMagnitude
scaleAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
I want to smoothly stop this animation in toValue. In other words, I want to allow current animation cycle finish, but stop repeating. Is there a nice and clean way to do this? I had a few ideas about freezing current animation, removing it and creating a new one with time offset, but maybe there is a better way?
There is a standard way to do this cleanly — though it's actually quite tricky if you don't know about it:
The first thing you do is set the layer's scale to the scale of its presentationLayer.
Then call removeAllAnimations on the layer.
Now do a fast animation where you set the layer's scale to 1.
Here's a possible implementation (for extra credit, I suppose we could adjust the duration of the fast animation to match what the current scale is, but I didn't bother to do that here):
#IBAction func doStop(_ sender: Any) {
let lay = v.layer
lay.transform = lay.presentation()!.transform
lay.removeAllAnimations()
CATransaction.flush()
lay.transform = CATransform3DIdentity
let scaleAnimation = CABasicAnimation(keyPath: "transform")
scaleAnimation.duration = 0.4
scaleAnimation.timingFunction = CAMediaTimingFunction(name: .easeOut)
lay.add(scaleAnimation, forKey: nil)
}
Result:

Creating a progress indicator with a rounded rectangle

I am attempting to create a rounded-rectangle progress indicator in my app. I have previously implemented a circular indicator, but not like this shape. I would like it to look something like this (start point is at the top):
But I get this with 0 as the .strokeStart property of the layer:
My current code place in viewDidLoad():
let queueShapeLayer = CAShapeLayer()
let queuePath = UIBezierPath(roundedRect: addToQueue.frame, cornerRadius: addToQueue.layer.cornerRadius)
queueShapeLayer.path = queuePath.cgPath
queueShapeLayer.lineWidth = 5
queueShapeLayer.strokeColor = UIColor.white.cgColor
queueShapeLayer.fillColor = UIColor.clear.cgColor
queueShapeLayer.strokeStart = 0
queueShapeLayer.strokeEnd = 0.5
view.layer.addSublayer(queueShapeLayer)
addToQueue is the button which says 'Upvote'.
Unlike creating a circular progress indicator, I cannot set the start and end angle in the initialisation of a Bezier path.
How do I make the progress start from the top middle as seen in the first image?
Edit - added a picture without corner radius on:
It seems that the corner radius is creating the issue.
If you have any questions, please ask!
I found a solution so the loading indicator works for round corners:
let queueShapeLayer = CAShapeLayer()
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// Queue timer
let radius = addToQueue.layer.cornerRadius
let diameter = radius * 2
let totalLength = (addToQueue.frame.width - diameter) * 2 + (CGFloat.pi * diameter)
let queuePath = UIBezierPath(roundedRect: addToQueue.frame, cornerRadius: radius)
queueShapeLayer.path = queuePath.cgPath
queueShapeLayer.lineWidth = 5
queueShapeLayer.strokeColor = UIColor.white.cgColor
queueShapeLayer.fillColor = UIColor.clear.cgColor
queueShapeLayer.strokeStart = 0.25 - CGFloat.pi * diameter / 3 / totalLength // Change the '0.25' to 0.5, 0.75 etc. wherever you want the bar to start
queueShapeLayer.strokeEnd = queueShapeLayer.strokeStart + 0.5 // Change this to the value you want it to go to (in this case 0.5 or 50% loaded)
view.layer.addSublayer(queueShapeLayer)
}
After I had did this though, I was having problems that I couldn't animate the whole way round. To get around this, I created a second animation (setting strokeStart to 0) and then I placed completion blocks so I could trigger the animations at the correct time.
Tip:
Add animation.fillMode = CAMediaTimingFillMode.forwards & animation.isRemovedOnCompletion = false when using a CABasicAnimation for the animation to wait until you remove it.
I hope this formula helps anyone in the future!
If you need help, you can always message me and I am willing to help. :)

UIView custom transition snaps back on completion

I have implemented a class BubbleAnimator, that should create a bubble-like transition between views and added it through the UIViewControllerTransitioningDelegate-protocol. The presenting animation works fine so far (that's why I haven't added all the code for this part).
But on dismissing the view, the 'fromViewController' flashes up at the very end of the animation. After this very short flash, the correct toViewController is displayed again, but this glitch is very annoying.
The following is the relevant animateTransition-method:
//Get all the necessary views from the context
let containerView = transitionContext.containerView()
let fromViewController = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey)
let toViewController = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey)
//Presenting
if self.reverse == false {
//Add the destinationvc as subview
containerView!.addSubview(fromViewController!.view)
containerView!.addSubview(toViewController!.view)
/*...Animating the layer goes here... */
//Dismissing
} else {
containerView!.addSubview(toViewController!.view)
containerView!.addSubview(fromViewController!.view)
//Init the paths
let circleMaskPathInitial = UIBezierPath(ovalInRect: self.originFrame)
let extremePoint = CGPoint(x: originFrame.origin.x , y: originFrame.origin.y - CGRectGetHeight(toViewController!.view.bounds) )
let radius = sqrt((extremePoint.x*extremePoint.x) + (extremePoint.y*extremePoint.y))
let circleMaskPathFinal = UIBezierPath(ovalInRect: CGRectInset(originFrame, -radius, -radius))
//Create a layer
let maskLayer = CAShapeLayer()
maskLayer.path = circleMaskPathFinal.CGPath
fromViewController!.view.layer.mask = maskLayer
//Create and add the animation
let animation = CABasicAnimation(keyPath: "path")
animation.toValue = circleMaskPathInitial.CGPath
animation.fromValue = circleMaskPathFinal.CGPath
animation.duration = self.transitionDuration(transitionContext)
animation.delegate = self
maskLayer.addAnimation(animation, forKey: "path")
}
The cleanup takes place in the delegate method:
override public func animationDidStop(anim: CAAnimation, finished flag: Bool) {
self.transitionContext?.completeTransition(!(self.transitionContext?.transitionWasCancelled())!)
}
I guess, that I am doing something wrong with adding the views to the containerView, but I couldn't figure it out. Another possibility is, that the view's layer mask gets reset, when the function completeTransition is called.
Thanks to this blogpost I have finally been able to solve this problem. Short explanation:
The CAAnimation only manipulates the presentation-layer of the view, but does not change the model-layer. When the animation now finishes, it's value snaps back to the original and unchanged value of the model-layer.
Short and simply workaround:
animation.fillMode = kCAFillModeForwards
animation.removedOnCompletion = false
The better solution, as it doesn't prevent the animation from being removed is to manually set the final value of the layer's position before the animation starts. This way, the model-layer is assigned the correct value:
maskLayer.path = circleMaskPathInitial.CGPath
//Create and add the animation below..