Interactive Animations with Swift
Swift empowers developers to create high-performance apps that entice users to engage with mobile devices more than ever before. Functionality has always been a key factor, however, animations make the experience of your app unique. Users often associate how an app looks with how well it works. That’s were the UIViewPropertyAnimator comes in to play.
Recently I worked on an exciting iOS app which required a more interactive user experience. To achieve this goal, I ensured the app had a consistent set of animations throughout, with parts of the app giving more control to the user when animating certain views.
Today I would like to briefly talk about the UIViewPropertyAnimator object and how you can use this to give users a fun, interactive experience when using your next app.
UIViewPropertyAnimator
The UIViewPropertyAnimator was first introduced in iOS 10 and is a class that animates changes to views and allows the dynamic modification of those animations. With a property animator, you have the option of executing your custom animations from start to finish without interruption, or you can turn them into interactive animations and control the timing yourself.
The animator operates on animatable properties of views, such as the frame, center, alpha, and transform properties, creating the needed animations from the blocks you provide.
You might be thinking this sounds just like the standard UIView animations that have been around since iOS 4, however, the property animator allows you to create more complex animations. The good news is that if we wanted to do a basic animation, the syntax is very similar.
Using UIView animations:
UIView.animate(withDuration: 0.3, delay: 0.0, options: .curveEaseInOut, animations: {
self.textLabel.alpha = 1
}, completion: nil)
Using the UIViewPropertyAnimator:
let propertyAnimator = UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut) {
self.textLabel.alpha = 1
}
propertyAnimator.startAnimation()
To better understand the property animator, let’s first have a look at the animation states.
Animation States
Animator objects move through a set of states during the processing of a set of animations. These states define the animator’s behaviour, including how it handles changes. When implementing your own animators, you must respect these state transitions and keep the state property updated accurately. Figure 1 shows the states and the state transitions that occur.
The animator object can be in one of three states: active, inactive, and stopped. The animator is initialised in an inactive state. It then transitions to the active state when started. If the animation is paused, the animator remains in the active state. Once the animation has finished, it transitions back to the inactive state. If the animation is stopped, it will transition to the stopped state and updates the properties of the corresponding views to their current in-progress values.
Example: Creating an Interactive Expandable View
Step 1 – Create the project setup
Firstly, create a new Single Page Application in Xcode, open up the Storyboard and use the ViewController that is already provided. Drag a UIView object on to your ViewController, give it a black background and add the following constraints:
Next we need to hook up our UIView and the height constraint in our ViewController.swift file:
@IBOutlet weak var expandableView: UIView!
@IBOutlet weak var expandableViewHeight: NSLayoutConstraint!
For this example, we will define a max and min-height for the expandable view, however, these can easily be altered for your own implementation:
let EXPANDABLE_VIEW_HEIGHT_MAX: CGFloat = UIScreen.main.bounds.size.height - 80
let EXPANDABLE_VIEW_HEIGHT_MIN: CGFloat = 80
Next, we’ll create an enum that defines the open and closed states and their opposite states:
private enum State {
case open
case closed
var opposite: State {
switch self {
case .open: return .closed
case .closed: return .open
}
}
}
We will also create a variable containing the current state for the expandable view, an array for any property animators we use, and an array for the animation progress of each of our property animators:
private var currentState: State = .closed
private var propertyAnimators: [UIViewPropertyAnimator] = []
private var animationProgress: [CGFloat] = []
Step 2 – Creating a non-interactive animation
To give an example of how we could create a non-interactive expanding view, we can use a simple a tap gesture recogniser, add this to the expandable view, and create a function for the gesture to call when fired:
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(viewTapped(recogniser:)))
tapGesture.delegate = self
expandableView.addGestureRecognizer(tapGesture)
The function below uses a simple UIView animation and updates the state in the completion handler.
@objc private func viewTapped(recogniser: UITapGestureRecognizer) {
let state = currentState.opposite
self.expandableViewHeight.constant = state == .open ? EXPANDABLE_VIEW_HEIGHT_MAX : EXPANDABLE_VIEW_HEIGHT_MIN
UIView.animate(withDuration: 1.0, delay: 0.0, options: .curveEaseOut, animations: {
self.view.layoutIfNeeded()
}) { (_) in
self.currentState = state
}
}
Don’t forget to add the delegate to your ViewController:
class ViewController: UIViewController, UIGestureRecognizerDelegate
You should now be able to build and run your project to see that you can tap to open and close the expandable view. This looks ok but we still can’t interact with the animation.
Step 3 – Creating an interactive animation
To create an interactive version of the example above, we will first create a new function where the animations will be executed:
private func animateExpandableView(to state: State, duration: TimeInterval) {
// Here we need to ensure that the animators array is empty which implies new animations need to be created
guard propertyAnimators.isEmpty else {
return
}
// Create a new property animator for the expanding view
let viewAnimator = UIViewPropertyAnimator(duration: duration, dampingRatio: 1.0, animations: {
switch state {
case .open:
self.expandableViewHeight.constant = EXPANDABLE_VIEW_HEIGHT_MAX
case .closed:
self.expandableViewHeight.constant = EXPANDABLE_VIEW_HEIGHT_MIN
}
self.view.layoutIfNeeded()
})
viewAnimator.addCompletion { position in
// update the state
switch position {
case .start:
self.currentState = state.opposite
case .end:
self.currentState = state
case .current:
break
}
// Reset the constraint positions
switch self.currentState {
case .open:
self.expandableViewHeight.constant = EXPANDABLE_VIEW_HEIGHT_MAX
case .closed:
self.expandableViewHeight.constant = EXPANDABLE_VIEW_HEIGHT_MIN
}
// Remove all running animators
self.propertyAnimators.removeAll()
}
viewAnimator.startAnimation()
propertyAnimators.append(viewAnimator)
}
Next, I have created a UIPanGestureRecgnizer subclass which overrides the touchesBegan event so that it enters the began state instead of waiting for a touchesMoved event:
class InteractivePanGestureRecognizer: UIPanGestureRecognizer {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
if (self.state == UIGestureRecognizer.State.began) {
return
}
super.touchesBegan(touches, with: event)
self.state = UIGestureRecognizer.State.began
}
}
This ensures that you can both tap and pan the expandable view, giving us the desired functionality.
Next, we need to replace the UITapGestureRecognizer with our InteractivePanGestureRecognizer:
let panGesture = InteractivePanGestureRecognizer(target: self, action: #selector(viewPanned(recognizer:)))
panGesture.delegate = self
expandableView.addGestureRecognizer(panGesture)
Next, we will create a function for our new pan gesture to fire:
@objc private func viewPanned(recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began:
// Start the animations
animateExpandableView(to: currentState.opposite, duration: 1)
// Pause all animations, since the next event may be a pan changed
propertyAnimators.forEach { $0.pauseAnimation() }
// Keep track of each animator's progress
animationProgress = propertyAnimators.map { $0.fractionComplete }
case .changed:
// Get the current translation of the expandable view
let translation = recognizer.translation(in: self.expandableView)
// Here we calculate the percentage of the animation that has been carried out
var percentage = -translation.y / (EXPANDABLE_VIEW_HEIGHT_MAX - EXPANDABLE_VIEW_HEIGHT_MIN)
// Apply the percentage for the current state and reversed state
if currentState == .open { percentage *= -1 }
if propertyAnimators[0].isReversed { percentage *= -1 }
// Apply the new percentage to each of the animators
for (index, animator) in propertyAnimators.enumerated() {
animator.fractionComplete = percentage + animationProgress[index]
}
case .ended:
// Calculate the velocity of the pan gesture
let yVelocity = recognizer.velocity(in: self.expandableView).y
// If the velocity is greater than zero, we will complete the animation
let shouldComplete = yVelocity > 0
// If no motion, continue all animations
if yVelocity == 0 {
propertyAnimators.forEach { $0.continueAnimation(withTimingParameters: nil, durationFactor: 0) }
break
}
// Reverse the animations based on the pan gesture and current state
switch currentState {
case .open:
if !shouldComplete && !propertyAnimators[0].isReversed {
propertyAnimators.forEach {
$0.isReversed = !$0.isReversed
}
}
if shouldComplete && propertyAnimators[0].isReversed {
propertyAnimators.forEach {
$0.isReversed = !$0.isReversed
}
}
case .closed:
if shouldComplete && !propertyAnimators[0].isReversed {
propertyAnimators.forEach {
$0.isReversed = !$0.isReversed
}
}
if !shouldComplete && propertyAnimators[0].isReversed {
propertyAnimators.forEach {
$0.isReversed = !$0.isReversed
}
}
}
// Continue animations
propertyAnimators.forEach {
$0.continueAnimation(withTimingParameters: nil, durationFactor: 0)
}
default:
break
}
}
This function is where all the calculations are carried out. We first calculate what percentage of the animation has occurred and updating all of our property animators (the property animators array allows us to animate more than just the expandable view in the future, however for this example, we are just using the one animator). We have then calculated the velocity of the pan gesture and used this to determine whether the animation should complete if the user stops panning.
If we run our project again, we should now be able to both pan and tap our expandable view which gives the user a fully interactive and more intuitive feel to the app.
Step 4 – More complex animations
If you want to go a step further, you could add some elements to the expandable view and animate them too by giving them their own property animators. You can also animate other properties of the expandable view, such as the backgroundColor, and cornerRadius.
To do this, simply update the following code within the animateExpandableView function:
let viewAnimator = UIViewPropertyAnimator(duration: duration, dampingRatio: 1.0, animations: {
switch state {
case .open:
self.expandableViewHeight.constant = EXPANDABLE_VIEW_HEIGHT_MAX
self.expandableView.backgroundColor = UIColor.black.withAlphaComponent(0.5)
self.expandableView.layer.cornerRadius = 15
case .closed:
self.expandableViewHeight.constant = EXPANDABLE_VIEW_HEIGHT_MIN
self.expandableView.backgroundColor = UIColor.black.withAlphaComponent(1.0)
self.expandableView.layer.cornerRadius = 0
}
self.view.layoutIfNeeded()
})
This will now animate the backgroundColor and cornerRadius as the user pans the view.
We will go one step further by adding a titleLabel and imageView, give the imageView a width constraint and an aspect ration of 1. Then add the following code to your animateExpandableView function:
let titleLabelAnimator = UIViewPropertyAnimator(duration: duration, curve: .linear, animations: {
switch state {
case .open:
self.titleLabel.transform = .identity
self.titleLabel.transform = CGAffineTransform(scaleX: 1.4, y: 1.4)
case .closed:
self.titleLabel.transform = .identity
self.titleLabel.transform = CGAffineTransform(scaleX: 1.0, y: 1.0)
}
})
titleLabelAnimator.scrubsLinearly = true
let imageViewAnimator = UIViewPropertyAnimator(duration: duration, curve: .linear, animations: {
switch state {
case .open:
self.imageViewWidth.constant = 200
self.imageView.alpha = 1
case .closed:
self.imageViewWidth.constant = 50
self.imageView.alpha = 0
}
self.expandableView.layoutIfNeeded()
})
imageViewAnimator.scrubsLinearly = true
// Start all animators
viewAnimator.startAnimation()
titleLabelAnimator.startAnimation()
imageViewAnimator.startAnimation()
// Keep track of all running animators
propertyAnimators.append(viewAnimator)
propertyAnimators.append(titleLabelAnimator)
propertyAnimators.append(imageViewAnimator)
This should now animate the label and image as you pan the expandable view.
The final product should look something like this: