iOS: Why does the key of animation matter? - ios

I'm trying to implement an animation to move a view to the position I tap.
Each tap would cancel the previous animation and start moving again from the current position.
class MoveAnimationViewController: UIViewController {
lazy var block: UIView = {
let block = UIView()
block.frame = CGRect(x: 100, y: 100, width: 100, height: 100)
block.backgroundColor = UIColor.greenColor()
return block
}()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(block)
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: "tap:"))
}
func tap(gesture: UITapGestureRecognizer) {
let fromPosition = (block.layer.presentationLayer() ?? block.layer).position
let toPostion = gesture.locationInView(view)
block.layer.removeAllAnimations()
let animation = CABasicAnimation(keyPath: "position")
animation.duration = 2
animation.fromValue = NSValue(CGPoint: fromPosition)
animation.toValue = NSValue(CGPoint: toPostion)
block.layer.addAnimation(animation, forKey: nil)
block.layer.position = toPostion
}
}
However, the block view jumps to the destination directly without any animation.
Replace the following code
block.layer.addAnimation(animation, forKey: nil)
with
block.layer.addAnimation(animation, forKey: "move")
will fix the problem, but why?
By the way, the following statement looks illegal, since the presentationLayer method returns a AnyObject?, which has no position property. Right?
let fromPosition = (block.layer.presentationLayer() ?? block.layer).position
It should be replaced with this, I guess. But the compiler doesn't warn me. Is it a bug?
let fromPosition = (block.layer.presentationLayer() as? CALayer ?? block.layer).position

If you name an animation, i.e. associate a key with it, then it will replace any other animations with the same key. If you give it no name that automatic cancel and replace doesn't happen.

Related

Synchronously animate CALayer property and UIView with UIViewPropertyAnimator

I'm using a UIViewPropertyAnimator to animate the position of a view. But, I also want to animate CALayer properties like borderColor along with it. Currently, it doesn't animate and instantly changes at the start, like this:
Here's my code:
class ViewController: UIViewController {
var animator: UIViewPropertyAnimator?
let squareView = UIView(frame: CGRect(x: 0, y: 40, width: 80, height: 80))
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(squareView)
squareView.backgroundColor = UIColor.blue
squareView.layer.borderColor = UIColor.green.cgColor
squareView.layer.borderWidth = 6
animator = UIViewPropertyAnimator(duration: 2, curve: .easeOut, animations: {
self.squareView.frame.origin.x = 100
self.squareView.layer.borderColor = UIColor.red.cgColor
})
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.animator?.startAnimation()
}
}
}
I looked at this question, How to synchronously animate a UIView and a CALayer, and the answer suggested using "an explicit animation, like a basic animation." It said,
If the timing function and the duration of both animations are the same then they should stay aligned.
However, if I use CABasicAnimation, I lose all the benefits of UIViewPropertyAnimator, like timing and stopping in the middle. I will also need to keep track of both. Is there any way to animate CALayer properties with UIViewPropertyAnimator?
CABasicAnimation has timing as well as keyframe animation to stop in the middle. But, to replicate your animation above:
squareView.layer.transform = CATransform3DMakeTranslation(100, 0, 0)
let fromValue = squareView.transform
let toValue = 100
let translateAnimation = CABasicAnimation(keyPath: #keyPath(CALayer.transform))
translateAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
translateAnimation.fromValue = fromValue
translateAnimation.toValue = toValue
translateAnimation.valueFunction = CAValueFunction(name: .translateX)
let fromColor = squareView.layer.borderColor
let toColor = UIColor.red.cgColor
let borderColorAnimation = CABasicAnimation(keyPath: "borderColor")
borderColorAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
borderColorAnimation.fromValue = fromColor
borderColorAnimation.toValue = toColor
let groupAnimation = CAAnimationGroup()
groupAnimation.animations = [translateAnimation, borderColorAnimation]
groupAnimation.duration = 5
squareView.layer.add(groupAnimation, forKey: nil)

Efficient off-screen UIView rendering and mirroring

I have a "off-screen" UIView hierarchy which I want render in different locations of my screen. In addition it should be possible to show only parts of this view hierarchy and should reflect all changes made to this hierarchy.
The difficulties:
The UIView method drawHierarchy(in:afterScreenUpdates:) always calls draw(_ rect:) and is therefore very inefficient for large hierarchies if you want to incorporate all changes to the view hierarchy. You would have to redraw it every screen update or observe all changing properties of all views. Draw view hierarchy documentation
The UIView method snapshotView(afterScreenUpdates:) also does not help much since I have not found a way to get a correct view hierarchy drawing if this hierarchy is "off-screen". Snapshot view documentation
"Off-Screen": The root view of this view hierarchy is not part of the UI of the app. It has no superview.
Below you can see a visual representation of my idea:
Here's how I would go about doing it. First, I would duplicate the view you are trying to duplicate. I wrote a little extension for this:
extension UIView {
func duplicate<T: UIView>() -> T {
return NSKeyedUnarchiver.unarchiveObject(with: NSKeyedArchiver.archivedData(withRootObject: self)) as! T
}
func copyProperties(fromView: UIView, recursive: Bool = true) {
contentMode = fromView.contentMode
tag = fromView.tag
backgroundColor = fromView.backgroundColor
tintColor = fromView.tintColor
layer.cornerRadius = fromView.layer.cornerRadius
layer.maskedCorners = fromView.layer.maskedCorners
layer.borderColor = fromView.layer.borderColor
layer.borderWidth = fromView.layer.borderWidth
layer.shadowOpacity = fromView.layer.shadowOpacity
layer.shadowRadius = fromView.layer.shadowRadius
layer.shadowPath = fromView.layer.shadowPath
layer.shadowColor = fromView.layer.shadowColor
layer.shadowOffset = fromView.layer.shadowOffset
clipsToBounds = fromView.clipsToBounds
layer.masksToBounds = fromView.layer.masksToBounds
mask = fromView.mask
layer.mask = fromView.layer.mask
alpha = fromView.alpha
isHidden = fromView.isHidden
if let gradientLayer = layer as? CAGradientLayer, let fromGradientLayer = fromView.layer as? CAGradientLayer {
gradientLayer.colors = fromGradientLayer.colors
gradientLayer.startPoint = fromGradientLayer.startPoint
gradientLayer.endPoint = fromGradientLayer.endPoint
gradientLayer.locations = fromGradientLayer.locations
gradientLayer.type = fromGradientLayer.type
}
if let imgView = self as? UIImageView, let fromImgView = fromView as? UIImageView {
imgView.tintColor = .clear
imgView.image = fromImgView.image?.withRenderingMode(fromImgView.image?.renderingMode ?? .automatic)
imgView.tintColor = fromImgView.tintColor
}
if let btn = self as? UIButton, let fromBtn = fromView as? UIButton {
btn.setImage(fromBtn.image(for: fromBtn.state), for: fromBtn.state)
}
if let textField = self as? UITextField, let fromTextField = fromView as? UITextField {
if let leftView = fromTextField.leftView {
textField.leftView = leftView.duplicate()
textField.leftView?.copyProperties(fromView: leftView)
}
if let rightView = fromTextField.rightView {
textField.rightView = rightView.duplicate()
textField.rightView?.copyProperties(fromView: rightView)
}
textField.attributedText = fromTextField.attributedText
textField.attributedPlaceholder = fromTextField.attributedPlaceholder
}
if let lbl = self as? UILabel, let fromLbl = fromView as? UILabel {
lbl.attributedText = fromLbl.attributedText
lbl.textAlignment = fromLbl.textAlignment
lbl.font = fromLbl.font
lbl.bounds = fromLbl.bounds
}
if recursive {
for (i, view) in subviews.enumerated() {
if i >= fromView.subviews.count {
break
}
view.copyProperties(fromView: fromView.subviews[i])
}
}
}
}
to use this extension, simply do
let duplicateView = originalView.duplicate()
duplicateView.copyProperties(fromView: originalView)
parentView.addSubview(duplicateView)
Then I would mask the duplicate view to only get the particular section that you want
let mask = UIView(frame: CGRect(x: 0, y: 0, width: yourNewWidth, height: yourNewHeight))
mask.backgroundColor = .black
duplicateView.mask = mask
finally, I would scale it to whatever size you want using CGAffineTransform
duplicateView.transform = CGAffineTransform(scaleX: xScale, y: yScale)
the copyProperties function should work well but you can change it if necessary to copy even more things from one view to another.
Good luck, let me know how it goes :)
I'd duplicate the content I wish to display and crop it as I want.
Let's say I have a ContentViewController which carries the view hierarchy I wish to replicate. I would encapsule all the changes that can be made to the hierarchy inside a ContentViewModel. Something like:
struct ContentViewModel {
let actionTitle: String?
let contentMessage: String?
// ...
}
class ContentViewController: UIViewController {
func display(_ viewModel: ContentViewModel) { /* ... */ }
}
With a ClippingView (or a simple UIScrollView) :
class ClippingView: UIView {
var contentOffset: CGPoint = .zero // a way to specify the part of the view you wish to display
var contentFrame: CGRect = .zero // the actual size of the clipped view
var clippedView: UIView?
override init(frame: CGRect) {
super.init(frame: frame)
clipsToBounds = true
}
override func layoutSubviews() {
super.layoutSubviews()
clippedView?.frame = contentFrame
clippedView?.frame.origin = contentOffset
}
}
And a view controller container, I would crop each instance of my content and update all of them each time something happens :
class ContainerViewController: UIViewController {
let contentViewControllers: [ContentViewController] = // 3 in your case
override func viewDidLoad() {
super.viewDidLoad()
contentViewControllers.forEach { viewController in
addChil(viewController)
let clippingView = ClippingView()
clippingView.clippedView = viewController.view
clippingView.contentOffset = // ...
viewController.didMove(to: self)
}
}
func somethingChange() {
let newViewModel = ContentViewModel(...)
contentViewControllers.forEach { $0.display(newViewModel) }
}
}
Could this scenario work in your case ?

Animate CAShapeLayer path with CASpringAnimation

I have a custom UIView where I add a CAShapeLayer as a direct sublayer of the view's layer:
private let arcShapeLayer = CAShapeLayer()
The CAShapeLayer has the following properties (the border is to visualize more easily the animation) declared in the awakeFromNib:
arcShapeLayer.lineWidth = 2.0
arcShapeLayer.strokeColor = UIColor.black.cgColor
arcShapeLayer.fillColor = UIColor.lightGray.cgColor
arcShapeLayer.masksToBounds = false
layer.masksToBounds = false
layer.addSublayer(arcShapeLayer)
I declare the frame of the arcShapeLayer in the layoutSubviews:
arcShapeLayer.frame = layer.bounds
Then I have the following function where I pass a certain percentage (from the ViewController) in order to achieve the arc effect after some dragging. Simply put I add a quadCurve at the bottom of the layer:
func configureArcPath(curvePercentage: CGFloat) -> CGPath {
let path = UIBezierPath()
path.move(to: CGPoint.zero)
path.addLine(to:
CGPoint(
x: arcShapeLayer.bounds.minX,
y: arcShapeLayer.bounds.maxY
)
)
path.addQuadCurve(
to:
CGPoint(
x: arcShapeLayer.bounds.maxX,
y: arcShapeLayer.bounds.maxY
),
controlPoint:
CGPoint(
x: arcShapeLayer.bounds.midX,
y: arcShapeLayer.bounds.maxY + arcShapeLayer.bounds.maxY * (curvePercentage * 0.4)
)
)
path.addLine(to:
CGPoint(
x: arcShapeLayer.bounds.maxX,
y: arcShapeLayer.bounds.minY
)
)
path.close()
return path.cgPath
}
Then I try to animate with a spring effect the arc with the following code:
func animateArcPath() {
let springAnimation = CASpringAnimation(keyPath: "path")
springAnimation.initialVelocity = 10
springAnimation.mass = 10
springAnimation.duration = springAnimation.settlingDuration
springAnimation.fromValue = arcShapeLayer.path
springAnimation.toValue = configureArcPath(curvePercentage: 0.0)
springAnimation.fillMode = .both
arcShapeLayer.add(springAnimation, forKey: nil)
arcShapeLayer.path = configureArcPath(curvePercentage: 0.0)
}
The problem, as you can see in the video, is that the arc never overshoots. Although it oscillates between its original position and the rest position, the spring effect is never achieved.
What am I missing here?
I played around with this and I can report that you are absolutely right. It has nothing to do clamping at zero. The visible animation clamps at both extremes.
Let's say you supply a big mass value so that the overshoot should go way past the initial position of the convex bow on its return journey back to the start. Then what we see is that the animation clamps both at that original position and at the minimum you are trying to animate to. As the spring comes swinging between them, we see the animation happening between one extreme and the other, but as it reaches the max or min it just clamps there until it has had time to swing to its full extent and return.
I have to conclude that CAShapeLayer path doesn't like springing animations. This same point is raised at CAShapeLayer path spring animation not 'overshooting'
I was able to simulate the sort of look you're probably after by chaining normal basic animations:
Here's the code I used (based on your own code):
#IBAction func animateArcPath() {
self.animate(to:-1, in:0.5, from:arcShapeLayer.path!)
}
func animate(to arc:CGFloat, in time:Double, from current:CGPath) {
let goal = configureArcPath(curvePercentage: arc)
let anim = CABasicAnimation(keyPath: "path")
anim.duration = time
anim.fromValue = current
anim.toValue = goal
anim.delegate = self
anim.setValue(arc, forKey: "arc")
anim.setValue(time, forKey: "time")
anim.setValue(goal, forKey: "pathh")
arcShapeLayer.add(anim, forKey: nil)
}
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
if let arc = anim.value(forKey:"arc") as? CGFloat,
let time = anim.value(forKey:"time") as? Double,
let p = anim.value(forKey:"pathh") {
if time < 0.05 {
return
}
self.animate(to:arc*(-0.5), in:time*0.5, from:p as! CGPath)
}
}

Spring animation for rotation that ends where it began

I'd like to create a CASpringAnimation for rotation that ends where it began. Here's some working code that doesn't end where it began (but otherwise is what I'm looking for):
func spring(view: UIView) -> CASpringAnimation{
let animation = CASpringAnimation(keyPath: "transform")
animation.damping = 0.5
animation.initialVelocity = 70
animation.mass = 0.04
animation.stiffness = 12
animation.duration = 1.2
animation.toValue = CATransform3DRotate(CATransform3DMakeAffineTransform(view.transform),
CGFloat(M_PI)/32, // Must be non-zero 😕
0, 0, 1)
return animation
}
And here's how you'd use it:
let animation = spring(view: viewToAnimate)
viewToAnimate.layer.add(animation, forKey: nil)
When I set the angle (beside the comment in CATransform3DRotate) to 0, the animation doesn't work! Any ideas on how to get a spring rotation with the same start and end angles? Thanks for reading.
By adding a CABasicAnimation that animates to 0° over the same time duration, and also by tweaking the spring animation parameters, I've got something that looks pretty good (tested for iOS 10):
#IBAction func springButtonTapped(_ sender: UIButton) { // Attach to the view you'd like animated
let springAnim = spring(view:sender)
sender.layer.add(springAnim, forKey: nil)
let correctionAnim = correct(view:sender)
sender.layer.add(correctionAnim, forKey: nil)
}
func correct(view: UIButton) -> CABasicAnimation{
let animation = CABasicAnimation(keyPath: "transform")
animation.duration = 0.3
animation.toValue = CATransform3DRotate(CATransform3DMakeAffineTransform(view.transform),
0,
0, 0, 1)
return animation
}
func spring(view: UIView) -> CASpringAnimation{
let animation = CASpringAnimation(keyPath: "transform")
animation.damping = 0.0
animation.initialVelocity = 80
animation.mass = 0.04
animation.stiffness = 50
animation.duration = 0.3
animation.toValue = CATransform3DRotate(CATransform3DMakeAffineTransform(view.transform),
CGFloat(M_PI)/64,
0, 0, 1)
return animation
}

CABasicAnimation byValue strange behavior

I was reading the 12th number of Objc.io and encountered a problem when trying the first pieces of code.
In the first part "A Basic Animation" the last piece of code uses byValue to interpolate a property using initial property value and adding byValue.
So I tried the code in my project and discovered it wasn't working and had a really strange behavior.
Here's a very short gif illustrating the problem :
And here's the only code I'm using :
class ViewController: UIViewController {
var rocket1: UIView!
var rocket2: UIView!
override func viewDidLoad() {
super.viewDidLoad()
rocket1 = UIView(frame: CGRect(x: 20, y: 100, width: 50, height: 20))
rocket1.backgroundColor = UIColor.redColor()
rocket2 = UIView(frame: CGRect(x: 20, y: 150, width: 50, height: 20))
rocket2.backgroundColor = UIColor.blueColor()
self.view.addSubview(rocket1)
self.view.addSubview(rocket2)
}
#IBAction func button() {
let animation = CABasicAnimation(keyPath: "position.x")
animation.byValue = 300
animation.duration = 1
rocket1.layer.addAnimation(animation, forKey: "basic")
rocket1.layer.position.x += 300
animation.beginTime = CACurrentMediaTime() + 0.5
rocket2.layer.addAnimation(animation, forKey: "basic")
rocket2.layer.position.x += 300
}
}
If you want to try the project here's the full project (use iPhone 6 simulator or shapes will be hidden because 5S is too "short") : https://dl.dropboxusercontent.com/u/378166/VCoreAnimation.zip
you should create a completion block for this animation like this:
#IBAction func button() {
let animation = CABasicAnimation(keyPath: "position.x")
animation.byValue = 300
animation.duration = 1
CATransaction.begin()
CATransaction.setCompletionBlock({
rocket1.layer.position.x += 300
})
rocket1.layer.addAnimation(animation, forKey: "basic")
CATransaction.commit()
animation.beginTime = CACurrentMediaTime() + 0.5
CATransaction.begin()
CATransaction.setCompletionBlock({
rocket2.layer.position.x += 300
})
rocket2.layer.addAnimation(animation, forKey: "basic")
CATransaction.commit()
}
In this case we need 2 CATransaction blocks since we have 2 animations running with a time diference from each other.

Resources