ios animating already transformed view - ios

I have a collection view with a collection of images (which are views). The layout causes transformations to occur on every visible item. The item at the center is both zoomed and translated while the others are just translated.
The point is, the layout is setting the layer's transform property for each item.
Later, when the user touches an item, I want to animate the item using a keyframe animation.
The behavior I am experiencing is that the item seems to revert back to its untransformed state, the animation occurs on its untransformed state and then the item's layout transformation returns.
Why?
I've tried using the view's transform property, the backing layer's affine transform property, and the backing layer's 3D transform property all with this same behavior. The keyframe animation uses 3D transforms.
I clearly have a gap in my understanding of transforms and animations, though I have seen this question posed for platforms other than iOS without answers.
Layout transformation code (in a flow layout subclass):
// Calculate the distance from the center of the visible rect to the center of the attributes.
// Then normalize it so we can compare them all. This way, all items further away than the
// active get the same transform.
let distanceFromVisibleRectToItem = CGRectGetMidX(visibleRect) - attributes.center.x
let normalizedDistance = distanceFromVisibleRectToItem / ACTIVE_DISTANCE
let isLeft = distanceFromVisibleRectToItem > 0
var transform = CATransform3DIdentity
var maskAlpha: CGFloat = 0.0
if (abs(distanceFromVisibleRectToItem) < ACTIVE_DISTANCE) {
// We're close enough to apply the transform in relation to how far away from the center we are.
transform = CATransform3DTranslate(transform,
(isLeft ? -FLOW_OFFSET : FLOW_OFFSET) * abs(distanceFromVisibleRectToItem/TRANSLATE_DISTANCE),
0, (1 - abs(normalizedDistance)) * 40000 + (isLeft ? 200 : 0))
// Set the zoom factor.
let zoom = 1 + ZOOM_FACTOR * (1 - abs(normalizedDistance))
transform = CATransform3DScale(transform, zoom, zoom, 1)
attributes.zIndex = 1
let ratioToCenter = (ACTIVE_DISTANCE - abs(distanceFromVisibleRectToItem)) / ACTIVE_DISTANCE
// Interpolate between 0.0f and INACTIVE_GREY_VALUE
maskAlpha = INACTIVE_GREY_VALUE + ratioToCenter * (-INACTIVE_GREY_VALUE)
} else {
// We're too far away - just apply a standard perspective transform.
transform = CATransform3DTranslate(transform, isLeft ? -FLOW_OFFSET : FLOW_OFFSET, 0, 0)
attributes.zIndex = 0
maskAlpha = INACTIVE_GREY_VALUE
}
attributes.transform3D = transform
Animation code (note this is a Swift extension to the UIView class):
func bounceView(completion: (() -> Void)? = nil) {
var animation = bounceAnimation(frame.height)
animation.delegate = AnimationDelegate(completion: completion)
layer.addAnimation(animation, forKey: "bounce")
}
func bounceAnimation(itemHeight: CGFloat) -> CAKeyframeAnimation {
let factors: [CGFloat] = [0, 32, 60, 83, 100, 114, 124, 128, 128, 124, 114, 100, 83, 60, 32,
0, 24, 42, 54, 62, 64, 62, 54, 42, 24, 0, 18, 28, 32, 28, 18, 0]
var transforms: [AnyObject] = [NSValue(CATransform3D: self.layer.transform)]
for factor in factors {
let positionOffset = factor/256.0 * itemHeight
let transform = CATransform3DMakeTranslation(0, -positionOffset, 0)
transforms.append(NSValue(CATransform3D: transform))
}
let animation = CAKeyframeAnimation(keyPath: "transform")
animation.repeatCount = 1
animation.duration = CFTimeInterval(factors.count)/30.0
animation.fillMode = kCAFillModeForwards
animation.values = transforms
animation.removedOnCompletion = true // final stage is equal to starting stage
animation.autoreverses = false
return animation;
}
Note: I should state more simply that I want this process to begin and end in the collection view layout transformed state. I want the animation to occur on the layout transformed state as well (i.e. no reverting to the untransformed state at all).

It seems that in your animations you are not syncing your model with your presentation layer.
When you animate with Core Animation (CABasicAnimation, for instance) it only changes the presentation layer (the thing you see on the screen) but in fact the state your layers have is different from the visible one.
(You can read more about that in this objc.io issue)
So basically to fix this you need to update your model in your CAAnimation. You need to set a from and toValue, and then, after you set those, you update your transform so it can match the final state of your presentation layer.
EDIT
Now that we have some code I can be concrete
To sync the model layer to the presentation layer you need to set your model to the final state before adding the animation:
var animation = bounceAnimation(frame.height)
animation.delegate = AnimationDelegate(completion: completion)
layer.transform = CATransform3DMakeTranslation(0, 0, 0)
layer.addAnimation(animation, forKey: "bounce")
David Rönnqvist has a good rant about this issue in this gist.

SOLUTION:
The help Tiago Almeida offered was very useful to get me thinking about what the animation was actually doing (how it was actually working). My basic assumption that the animation would begin with the current model layer transform was incorrect. As Tiago points out, the presentation layer is a separate and distinct layer from the model layer.
Once I got that through my thick head, I realized that I had to manually concatenate the current model transform with the transform being built for the animation frame and viola! Things work as expected.
The updated animation code:
var transforms: [AnyObject] = []
for factor in factors {
let positionOffset = factor/256.0 * itemHeight
var transform = CATransform3DMakeTranslation(0, -positionOffset, 0)
transform = CATransform3DConcat(self.layer.transform, transform)
transforms.append(NSValue(CATransform3D: transform))
}

Related

Resize sprite without shrinking contents

I have created a big circle with a UIBezierPath and turned it into a Sprite using this,
let path = UIBezierPath(arcCenter: CGPoint(x: 0, y: 0), radius: CGFloat(226), startAngle: 0.0, endAngle: CGFloat(M_PI * 2), clockwise: false)
// create a shape from the path and customize it
let shape = SKShapeNode(path: path.cgPath)
shape.lineWidth = 20
shape.position = center
shape.strokeColor = UIColor(red:0.98, green:0.99, blue:0.99, alpha:1.00)
let trackViewTexture = self.view!.texture(from: shape, crop: outerPath.bounds)
let trackViewSprite = SKSpriteNode(texture: trackViewTexture)
trackViewSprite.physicsBody = SKPhysicsBody(edgeChainFrom: innerPath.cgPath)
self.addChild(trackViewSprite)
It works fine. It creates the circle perfectly. But I need to resize it using
SKAction.resize(byWidth: -43, height: -43, duration: 0.3)
Which will make it a bit smaller. But, when it resizes the 20 line width I set now is very small because of the aspect fill. So when I shink it looks something like this:
But I need it to shrink like this-- keeping the 20 line width:
How would I do this?
Don't know if this would affect anything, but the sprites are rotating with an SKAction forever
-- EDIT --
Now, how do I use this method to scale to a specific size? Like turn 226x226 to 183x183?
Since by scaling down the circle, not only its radius gets scaled but its line's width too, you need to set a new lineWidth proportional with the scale. For example, when scaling the circle down by 2, you will need to double the lineWidth.
This can be done in two ways:
Setting the lineWidth in the completion block of the run(SKAction, completion: #escaping () -> Void) method. However this will result in seeing the line shrinking while the animation is running, then jumping to its new width once the animation finishes. If your animation is short, this may not be easy to observe tough.
Running a parallel animation together with the scaling one, which constantly adjusts the lineWidth. For this, you can use SKAction's customAction method.
Here is an example for your case:
let scale = CGFloat(0.5)
let finalLineWidth = initialLineWidth / scale
let animationDuration = 1.0
let scaleAction = SKAction.scale(by: scale, duration: animationDuration)
let lineWidthAction = SKAction.customAction(withDuration: animationDuration) { (shapeNode, time) in
if let shape = shapeNode as? SKShapeNode {
let progress = time / CGFloat(animationDuration)
shape.lineWidth = initialLineWidth + progress * (finalLineWidth - initialLineWidth)
}
}
let group = SKAction.group([scaleAction, lineWidthAction])
shape.run(group)
In this example, your shape will be scaled by 0.5, therefore in case of an initial line width of 10, the final width will be 20. First we create a scaleAction with a specified duration, then a custom action which will update the line's width every time its actionBlock is called, by using the progress of the animation to make the line's width look like it's not changing. At the end we group the two actions so they will run in parallel once you call run.
As a hint, you don't need to use Bezier paths to create circles, there is a init(circleOfRadius: CGFloat) initializer for SKShapeNode which creates a circle for you.

CALayers: A) Can I draw directly on them and B) How to make a width adjustment on a superlayer affect a sublayer

So my goal is to make a sort of sliding door animation in response to a swipe gesture. You can see a GIF of my current animation here (ignore the fact that the gesture behaves opposite to what you'd expect).
Here's how I'm currently accomplishing this: I have a subclass of UIView I'm calling DoorView. DoorView has three CALayers: the base superlayer that comes with every UIView; a sublayer called doorLayer which is the white rectangle that slides; and another sublayer called frameLayer which is the "doorframe" (the black border around doorLayer). The doorLayer and the frameLayer have their own separate animations that are triggered in sequence.
Here's what I need to add to DoorView: a simple rectangle that represents a door handle. At the moment I don't plan to give the door handle its own animation. Instead, I want it to simply be "attached" to the doorLayer so that it animates along with any animations applied to doorLayer.
This is where my first question comes in: I know that I can add another layer (let's call it handleLayer) and add it as a sublayer to doorLayer. But is there a way to simply "draw" a small rectangle on doorLayer without needing an extra layer? And if so, is this preferable for any reason?
Now for my second question: so at the moment I am in fact using a separate layer called handleLayer which is added as a sublayer to doorLayer. You can see a GIF of the animation with the handleLayer here.
And here is the animation being applied to doorLayer:
UIView.animateWithDuration(1.0, animations: { () -> Void in
self.doorLayer.frame.origin.x = self.doorLayer.frame.maxX
self.doorLayer.frame.size.width = 0
}
This animation shifts the origin of doorLayer's frame to the door's right border while decrementing its width, resulting in the the appearance of a door sliding to the right and disappearing as it does so.
As you can see in the above GIF, the origin shift of doorLayer is applied to its handleLayer sublayer, as desired. But the width adjustment does not carry over to the handleLayer. And this is good, because I don't want the handle to be getting narrower at the same rate as the doorLayer.
Instead what is desired is that the handleLayer moves with the doorLayer, but retains its size. But when the doorLayer disappears into the right side of the doorframe, the handle disappears with it (as it would look with a normal door). Any clue what the best way to accomplish this is?
Currently in my doorLayer's animation, I added this line:
if self.doorLayer.frame.size.width <= self.handleLayer.frame.size.width {
self.handleLayer.frame.size.width = 0
}
But that results in this, which isn't quite right.
Thanks for any help!
From a high level, you would need to
Make your sliding layer a child of your outline layer
Make your outline layer masks its bounds
Animate your sliding layer's transform using a x translation
On completion of the animation, animate your outline layer's transform using a scale translation
Reverse the animations to close the door again
Your doorknob layer is fine as is and no need to animate it separately.
I took a shot at it for fun and here's what I came up with. I didn't use a swipe gesture, but it could just as easily by added. I trigger the animation with a tap on the view. Tap again to toggle back.
func didTapView(gesture:UITapGestureRecognizer) {
// Create a couple of closures to perform the animations. Each
// closure takes a completion block as a parameter. This will
// be used as the completion block for the Core Animation transaction's
// completion block.
let slideAnimation = {
(completion:(() -> ())?) in
CATransaction.begin()
CATransaction.setCompletionBlock(completion)
CATransaction.setAnimationDuration(1.0)
if CATransform3DIsIdentity(self.slideLayer.transform) {
self.slideLayer.transform = CATransform3DMakeTranslation(220.0, 0.0, 0.0)
} else {
self.slideLayer.transform = CATransform3DIdentity
}
CATransaction.commit()
}
let scaleAnimation = {
(completion:(() -> ())?) in
CATransaction.begin()
CATransaction.setCompletionBlock(completion)
CATransaction.setAnimationDuration(1.0)
if CATransform3DIsIdentity(self.baseLayer.transform) {
self.baseLayer.transform = CATransform3DMakeScale(2.0, 2.0, 2.0)
} else {
self.baseLayer.transform = CATransform3DIdentity
}
CATransaction.commit()
}
// Check to see if the slide layer's transform is the identity transform
// which would mean that the door is currently closed.
if CATransform3DIsIdentity(self.slideLayer.transform) {
// If the door is closed, perform the slide animation first
slideAnimation( {
// And when it completes, perform the scale animation
scaleAnimation(nil) // Pass nil here since we're done animating
} )
} else {
// Otherwise the door is open, so perform the scale (down)
// animation first
scaleAnimation( {
// And when it completes, perform the slide animation
slideAnimation(nil) // Pass nil here since we're done animating
})
}
}
Here's how the layers are setup initially:
func addLayers() {
baseLayer = CALayer()
baseLayer.borderWidth = 10.0
baseLayer.bounds = CGRect(x: 0.0, y: 0.0, width: 220, height: 500.0)
baseLayer.masksToBounds = true
baseLayer.position = self.view.center
slideLayer = CALayer()
slideLayer.bounds = baseLayer.bounds
slideLayer.backgroundColor = UIColor.whiteColor().CGColor
slideLayer.position = CGPoint(x: baseLayer.bounds.size.width / 2.0, y: baseLayer.bounds.size.height / 2.0)
let knobLayer = CALayer()
knobLayer.bounds = CGRect(x: 0.0, y: 0.0, width: 20.0, height: 20.0)
knobLayer.cornerRadius = 10.0 // Corner radius with half the size of the width and height make it round
knobLayer.backgroundColor = UIColor.blueColor().CGColor
knobLayer.position = CGPoint(x: 30.0, y: slideLayer.bounds.size.height / 2.0)
slideLayer.addSublayer(knobLayer)
baseLayer.addSublayer(slideLayer)
self.view.layer.addSublayer(baseLayer)
}
And here's what the animation looks like:
You can see a full Xcode project here: https://github.com/perlmunger/Door

Save CoreAnimation sequence frame by frame

How can I save each frame of a CoreAnimation based animation (as image files)?
Here's my little playground scene. The animation lasts 2.4 seconds.
let stage = UIView(frame: CGRect(x: 0.0, y: 0.0, width: 300, height: 300))
stage.backgroundColor = UIColor.blueColor();
var dot = UIView(frame: CGRectMake(0, 0, 10, 10))
dot.backgroundColor = UIColor.redColor()
dot.center = stage.center
UIView.animateWithDuration(2.4, animations: { () -> Void in
dot.center.y = dot.center.y + 50
})
I know how to save a static UIView frame as PDF but I am not sure how to hook into the animation sequence while it happens and capture/save the view frame by frame.
Option 1
As far as I see it I need to hook into the animation block and save the stage view for each frame (at a given frame rate?). How can I do this? Is there some sort of callback I can use?
Option 2
While looking for a solution I came across another option that looks even more promising (based on this question and this awesome blog post). Using a CBAnimation, setting a timingFunction and then setting the timeOffset to the progress states I want to render.
Here's an example for the frame at 50% progress (using a different example here).
var drawAnimation = CABasicAnimation(keyPath: "strokeEnd")
…
drawAnimation.fromValue = NSNumber(float: 0.0)
drawAnimation.toValue = NSNumber(float: 1.0)
drawAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseIn)
circle.addAnimation(drawAnimation, forKey: "drawCircleAnimation")
circle.speed = 0.0;
circle.timeOffset = 0.5
The problem is that I cannot capture this timeOffset based animation state. When I render the view I only get the final state instead of the frozen animation frame at 0.5.
Any help to resolve this is appreciated.

CATransform3D breaks layer order?

Basically I want to build the scrollView like the one in iOS7 safari tab switcher. I use CATransform3D to get the feeling of oblique. However when I employ transform the layers just don't display in proper order.(They are in correct order before transform, seems like a total reversal in order.Back layer jumps to the front). How can I fix this thorny problem?
By the way in my case superView.bringSubViewToFront doesn't work.
My code:
//Create imageView
...
scroller.addSubview(imageView)
let layer = imageView.layer
//Set shadow
layer.shadowOpacity = 0.2
layer.shadowRadius = 2
layer.shadowOffset = CGSizeMake(6, -10)
//Transform, if without following code the layer order is correct
var transform = CATransform3DIdentity
transform.m34 = 0.0009
let radiants = 0.11*M_PI
transform = CATransform3DRotate(transform, CGFloat(radiants), 1.0, 0.0, 0.0)
scroller.bringSubviewToFront(imageView)//It doesn't work.
I just found that if you set the z axis to 1.0 in CATransform3DTranslate the layer become in front of other layers, so just make it 0.

CGAffineTransform scale and translation - jump before animation

I am struggling with an issue regarding CGAffineTransform scale and translation where when I set a transform in an animation block on a view that already has a transform the view jumps a bit before animating.
Example:
// somewhere in view did load or during initialization
var view = UIView()
view.frame = CGRectMake(0,0,100,100)
var scale = CGAffineTransformMakeScale(0.8,0.8)
var translation = CGAffineTransformMakeTranslation(100,100)
var concat = CGAffineTransformConcat(translation, scale)
view.transform = transform
// called sometime later
func buttonPressed() {
var secondScale = CGAffineTransformMakeScale(0.6,0.6)
var secondTranslation = CGAffineTransformMakeTranslation(150,300)
var secondConcat = CGAffineTransformConcat(secondTranslation, secondScale)
UIView.animateWithDuration(0.5, animations: { () -> Void in
view.transform = secondConcat
})
}
Now when buttonPressed() is called the view jumps to the top left about 10 pixels before starting to animate. I only witnessed this issue with a concat transform, using only a translation transform works fine.
Edit: Since I've done a lot of research regarding the matter I think I should mention that this issue appears regardless of whether or not auto layout is turned on
I ran into the same issue, but couldn't find the exact source of the problem. The jump seems to appear only in very specific conditions: If the view animates from a transform t1 to a transform t2 and both transforms are a combination of a scale and a translation (that's exactly your case). Given the following workaround, which doesn't make sense to me, I assume it's a bug in Core Animation.
First, I tried using CATransform3D instead of CGAffineTransform.
Old code:
var transform = CGAffineTransformIdentity
transform = CGAffineTransformScale(transform, 1.1, 1.1)
transform = CGAffineTransformTranslate(transform, 10, 10)
view.layer.setAffineTransform(transform)
New code:
var transform = CATransform3DIdentity
transform = CATransform3DScale(transform, 1.1, 1.1, 1.0)
transform = CATransform3DTranslate(transform, 10, 10, 0)
view.layer.transform = transform
The new code should be equivalent to the old one (the fourth parameter is set to 1.0 or 0 so that there is no scaling/translation in z direction), and in fact it shows the same jumping. However, here comes the black magic: In the scale transformation, change the z parameter to anything different from 1.0, like this:
transform = CATransform3DScale(transform, 1.1, 1.1, 1.01)
This parameter should have no effect, but now the jump is gone.
🎩✨
Looks like Apple UIView animation internal bug. When Apple interpolates CGAffineTransform changes between two values to create animation it should do following steps:
Extract translation, scale, and rotation
Interpolate extracted values form start to end
Assemble CGAffineTransform for each interpolation step
Assembling should be in following order:
Translation
Scaling
Rotation
But looks like Apple make translation after scaling and rotation. This bug should be fixed by Apple.
I dont know why, but this code can work
update:
I successfully combine scale, translate, and rotation together, from any transform state to any new transform state.
I think the transform is reinterpreted at the start of the animation.
the anchor of start transform is considered in new transform, and then we convert it to old transform.
self.v = UIView(frame: CGRect(x: 50, y: 50, width: 50, height: 50))
self.v?.backgroundColor = .blue
self.view.addSubview(v!)
func buttonPressed() {
let view = self.v!
let m1 = view.transform
let tempScale = CGFloat(arc4random()%10)/10 + 1.0
let tempRotae:CGFloat = 1
let m2 = m1.translatedBy(x: CGFloat(arc4random()%30), y: CGFloat(arc4random()%30)).scaledBy(x: tempScale, y: tempScale).rotated(by:tempRotae)
self.animationViewToNewTransform(view: view, newTranform: m2)
}
func animationViewToNewTransform(view: UIView, newTranform: CGAffineTransform) {
// 1. pointInView.apply(view.transform) is not correct point.
// the real matrix is mAnchorToOrigin.inverted().concatenating(m1).concatenating(mAnchorToOrigin)
// 2. animation begin trasform is relative to final transform in final transform coordinate
// anchor and mAnchor
let normalizedAnchor0 = view.layer.anchorPoint
let anchor0 = CGPoint(x: normalizedAnchor0.x * view.bounds.width, y: normalizedAnchor0.y * view.bounds.height)
let mAnchor0 = CGAffineTransform.identity.translatedBy(x: anchor0.x, y: anchor0.y)
// 0->1->2
//let origin = CGPoint(x: 0, y: 0)
//let m0 = CGAffineTransform.identity
let m1 = view.transform
let m2 = newTranform
// rotate and scale relative to anchor, not to origin
let matrix1 = mAnchor0.inverted().concatenating(m1).concatenating(mAnchor0)
let matrix2 = mAnchor0.inverted().concatenating(m2).concatenating(mAnchor0)
let anchor1 = anchor0.applying(matrix1)
let mAnchor1 = CGAffineTransform.identity.translatedBy(x: anchor1.x, y: anchor1.y)
let anchor2 = anchor0.applying(matrix2)
let txty2 = CGPoint(x: anchor2.x - anchor0.x, y: anchor2.y - anchor0.y)
let txty2plusAnchor2 = CGPoint(x: txty2.x + anchor2.x, y: txty2.y + anchor2.y)
let anchor1InM2System = anchor1.applying(matrix2.inverted()).applying(mAnchor0.inverted())
let txty2ToM0System = txty2plusAnchor2.applying(matrix2.inverted()).applying(mAnchor0.inverted())
let txty2ToM1System = txty2ToM0System.applying(mAnchor0).applying(matrix1).applying(mAnchor1.inverted())
var m1New = m1
m1New.tx = txty2ToM1System.x + anchor1InM2System.x
m1New.ty = txty2ToM1System.y + anchor1InM2System.y
view.transform = m1New
UIView.animate(withDuration: 1.4) {
view.transform = m2
}
}
I also try the zScale solution, it seems also work if set zScale non-1 at the first transform or at every transform
let oldTransform = view.layer.transform
let tempScale = CGFloat(arc4random()%10)/10 + 1.0
var newTransform = CATransform3DScale(oldTransform, tempScale, tempScale, 1.01)
newTransform = CATransform3DTranslate(newTransform, CGFloat(arc4random()%30), CGFloat(arc4random()%30), 0)
newTransform = CATransform3DRotate(newTransform, 1, 0, 0, 1)
UIView.animate(withDuration: 1.4) {
view.layer.transform = newTransform
}
Instead of CGAffineTransformMakeScale() and CGAffineTransformMakeTranslation(), which create a transform based off of CGAffineTransformIdentity (basically no transform), you want to scale and translate based on the view's current transform using CGAffineTransformScale() and CGAffineTransformTranslate(), which start with the existing transform.
The source of the issue is the lack of perspective information to the transform.
You can add perspective information modifying the m34 property of your 3d transform
var transform = CATransform3DIdentity
transform.m34 = 1.0 / 200 //your own perspective value here
transform = CATransform3DScale(transform, 1.1, 1.1, 1.0)
transform = CATransform3DTranslate(transform, 10, 10, 0)
view.layer.transform = transform

Resources