Animating with AutoLayout

For a project I'm working on that uses autolayout and storyboards extensively I needed to animate some views on and off screen at various times.

Before autolayout, I would have just hidden or shown the views and adjusted the frames of the other views in an animation block. Without autolayout, you don't really want to manipulate the frame of the view; leave that to your constraints. So how do you do animated builds and tear downs?

Let's Animate Some Views

First, let's describe the builds we want to animate. There are basically three, shown below.

  1. Bring the blue Next > button on screen from below.
  2. Squeeze the blue Next > button from the left and grow in the green Back > button.
  3. Reverse step #2.

Bring the Blue Button on Stage

The first one is the easiest. Before autolayout, I would have simply set the frame off screen and then in animation block restore it to normal. Doing this with autolayout can create layout conflict as you unexpectedly trigger layout of other subviews and generally just force the layouts computed by the layout engine to be a lie.

The best way I've found to do this is to apply transforms to the view and animate thier addition or removal. As far as the layout engine is concerned, the button is right where it's meant to be, we've only translated it off screen. To bring it on screen, we need only to reset the view's transform back to the identity transform.

First, when loading the view a transform is applied to move the view off screen.

override func viewDidLoad() {
    self.nextButton.transform = CGAffineTransformMakeTranslation(0.0, 500.0)
}

Second, to bring it on screen at the right time, we remove the transform in an animation block.

func showNextButton() {
    UIView.animateWithDuration(0.25, delay: 0.0,
        usingSpringWithDamping: 0.75, initialSpringVelocity: 0.7,
        options: nil, animations: {
            self.nextButton.transform = CGAffineTransformIdentity
        }) { finished in
            self.nextButton.enabled = true
        }
}

No need to fiddling with frames required.

Squeezing the Green Button into View

This one is a little trickier – we could try to use the same transform trick to move the green button on and off screen, but like with the blue button the layout engine thinks it's where it's supposed to be and you'll end up with a blank spot where the green button should be.

In this scenario, we really do need to change the frames. You can see in the animation that the blue button changes size to make room for the green button which animates up from nothing to full size.

One possible solution is to add & remove constraints programmatically. This will work, but feels dirty to me. The solution I chose was to over-constrain the views and use priorities to control which set of constraints apply.

The views have the following constraints at play (there are more constraints, but they do not effect the animation):

  • Blue Button
    • Equal width to Green Button, Priority 250, Outlet backButtonEqualWidthConstraint
    • Leading space to Green Button, Constant 0, Priority 750, Outlet backButtonTrailingSpaceConstraint
    • Align leading space to a view not shown, Priority 750
  • Green Button
    • Width, Constant 0, Priority 500

As expressed, the zero width constraint for the green button takes precedence over the equal width constraint, so the green button is not visible. The leading constraint on the blue button adjusts it's frame to take up the unused space and the padding constraint with a constant of zero make it invisible.

To show the green button, we first flip the priorities of the zero width constraint and the equal width constraint and then change the padding constraint's constant to 8.0 and re-layout the view as shown in the code below:

func showBackButton() {
    view.layoutIfNeeded()

    backButtonEqualWidthConstraint.priority = 750
    backButtonTrailingSpaceConstraint.constant = 8.0

    UIView.animateWithDuration(0.25, delay: 0.0,
        usingSpringWithDamping: 0.75, initialSpringVelocity: 0.7,
        options: nil, animations: { () -> Void in
            self.view.layoutIfNeeded()
        }) { finished in }
}

The last bit of voodoo is to call the layoutIfNeeded() function inside the animation block as this call will modify the frames so they are captured by the animation.

Put Your Thing Down Flip It and Reverse It

The final animation is just the reverse of the previous; flip the constraints back and animate.

func hideBackButton() {
    view.layoutIfNeeded()

    backButtonEqualWidthConstraint.priority = 250
    backButtonTrailingSpaceConstraint.constant = 0.0

    UIView.animateWithDuration(0.25, delay: 0.0,
        usingSpringWithDamping: 0.75, initialSpringVelocity: 0.7,
        options: nil, animations: { () -> Void in
            self.view.layoutIfNeeded()
        }) { finished in }
}

Don't Repeat Yourself

The functions above could be refactored into one since they are basically identical except for the priority and constant. Something like so:

func showBackButton(shown: Bool) {
    view.layoutIfNeeded()

    backButtonEqualWidthConstraint.priority = shown ? 750 : 250
    backButtonTrailingSpaceConstraint.constant = shown ? 8.0 : 0.0
    backButton.setNeedsDisplay()

    UIView.animateWithDuration(0.25, delay: 0.0,
        usingSpringWithDamping: 0.75, initialSpringVelocity: 0.7,
        options: nil, animations: { () -> Void in
            self.view.layoutIfNeeded()
        }) { finished in }
}

The magic numbers should probably be constants, as well. Also, I really prefer to encapsulate colors, animations and other constants into "theme" classes, but that's left as an exercise for the reader.

Home