Using UIViewPropertyAnimator to Create Custom Animations

Original author: Eugene Trapeznikov
  • Transfer
  • Tutorial
Creating animations is great. They are an important part of the iOS Human Interface Guidelines . Animations help draw the user's attention to important things or simply make the application less boring.

There are several ways to implement animation in iOS. Probably the most popular way is to use UIView.animate (withDuration: animations :) . You can animate an image layer using CABasicAnimation . Furthermore, UIKit to configure a custom animation to display controller via UIViewControllerTransitioningDelegate .

In this article I want to discuss another exciting way to animate views - UIViewPropertyAnimator. This class provides many more management functions than its predecessor, UIView.animat e. Use it to create temporary, interactive, and interrupted animations. In addition, it is possible to quickly change the animator.

Introducing UIViewPropertyAnimator


UIViewPropertyAnimator was introduced in iOS 10 . It allows you to create animations in an object-oriented manner. Let's look at an example of an animation created using the UIViewPropertyAnimator .

image

This is how it was when using UIView.

UIView.animate(withDuration: 0.3) {
    view.frame = view.frame.offsetBy(dx: 100, dy: 0)
}

And here's how to do it with the UIViewPropertyAnimator :

let animator = UIViewPropertyAnimator(duration:0.3, curve: .linear) {
     view.frame = view.frame.offsetBy(dx:100, dy:0)
 }
 animator.startAnimation()

If you need to check the animation, just create a Playground and run the code as shown below. Both code fragments will lead to the same result.

image

You might think that in this example there is not much difference. So, what's the point of adding a new way to create animations? UIViewPropertyAnimator becomes more useful when you need to create interactive animations.

Interactive and interrupted animation


Do you remember the classic “Finger Swipe to Unlock Device” gesture? Or the gesture "Move your finger on the screen from bottom to top" to open the Control Center? These are great examples of interactive animation. You can start moving the image with your finger, then release it and the image will return to its original position. In addition, you can catch the image during the animation and continue moving it with your finger.

UIView animations do not provide an easy way to control the percentage of animation completion. You cannot pause the animation in the middle of a loop and continue its execution after interruption.

In this case, it will be about UIViewPropertyAnimator. Next, we will look at how you can easily create fully interactive, interrupted animation, and reverse animation in a few steps.

Preparation of the launch project


First you need to download the starter project . Once you open the archive, you will find the CityGuide application that helps users plan their holidays. The user can scroll through the list of cities, and then open a detailed description with detailed information about the city that he liked.

Consider the source code of the project before we start creating beautiful animations. Here's what you can find in a project by opening it in Xcode :

  1. ViewController.swift : The main application controller with a UICollectionView that displays an array of City objects .
  2. CityCollectionViewCell.swift: Cell to display City . In fact, in this article most of the changes will apply to this class. You may notice that descriptionLabel and closeButton are already defined in the class. However, after starting the application, these objects will be hidden. Do not worry, they will be visible a little later. This class also has collectionView and index properties . Later they will be used for animation.
  3. CityCollectionViewFlowLayout.swift: This class is responsible for horizontal scrolling. We will not change it yet.
  4. City.swift : The main application model has a method that was used in ViewController.
  5. Main.storyboard: You can find the user interface for the ViewController and CityCollectionViewCell .

Let's try to build and run the sample application. As a result of this, we obtain the following.

cityguideapp-iphone8

Implementation of deployment and collapse animation


After starting the application, a list of cities is displayed. But the user cannot interact with objects in the form of cells. Now you need to display information for each city when the user clicks on one of the cells. Take a look at the final version of the application. Here's what actually needed to be developed:

image

The animation looks good, doesn't it? But there is nothing special here, it's just the basic logic of the UIViewPropertyAnimator . Let's see how to implement this type of animation. Create a collectionView method (_: didSelectItemAt) , add the following code fragment to the end of the ViewController file :

func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
    let selectedCell = collectionView.cellForItem(at: indexPath)! as! CityCollectionViewCell
    selectedCell.toggle()
}

Now we need to implement the toggle method . Let's switch to CityCollectionViewCell.swift and implement this method.

First, add the State enumeration to the top of the file, right before the declaration of the CityCollectionViewCell class . This listing allows you to track the state of a cell:

private enum State {
    case expanded
    case collapsed
    var change: State {
        switch self {
        case .expanded: return .collapsed
        case .collapsed: return .expanded
        }
    }
}

Add some properties to control the animation in the CityCollectionViewCell class :

private var initialFrame: CGRect?
private var state: State = .collapsed
private lazy var animator: UIViewPropertyAnimator = {
    return UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut)
}()

The initialFrame variable is used to store the cell frame until the animation runs . state is used to track if the cell is expanded or collapsed. And the animator variable is used to control the animation.

Now add the toggle method and call it from the close method , for example:

@IBAction func close(_ sender: Any) {
    toggle()
}
func toggle() {
    switch state {
    case .expanded:
        collapse()
    case .collapsed:
        expand()
    }
}

Then we add two more methods: expand () and collapse () . We will continue their implementation. First we start with the method expansiond () :

private func expand() {
    guard let collectionView = self.collectionView, let index = self.index else { return }
    animator.addAnimations {
        self.initialFrame = self.frame
        self.descriptionLabel.alpha = 1
        self.closeButton.alpha = 1
        self.layer.cornerRadius = 0
        self.frame = CGRect(x: collectionView.contentOffset.x, y:0, width: collectionView.frame.width, height: collectionView.frame.height)
        if let leftCell = collectionView.cellForItem(at: IndexPath(row: index - 1, section: 0)) {
            leftCell.center.x -= 50
        }
        if let rightCell = collectionView.cellForItem(at: IndexPath(row: index + 1, section: 0)) {
            rightCell.center.x += 50
        }
        self.layoutIfNeeded()
    }
    animator.addCompletion { position in
        switch position {
        case .end:
            self.state = self.state.change
            collectionView.isScrollEnabled = false
            collectionView.allowsSelection = false
        default:
            ()
        }
    }
    animator.startAnimation()
}

How much code. Let me explain what is happening step by step:

  1. First check if not equal collectionView and index zero. Otherwise, we will not be able to start the animation.
  2. Next, start creating the animation by calling animator.addAnimations .
  3. Next, save the current frame, which is used to restore it in the convolution animation.
  4. Then we set the alpha value for descriptionLabel and closeButton to make them visible.
  5. Next, remove the rounded corner and set a new frame for the cell. The cell will be shown in full screen.
  6. Next we move the neighboring cells.
  7. Now call the animator.addComplete () method to disable the interaction of the collection image. This prevents users from scrolling it during cell expansion. Also change the current state of the cell. It is important to change the state of the cell, and only after that the animation ends.

Now add a convolution animation. In short, it's just that we restore the cell to its previous state:

private func collapse() {
    guard let collectionView = self.collectionView, let index = self.index else { return }
    animator.addAnimations {
        self.descriptionLabel.alpha = 0
        self.closeButton.alpha = 0
        self.layer.cornerRadius = self.cornerRadius
        self.frame = self.initialFrame!
        if let leftCell = collectionView.cellForItem(at: IndexPath(row: index - 1, section: 0)) {
            leftCell.center.x += 50
        }
        if let rightCell = collectionView.cellForItem(at: IndexPath(row: index + 1, section: 0)) {
            rightCell.center.x -= 50
        }
        self.layoutIfNeeded()
    }
    animator.addCompletion { position in
        switch position {
        case .end:
            self.state = self.state.change
            collectionView.isScrollEnabled = true
            collectionView.allowsSelection = true
        default:
            ()
        }
    }
    animator.startAnimation()
}

Now it's time to compile and run the application. Try clicking on the cell and you will see the animation. To close the image, click on the cross icon in the upper right corner.

Adding gesture processing


You can claim to achieve the same result using UIView.animate . What is the point of using a UIViewPropertyAnimator ?

Well, it's time to make the animation interactive. Add UIPanGestureRecognizer and a new property named popupOffset , to keep track of how much you can move the cell. Let's declare these variables in the CityCollectionViewCell class :

private let popupOffset: CGFloat = (UIScreen.main.bounds.height - cellSize.height)/2.0
private lazy var panRecognizer: UIPanGestureRecognizer = {
    let recognizer = UIPanGestureRecognizer()
    recognizer.addTarget(self, action: #selector(popupViewPanned(recognizer:)))
    return recognizer
}()

Then add the following method to register the swipe definition:

override func awakeFromNib() {
    self.addGestureRecognizer(panRecognizer)
}

Now you need to add the popupViewPanned method to track the swipe gesture. Paste the following code into the CityCollectionViewCell :

@objc func popupViewPanned(recognizer: UIPanGestureRecognizer) {
    switch recognizer.state {
    case .began:
        toggle()
        animator.pauseAnimation()
    case .changed:
        let translation = recognizer.translation(in: collectionView)
        var fraction = -translation.y / popupOffset
        if state == .expanded { fraction *= -1 }
        animator.fractionComplete = fraction
    case .ended:
        animator.continueAnimation(withTimingParameters: nil, durationFactor: 0)
    default:
        ()
    }
}

There are three states. At the beginning of the gesture, we initialize the animator using the toggle method and immediately pause it. While the user drags the cell, we update the animation by setting the properties of the fractionComplete multiplier . This is the main magic of the animator, which allows them to control. Finally, when the user releases his finger, the continueAnimation animator method is called to continue the animation. Then the cell will move to the target position.

After starting the application, you can drag the cell up to expand it. Then drag the expanded cell down to collapse it.

The animation now looks pretty good, but interrupting the animation in the middle is not possible. Therefore, to make the animation fully interactive, you need to add another function - interruption. The user can start the expand / collapse animation as usual, but the animation should be paused immediately after the user clicks on the cell during the animation cycle.

To do this, you need to save the progress of the animation and then take this value into account in order to calculate the percentage of completion of the animation.

First, declare a new property in CityCollectionViewCell :

private var animationProgress: CGFloat = 0

Then update .began block method popupViewPanned with the next line of code, to store progress:

animationProgress = animator.fractionComplete

In the .changed block, you need to update the following line of code in order to correctly calculate the percentage of completion:

animator.fractionComplete = fraction + animationProgress

Now the application is ready for testing. Run the project and see what happens. If all the actions are performed correctly following my instructions, the animation should look like this:

image

Animation reverse


You can find a flaw for the current implementation. If you drag the cell a bit and then return it to its original position, the cell will continue to expand when you release your finger. Let's fix this problem to make the interactive animation even better.
Perform block update .end method popupViewPanned , as described below:

let velocity = recognizer.velocity(in: self)
let shouldComplete = velocity.y > 0
if velocity.y == 0 {
    animator.continueAnimation(withTimingParameters: nil, durationFactor: 0)
    break
}
switch state {
case .expanded:
    if !shouldComplete && !animator.isReversed { animator.isReversed = !animator.isReversed }
    if shouldComplete && animator.isReversed { animator.isReversed = !animator.isReversed }
case .collapsed:
    if shouldComplete && !animator.isReversed { animator.isReversed = !animator.isReversed }
    if !shouldComplete && animator.isReversed { animator.isReversed = !animator.isReversed }
}
animator.continueAnimation(withTimingParameters: nil, durationFactor: 0)

Now we take into account the speed of the gesture to determine if the animation should be reversed.

And finally, insert another line of code into the .changed block . Put this code to the right of the animator.fractionComplete calculation .

if animator.isReversed { fraction *= -1 }

Let's run the application again. Now everything should work without fail.

image

Fix pan gesture


So, we have completed the implementation of the animation using the UIViewPropertyAnimator . However, there is one unpleasant mistake. You may have met her while testing the application. The problem is that scrolling the cell horizontally is not possible. Let's try to swipe left / right through the cells, and we are confronted with the problem.

The main reason is related to the UIPanGestureRecognizer we created . It also catches a swipe gesture, and conflicts with the built-in gesture recognizer UICollectionView .

Although the user can still scroll through the top / bottom of the cells or the space between cells to scroll through cities, I still don't like such a bad user interface. Let's fix it.

To resolve conflicts, we need to implement a delegate method called gestRecognizerShouldBegin (_ :) . This method controls whether the gesture recognizer should continue to interpret touches. If you return false in the method, the gesture recognizer will ignore touches. So what we're going to do is give our own panorama recognition tool the ability to ignore horizontal movements.

To do this, let's set the delegate of our pan recognizer. Insert the following line of code into the panRecognizer initialization (you can put the code right before the return recognizer :

recognizer.delegate = self

Then, we implement the gestRecognizerShouldBegin (_ :) method as follows:

override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
    return abs((panRecognizer.velocity(in: panRecognizer.view)).y) > abs((panRecognizer.velocity(in: panRecognizer.view)).x)
}

We will open / close if its vertical speed is greater than the horizontal speed.

Wow! Let's test the application again. Now you can move around the list of cities by swiping left / right across the cells.

image

Bonus: Custom Sync Features


Before we finish this tutorial, let's talk about timing functions. Do you still remember the case when the developer asked you to implement a custom synchronization function for the animation that you create?

Usually, you should change the UIView.animation to CABasicAnimation or wrap it in CATransaction . Through UIViewPropertyAnimator you can easily implement a custom timing functions.

Under timing functions (or easing functions) refers animation speed functions which influence the rate of change of a particular property being animated. Four types are currently supported: easeInOut, easeIn, easeOut, linear.

Replace the animator initialization with this timing functions (try drawing your own Bezier cubic curve) as follows:

private lazy var animator: UIViewPropertyAnimator = {
    let cubicTiming = UICubicTimingParameters(controlPoint1: CGPoint(x: 0.17, y: 0.67), controlPoint2: CGPoint(x: 0.76, y: 1.0))
    return UIViewPropertyAnimator(duration: 0.3, timingParameters: cubicTiming)
}()

Alternatively, instead of using cubic synchronization parameters, you can also use spring synchronization, for example:

    let springTiming = UISpringTimingParameters(mass: 1.0,
                                                stiffness: 2.0,
                                                damping: 0.2,
                                                initialVelocity: .zero)

Try to start the project again and see what happens.

Conclusion


Through UIViewPropertyAnimator, you can enhance static screens and user interaction through interactive animations.

I know that you cannot wait to realize what you have learned in your own project. If you apply this approach in your project, it will be very cool, let me know about this by leaving a comment below.

As a reference, here you can download the final draft .

Further links


Professional animations using UIKit - https://developer.apple.com/videos/play/wwdc2017/230/

UIViewPropertyAnimator Apple Developer Documentation - https://developer.apple.com/documentation/uikit/uiviewpropertyanimator

Also popular now: