Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Animating shape layer type custom UIViews, during a UIView.animate?

Imagine a tall thin view, we'll say it looks like a ladder.

It is 100 high. We animate it to be the full height of the screen. So, in pseudocode..

func expandLadder() {
view.layoutIfNeeded()
UIView.animate(withDuration: 4 ...
  ladderTopToTopOfScreenConstraint = 0
  ladderBottomToBottomOfScreenConstraint = 0
  view.layoutIfNeeded()
}

No problem.

However. We draw the ladder in layoutSubviews. (Our ladder is just a thick black line.)

@IBDesignable class LadderView: UIView {
override func layoutSubviews() {
  super.layoutSubviews()
  setup()
}

func setup() {
  heightNow = frame size height
  print("I've recalculated the height!")
  shapeLayer ...
  topPoint = (0, 0)
  bottomPoint = (0, heightNow)
  p.addLines(between: [topPoint, bottomPoint])
  shapeLayer!.path = p
  shapeLayer add a CABasicAnimation or whatever
  layer.addSublayer(shapeLayer!)
}

No problem.

So we're just making a line p that fills the height "heightNow" of the view.

The problem is of course, when you do this ...

func expandLadder() {
view.layoutIfNeeded()
UIView.animate(withDuration: 4 ...
  ladderToTopOfScreenConstraint = 0
  ladderBottomOfScreenConstraint = 0
  view.layoutIfNeeded()
}

... when you do that, LayoutSubviews (or setBounds, or draw#rect, or anything else you can think of) is only called "before and after" the UIView.animate animation. It's a real pain.

In the example, the "actual" ladder, the shapeLayer, will jump to the next values, before you animate the size of the view L. I.E.: "I've recalculated the height!" is called only once before and once after the UIView.animate animation. Assuming you don't clip subviews, you'll see the "wrong, upcoming" size before each animation of the length of L.)

What's the solution?


BTW, of course you can do this using draw#rect and animating yourself, i.e. with CADisplayLink (as mentioned by Josh). So that's fine. It's incredibly easier to draw shapes just using a layer/path: my question here is how to make, in the example, the code in setup() run during every step of the UIView animation of the constraints of the view in question.

like image 209
Fattie Avatar asked Apr 11 '17 12:04

Fattie


1 Answers

The behavior you describe has to do with the way that iOS animations work. Specifically when you set the frame of a view in a UIAnimation block (that's what layoutIfNeeded does -- it forces a synchronous recalculation of the frame according to the autolayout rules) the view's frame immediately changes to the new value which is the apparent value at the end of the animation (add a print statement after layoutIfNeeded and you can see this is true). Incidentally changing the constraints doesn't do anything until layoutIfNeeded is called so you don't even need to put the constraint change in the animation block.

However during the animation, the system shows the presentation layer instead of the backing layer and it interpolates the value of the frame from the presentation layer from the initial value to the final value over the duration of the animation. This makes it look like the view is moving even though inspecting the value will reveal the view has already assumed its final position.

Therefore if you want the actual coordinates of the on screen layer while an animation is in flight, you have to use view.layer.presentationLayer to get them. See: https://developer.apple.com/reference/quartzcore/calayer/1410744-presentationlayer

This doesn't entirely solve your problem through because I assume that what you are trying to do is to spawn more layers as time goes on. If you cannot do with simple interpolation then you may be better off spawning all of the layers at the beginning and then using a keyframe animation to turn them on at certain intervals as the animation progresses. You can base your layer positions on view.layer.presentationLayer.bounds (your sublayers are in their super layers coordinates so don't use frame). It's difficult to provide an exact solution without knowing what you are trying to accomplish.

Another possible solution is to use drawRect with CADisplayLink which lets you redraw each frame however you want, so it's much more suited to things that cannot be easily interpolated from frame to frame (i.e. games or in your case adding/removing and animating geometry as a function of time).

Edit: Here is an example Playground showing how to animate the layer in parallel with the view.

    //: Playground - noun: a place where people can play

    import UIKit
    import PlaygroundSupport

    class V: UIViewController {
        var a = UIView()
        var b = CAShapeLayer()
        var constraints: [NSLayoutConstraint] = []
        override func viewDidLoad() {
            view.addSubview(a)
            a.translatesAutoresizingMaskIntoConstraints = false
            constraints = [
                a.topAnchor.constraint(equalTo: view.topAnchor, constant: 100),
                a.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 100),
                a.widthAnchor.constraint(equalToConstant: 200),
                a.heightAnchor.constraint(equalToConstant: 200)

            ]
            constraints.forEach {$0.isActive = true}
            a.backgroundColor = .blue
        }

        override func viewDidAppear(_ animated: Bool) {
            b.path = UIBezierPath(ovalIn: a.bounds).cgPath
            b.fillColor = UIColor.red.cgColor
            a.layer.addSublayer(b)
            constraints[3].constant = 400
            constraints[2].constant = 400
            let oldBounds = a.bounds
            UIView.animate(withDuration: 3, delay: 0, options: .curveLinear, animations: {
                self.view.layoutIfNeeded()
            }, completion: nil)
            let scale = a.bounds.size.width / oldBounds.size.width
            let animation = CABasicAnimation(keyPath: "transform.scale")
            animation.fromValue = 1
            animation.toValue = scale
            animation.duration = 3
            b.add(animation, forKey: "scale")
            b.transform = CATransform3DMakeScale(scale, scale, 1)

        }
    }

    let v = V()
    PlaygroundPage.current.liveView = v
like image 75
Josh Homann Avatar answered Oct 26 '22 13:10

Josh Homann