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.
Related
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
I have a layer and want to create an animation for this layer which will update contents of one of sublayers. CAAnimation keyPath has a notation, like sublayers.layerName.propertyName to update some values of a sublayer but seems like it doesn't work with .contents property.
func rightStepAfter(_ t: Double) -> CAAnimation {
let rightStep = CAKeyframeAnimation(keyPath: "sublayers.right.contents")
rightStep.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
rightStep.keyTimes = [0, 1]
rightStep.values = [UIImage(named:"leftfoot")!.cgImage!, UIImage(named:"rightfoot")!.cgImage!]
rightStep.beginTime = t
rightStep.duration = stepDuration
rightStep.fillMode = .forwards
return rightStep
}
func leftStepAfter(_ t: Double) -> CAAnimation {
let leftStep = CAKeyframeAnimation(keyPath: "sublayers.left.opacity")
leftStep.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
leftStep.keyTimes = [0, 1]
leftStep.values = [0, 1]
leftStep.beginTime = t
leftStep.duration = stepDuration
leftStep.fillMode = .forwards
return leftStep
}
Here leftStepAfter creates correct animation which updates opacity of a sublayer and rightStepAfter doesn't update contents of a sublayer. If you remove sublayers.right. from the keyPath - animation will correctly change contents of a CURRENT layer. Project to check it and the original project.
Why my animation doesn't work and how to fix it?
Did not find an answer to my original question (via keypath), but solved my problem by creating several animations for each sublayer.
I am simply trying to change the background color of the last element of a CALayer array. Here is my entire View Class, however its only 2-3 lines that I actually try to access the last element of the CALayer.
Here is my progressViewClass and I put comments to where exactly my problem is:
class ProgressBarView: UIView {
//Variables for progress bar
var holdGesture = UILongPressGestureRecognizer()
let animation = CABasicAnimation(keyPath: "bounds.size.width")
var layerHolder = [CALayer]()
var widthIndex = CGPoint(x: 0, y: 0)
var nextXOffset = CGFloat(0.0)
var checkIfFull = CGFloat()
var newLayer : CALayer?
var progressBarAnimationDuration : CFTimeInterval = (MainController.sharedInstance.totalMiliSeconsToRecord / 10)
// Only override draw() if you perform custom drawing.
// An empty implementation adversely affects performance during animation.
override func draw(_ rect: CGRect) {
}
func startProgressBar(){
if(RecordingViewController().currentCameraMode == .recordingMode || RecordingViewController().currentCameraMode == .record1stClipMode) {
newLayer = CALayer()
newLayer?.frame = CGRect(x: nextXOffset, y: 0, width: 0, height: self.bounds.height)
newLayer?.backgroundColor = UIColor(red:0.82, green:0.01, blue:0.11, alpha:1.0).cgColor
//print("before \(nextXOffset)")
newLayer?.anchorPoint = widthIndex
animation.fromValue = 0
animation.toValue = self.bounds.width - nextXOffset
animation.duration = progressBarAnimationDuration - ((MainController.sharedInstance.miliSecondsPassed) / 10)
self.layer.addSublayer(newLayer!)
//print("Long Press Began")
newLayer?.add(animation, forKey: "bounds.size.width")
}
else{
stopProgressBar()
}
}
func stopProgressBar(){
if(RecordingViewController().currentCameraMode != .recordingMode){
pauseLayer(layer: newLayer!)
newLayer?.frame = (newLayer?.presentation()!.frame)!
nextXOffset = (newLayer?.frame.maxX)!
layerHolder.append(newLayer!)
print("Layerholder has elements : \(layerHolder.count)")
}
}
// HERE IS MY PROBLEM
func highlightLastLayer(){
print("in highlight last layer Layerholder has elements : \(layerHolder.count)")
// I CAN HIDE THE CALAYER SO I BELIEVE IM ACCESSING THE CORRECT LAYER
// layerHolder.last?.isHidden = true
// This is suppose to change the last element background color to blue but doesnt
layerHolder.last?.backgroundColor = UIColor.blue.cgColor
}
// ALSO MY PROBLEM
func unhighlightLastLayer(){
print("inside unhighlight last layer")
// I CAN HIDE THE CALAYER SO I BELIEVE IM ACCESSING THE CORRECT LAYER
//layerHolder.last?.isHidden = false
// Changes CALayer back to red
layerHolder.last?.backgroundColor = UIColor(red:0.82, green:0.01, blue:0.11, alpha:1.0).cgColor
}
//Function to pause the Progress Bar
func pauseLayer(layer : CALayer){
let pausedTime : CFTimeInterval = layer.convertTime(CACurrentMediaTime(), from: nil)
layer.speed = 0.0
layer.timeOffset = pausedTime
}
}
Simply put, I create a progressView object in my viewController and then call those functions based on certain button input. This view is essentially a progress bar that you'd see in many video recording applications to show how much you have recorded. In the highlightLastLayer, I am trying to grab the last element of the "layerHolder" array and change its color to blue. Simple right? Doesn't work. Any ideas?
Where are you calling highlight and unhighlight. I am pretty sure you are doing this when you are "stopped" or "paused" because thats the only time you add anything to the layerHolder Array. You can only do this when you are not animating because when you are animating the presentation layer is shown instead of the "real" layer. Instead of setting the speed to zero, setup your layer to look like the current animation state and call layer. removeAllAnimations to kill the presentation layer and show the actual layer for your view instead. Now you can make all the changes that you want and they will actually show up.
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..
i will like to move my slider together with my thumb image in vertical. However the moving was wrong.
in horizontal it works perfectly but not vertically
- (void)viewDidLoad
{
CGAffineTransform trans = CGAffineTransformMakeRotation(-M_PI * 0.5);
self.SDSlider.transform = trans;
}
- (IBAction)sliderValueChanged:(UISlider *)sender
{
self.SDlabel.text = [NSString stringWithFormat:#"%d", (int)sender.value];
CGRect trackRect = [self.SDSlider trackRectForBounds:self.SDSlider.bounds];
CGRect thumbRect = [self.SDSlider thumbRectForBounds:self.SDSlider.bounds
trackRect:trackRect
value:self.SDSlider.value];
self.testSDlabel.center = CGPointMake(thumbRect.origin.x + self.SDSlider.frame.origin.x, self.SDSlider.frame.origin.y - 20);
self.testSDlabel.text = [NSString stringWithFormat:#"%.0f", self.SDSlider.value];
}
the label shifting from left to right instead of botton to up
Got is working by changing the center.y value
- (IBAction)sliderValueChanged:(UISlider *)sender
{
self.SDlabel.text = [NSString stringWithFormat:#"%d", (int)sender.value];
CGRect trackRect = [self.SDSlider trackRectForBounds:self.SDSlider.bounds];
CGRect thumbRect = [self.SDSlider thumbRectForBounds:self.SDSlider.bounds
trackRect:trackRect
value:self.SDSlider.value];
self.SDlabel.text = [NSString stringWithFormat:#"%.0f", self.SDSlider.value];
NSLog(#"thumbRect.origin.x %f",thumbRect.origin.x);
NSLog(#"self.testSDlabel %f",self.SDlabel.center.x);
self.SDlabel.center = CGPointMake(self.SDlabel.center.x, 423 - thumbRect.origin.x);
}
First set the initial center of label in viewdidLoad as
override func viewDidLoad() {
super.viewDidLoad()
initialLabelCenter = labelSliderValue.center.x
}
//Set value of slider into label from 100 to 1000
#IBAction func sliderActionValueChanged(_ sender: UISlider) {
let currentValue: CGFloat = CGFloat(sender.value)
labelSliderValue.text = String(describing: currentValue)
//Returns the drawing rectangle for the slider’s track
let trackRect = sliderInstance.trackRect(forBounds: sliderInstance.bounds)
//Returns the drawing rectangle for the slider’s thumb image.
let sliderThumbRect = sliderInstance.thumbRect(forBounds: sliderInstance.bounds, trackRect: trackRect, value: sliderInstance.value)
//Set label's center to move in a particular direction. In this case it will move left to right horizontally
labelSliderValue.center = CGPoint(x: initialLabelCenter + sliderThumbRect.origin.x, y: self.labelSliderValue.center.y)
}
NOTE: This code moves the label from left to right horizontally.