One of the best features of UIViewPropertyAnimator is that you can use it to create animations that can be interrupted, reversed, or interacted with. Many of the animations you see in iOS are interactive animations. For instance, swiping on a page to go back to the previous page is an interactive transition. Swiping between pages on the home screen, opening the control center, or pulling down the notification center are all examples of animations that you manipulate by interacting with them.
While the concept of interactive animations might sound complicated, UIViewPropertyAnimator makes it quite simple to implement them. As an example, you'll see how to implement a drawer on the contact detail page in the Hello-Contacts app. First, you'll prepare the view, so the drawer is partially visible in the app. Once the view is all set up, you will write the code to perform an interactive show-and-hide animation for the drawer.
Open Main.storyboard in the Hello-Contacts project and add a plain view to the contact-detail view controller's view. Make sure that you do not add the drawer view to the scroll view. It should be added on top of the scroll view. Set up Auto Layout constraints to make sure the drawer view's width is equal to the main view's width. Also, align the view with the horizontal center of its container. Next, make the drawer 350 points in height. The last constraint that you must add is a bottom space to the Safe Area constraint. Set the constant for this constraint to -305 so most of the drawer view is hidden from sight. Make sure this constraint is relative to the safe area margins so it doesn't overlap with the iPhone Xs' home screen indicator.
Next, add a button to the drawer. Align it horizontally and space it 8 points from the top of the drawer. Set the button's label to Toggle Drawer. It's a good idea to give the drawer view a background color so you can easily see it sliding over the contact detail page. The following screenshot shows the desired result:
The layout is now prepared. The implementation of the drawer functionality should implement the following features:
- Toggle the drawer by tapping on the toggle button.
- Toggle the drawer interactively when swiping on the drawer.
- Allow the user to tap on the toggle button and then swipe the drawer to manipulate or reverse the animation.
Behavior such as this is not straightforward; without UIViewPropertyAnimator, you would have to write a lot of complex code, and you'd still be pretty far from your desired results. Let's see what UIViewPropertyAnimator does to make implementing this effect manageable.
To prepare for the implementation of the drawer, add the following two properties to ContactDetailViewController:
@IBOutlet var drawer: UIView! var isDrawerOpen = false var drawerPanStart: CGFloat = 0 var animator: UIViewPropertyAnimator!
Also, add an extension to ContactDetailViewController that holds an @IBAction for the tap action. @IBAction is similar to @IBOutlet, but it used to call a particular method in response to a specific user action. An extension is used so it's easy to group the animation code nicely:
extension ContactDetailViewController { @IBAction func toggleDrawerTapped() { } }
Connect the outlet you've created to the drawer view in Interface Builder. Also, connect @IBAction to the toggle button's Touch Up Inside action. This is done by dragging from toggleDrawerTapped under the Received Actions header in the Connections Inspector. When you drag from the action to the button, a menu appears from which you can select the action for which @IBAction should trigger. To respond to a button tap, choose Touch Up Inside from this menu.
Lastly, add the following lines to the end of viewDidLoad():
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(didPanOnDrawer(recognizer:))) drawer.addGestureRecognizer(panRecognizer)
The preceding code sets up and adds a pan gesture-recognizer to the drawer view so the app can detect when a user starts dragging their finger on the drawer. Gesture-recognizers are a great way to respond to user interactions such as tapping, double-tapping, pinching, swiping, and panning.
Also, add the following method to the extension you created earlier for @IBAction. This is the method that is called when the user performs a pan gesture on the drawer:
@objc func didPanOnDrawer(recognizer: UIPanGestureRecognizer) { }
Now that all of the placeholders are implemented, let's create a simple first version of the open drawer animation. When the user taps on the toggle button, the drawer should open or close depending on the drawer's current state. The following snippet implements such an animation:
@IBAction func toggleDrawerTapped() { animator = UIViewPropertyAnimator(duration: 1, curve: .easeOut) { [unowned self] in if self.isDrawerOpen { self.drawer.transform = CGAffineTransform.identity } else { self.drawer.transform = CGAffineTransform(translationX: 0, y: -305) } } animator?.addCompletion { [unowned self] _ in self.animator = nil self.isDrawerOpen = !(self.drawer.transform == CGAffineTransform.identity) } animator?.startAnimation() }
The animation that is passed to the property animator uses the value of isDrawerOpen to determine whether the animation should open or close the drawer. When the drawer is currently open, it should close and vice versa. Once the animation finishes, the isDrawerOpen variable is updated to reflect the new state of the drawer. To determine the current state, the application reads the drawer's current transformation. If the drawer is not transformed, its transformation will equal CGAffineTransform.identity and the drawer is considered closed. Otherwise, the drawer is considered opened. You can build and run the app now to see this animation in action.
To allow the user to interrupt or start the animation by dragging their finger on the screen, the code must check whether an existing property animator is performing an animation. If no animator exists or if the current animator is not running any animations, a new instance of the animator should be created. In all other circumstances, it's possible to make use of the existing animator. Let's refactor the animator creation code from toggleDrawerTapped() so it reuses the animator if possible and creates a new animator if needed.
Add the following method to the extension and update toggleDrawerTapped() as shown in the following code:
func setUpAnimation() { guard animator == nil || animator?.isRunning == false else { return } animator = UIViewPropertyAnimator(duration: 1, curve: .easeOut) { [unowned self] in if self.isDrawerOpen { self.drawer.transform = CGAffineTransform.identity } else { self.drawer.transform = CGAffineTransform(translationX: 0, y: -305) } } animator?.addCompletion { [unowned self] _ in self.animator = nil self.isDrawerOpen = !(self.drawer.transform == CGAffineTransform.identity) } } @IBAction func toggleDrawerTapped() { setUpAnimation() animator?.startAnimation() }
Now add the following implementation for didPanOnDrawer(recognizer: UIPanGestureRecognizer):
@objc func didPanOnDrawer(recognizer: UIPanGestureRecognizer) { switch recognizer.state { case .began: setUpAnimation() animator?.pauseAnimation() drawerPanStart = animator?.fractionComplete ?? 0 case .changed: if self.isDrawerOpen { animator?.fractionComplete = (recognizer.translation(in: drawer).y / 305) + drawerPanStart } else { animator?.fractionComplete = (recognizer.translation(in: drawer).y / -305) + drawerPanStart } default: drawerPanStart = 0 let timing = UICubicTimingParameters(animationCurve: .easeOut) animator?.continueAnimation(withTimingParameters: timing, durationFactor: 0) let isSwipingDown = recognizer.velocity(in: drawer).y > 0 if isSwipingDown == !isDrawerOpen { animator?.isReversed = true } } }
This method is called for any change that occurs in the pan gesture-recognizer. When the pan gesture first starts, the animation is configured, and then pauseAnimation() is called on the animator object. This allows us to change the animation progress based on the user's pan behavior. Because the user might begin panning in the middle of the animation, for instance after tapping the toggle button first, the current fractionComplete value is stored in the drawerPanStart variable.
The value of fractionComplete is a value between 0 and 1 and it's decoupled from the time that your animation takes to run. So imagine that you are using an ease-in and ease-out timing parameter to animate a square from an x value of 0 to an x value of 100, the x value of 10 is not at 10% of the time the animation takes to complete. However, fractionComplete will be 0.1 which corresponds to the animation being 10% complete. This is because UIViewPropertyAnimator converts the timescale for your animation to linear once you pause it. Usually, this is the best behavior for an interactive animation. However, you can change this behavior by setting the scrubsLinearly property on your animator to false. If you do this, fractionComplete will take any timing parameters you've applied into account. You can try playing with this to see what it feels like for the drawer animation.
Once the initial animation is configured and paused, the user can move their finger around. When this happens, the fractionComplete property is calculated and set on the animator by taking the distance traveled by the user's finger and dividing it by the total distance required. Next, the progress made by the animation before being interrupted is added to this new value.
Finally, if the gesture ends, is canceled, or anything else, the start position is reset. Also, a timing parameter to use for the rest of the animation is configured and the animation is set up to continue. By passing a durationFactor of 0, the animator knows to use whatever time is left for the animation while taking into account its new timing function. Also, if the user tapped the toggle button to close the drawer, yet they catch it mid-animation and swipe upward, the animation should finish in the upward direction. The last couple of lines take care of this logic.
It's strongly recommended you experiment and play around with the code that you just saw. You can do powerful things with interruptible and interactive animations. Let's see how you can add some extra vibrancy to your animations with springs!