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.

PixelBeard Mobile App Agency Liverpool App Development swift interactive animation how to

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: