Holy Swift

Holy Swift

Animating View Transitions in Swift

Animating View Transitions in Swift

Everything changes, let's embrace the transition.

Subscribe to my newsletter and never miss my upcoming articles

Hallo Dames en Heren, Leo hier.

Today we will explore how can you create a transition animation between two view controllers. Whenever you try to present a view controller it can be animated or not. The good thing is that Apple provides API's that we can manipule the animation. This can give live to your apps and make them more vibrant and enjoyable.

Animations and view transitions uses two delegates and you have to manipulate a third container view to make it work properly. If you don't want to miss that, stay tuned because this will be legen...dary! This article will not show how At the end of this article, I'll leave a link to the project on GitHub so you will be able to easily get the whole project.

But first... the painting!

The Painting

This painting is a mural called "Sorting the Mail" an 1936 art piece from Reginald Marsh at the Ariel Rios Federal Building, Washington, D.C. He was born in March 14 of 1898 and died July 3 of 1954. He was an American painter, born in Paris, most notable for his depictions of life in New York City in the 1920s and 1930s. Crowded Coney Island beach scenes, popular entertainments such as vaudeville and burlesque, women, and jobless men on the Bowery are subjects that reappear throughout his work. He painted in egg tempera and in oils, and produced many watercolors, ink and ink wash drawings, and prints.

I choose this painting because it has the exact representation of the transition between the manual labor work to the industrial revolution machines. At the top you can see automatic tracks carrying over the mail and in the bottom all the workers having to sort each of those by hand.

The Problem

You have a list of items and each cell has an image and a label. You want when a user tap in a cell you animate the image to fit into an exact position on the details view.

The final should be something like this ( of course the gif doesn't catch the animation too good):

Simulator Screen Recording - iPhone 13 Pro - 2021-10-13 at 08.45.15.gif

A little disclaimer before we start: I'm using Xcode 13 and iOS 15, most of this SF Symbols are only available in iOS 15 +.

Table View Setup

Let's go. First let's setup the table view. Create a new project and copy paste this code to the default generated ViewController:

class ViewController: UIViewController {

    let tableView = UITableView()
    let transitionManager = TransitionDetailsController()

    let data = ["square.and.arrow.up","lasso.and.sparkles", "trash.square", "folder.badge.gearshape", "list.bullet.rectangle.portrait", "person.badge.clock.fill", "person.crop.square.filled.and.at.rectangle.fill", "peacesign", "network.badge.shield.half.filled", "music.mic.circle.fill"]

    var tempImageView = UIImageView()

    override func viewDidLoad() {
        super.viewDidLoad()
        configureTableView()

    }

    private func configureTableView() {
        view.addSubview(tableView)
        tableView.translatesAutoresizingMaskIntoConstraints = false

        tableView.delegate = self
        tableView.dataSource = self
        tableView.register(ColorTableViewCell.self, forCellReuseIdentifier: ColorTableViewCell.reuseId)
        tableView.separatorStyle = .none

        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.topAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
        ])
    }
}

This will not work yet, we have to create two things: the ColorTableViewCell and make our ViewController conforms to UITableViewDataSource and UITableViewDelegate. Let's do it, in your ViewController file copy paste the code below:

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {

        return data.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: ColorTableViewCell.reuseId) as? ColorTableViewCell else { return UITableViewCell()}
        cell.configure(imageString: data[indexPath.row])
        return cell
    }

Now create a new file called ColorTableViewCell.swift and copy paste this:

import UIKit

class ColorTableViewCell: UITableViewCell {

    let emojiImageView = UIImageView()
    let titleLabel = UILabel()
    static let reuseId = "ColorTableViewCell"

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        configureContentView()
        configureImageView()
        configureTitleLabel()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func configureContentView() {
        contentView.backgroundColor = .white
    }

    private func configureImageView() {
        emojiImageView.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(emojiImageView)

        emojiImageView.contentMode = .scaleAspectFit

        NSLayoutConstraint.activate([
            emojiImageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20),
            emojiImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -20),
            emojiImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
            emojiImageView.heightAnchor.constraint(equalToConstant: 44),
            emojiImageView.widthAnchor.constraint(equalToConstant: 44)
        ])
    }

    private func configureTitleLabel() {
        titleLabel.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(titleLabel)
        titleLabel.textColor = .systemBlue
        titleLabel.numberOfLines = 2

        NSLayoutConstraint.activate([
            titleLabel.leadingAnchor.constraint(equalTo: emojiImageView.trailingAnchor, constant: 20),
            titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20),
            titleLabel.centerYAnchor.constraint(equalTo: emojiImageView.centerYAnchor)
        ])
    }

    func configure(imageString: String) {
        emojiImageView.image = UIImage(systemName: imageString)
        titleLabel.text = imageString
    }
}

The very minimum table view setup is done. Now the fun part begins.

The Transition Animations

It's not the scope of this article to explain how does the UIViewControllerTransitioningDelegate and UIViewControllerAnimatedTransitioning, but in a few words the animating transition follows these steps:

  1. You trigger the transition. UIKit asks the target View Controller for its transitioning delegate. A custom or the default one.
  2. The UIKit query the transitioning delegate object from the target view controller for an animation controller via animationController(forPresented:presenting:source:).
  3. The UIKit builds the transitioning context and make it available so we can manipulate it as a intermediary view between the actual View Controller .
  4. UIKit query the controller for the duration of its animation by calling transitionDuration function and invoke invokes animateTransition on the the animation controller to perform the desired animation.
  5. The last part is you calling the completeTransition(_:) on the animation controller.

It's not a very straightforward process but with some effort we can make this happen. So let's do it all!

First create a new file called TransitionDetailsController.swift and copy paste the code below:

import UIKit

class TransitionDetailsController: NSObject, UIViewControllerTransitioningDelegate, UIViewControllerAnimatedTransitioning {
    // we will use this later. You can ignore this for now.
    //let interactiveTransition = UIPercentDrivenInteractiveTransition()

    // this is the animation duration.
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        0.5
    }

    // this is where we gonna put all of animations. More explanations below.
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        // there are three View Controllers involved in every View Controller animation transition.
        let containerView = transitionContext.containerView // Mark 1
        if let toViewController = transitionContext.viewController(forKey: .to) as? DetailsViewController ,
           let fromViewController = transitionContext.viewController(forKey: .from) as? ViewController { // Mark 2

            containerView.addSubview(toViewController.view) // Mark 3

            showPresentingAnimation(fromViewController, toViewController, transitionContext) // Mark 4

        } else if let fromViewController = transitionContext.viewController(forKey: .from) as? DetailsViewController { // Mark 5
            showDismissAnimation(fromViewController, transitionContext) // Mark 6
        }
    }

    private func showPresentingAnimation(_ fromViewController: ViewController, _ toViewController: DetailsViewController, _ transitionContext: UIViewControllerContextTransitioning) {

        // Here I setup the image view as a subview I created in the ViewController to the DetailsViewControler, this I can Animate the dismiss.
        let tempImage = fromViewController.tempImageView
        toViewController.tempImageInitialFrame = tempImage.frame
        toViewController.tempImageView = tempImage
        toViewController.interactiveTransition = interactiveTransition

        toViewController.view.addSubview(tempImage)
        toViewController.view.alpha = 0

        // Just the animation of the tempImage
        UIView.animateKeyframes(withDuration: 0.5, delay: 0, options: .calculationModePaced, animations: {
            UIView.addKeyframe(withRelativeStartTime: 0.0, relativeDuration: 0.90) {
                UIView.animate(withDuration: 0.90, animations: {
                    tempImage.frame = toViewController.emojiImageViewFinalFrame
                })
            }

            UIView.addKeyframe(withRelativeStartTime: 0.9, relativeDuration: 0.10) {
                UIView.animate(withDuration: 0.10, animations: {
                    toViewController.view.alpha = 1
                })
            }
        }, completion: { _ in
            transitionContext.completeTransition(true)
        })
    }

    // the dismiss animation
    private func showDismissAnimation(_ fromViewController: DetailsViewController, _ transitionContext: UIViewControllerContextTransitioning) {
        UIView.animate(withDuration: 0.5, animations: {
            fromViewController.view.alpha = 0
            fromViewController.tempImageView.frame = fromViewController.tempImageInitialFrame
        }) { _ in
            transitionContext.completeTransition(true)
        }
    }

// You can ignore this for now.
//    func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
//        interactiveTransition.completionSpeed = 0.99
//        return interactiveTransition
//    }

    // Mark 7
    func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return self
    }

    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return self
    }
}

The details of each mark:

  1. Mark 1 - This is one of the three views that participate in the process. The Container View acts as the superview of all other views (including those of the presenting and presented view controllers) during the animation sequence. UIKit sets this view for you and automatically adds the view of the presenting view controller to it.
  2. Mark 2 - These are the two other View Controllers that participate in the process, the from View Controller and the to, the target View Controller.
  3. Mark 3 - As you see above, the from view Controller is already added to the container view, so we need to add the to View Controller ( the DetailsViewController View in this case to the Container View hierarchy)
  4. Mark 4 - This is just a helper method to not clutter too much the animateTransition function, the summary is it will handle all the presenting animations.
  5. Mark 5 - This is the case when we are dismissing the DetailsViewController, the From View Controller is inverted.
  6. Mark 6 - This is the animation block of the dismissing part.
  7. Mark 7 - These two function are important to say to the UIKit that we are going to use our TransitionDetailsController to control the transition animation.

Now you have to create the file called DetailsViewController.swift and copy the code below:

import UIKit

class DetailsViewController: UIViewController {

    var imageName: String!
    let emojiImageViewFinalFrame = CGRect(x: 50, y: 50, width: UIScreen.main.bounds.width - 100 , height: UIScreen.main.bounds.height/3)
    var tempImageView: UIImageView!
    var tempImageInitialFrame: CGRect!
    var interactiveTransition: UIPercentDrivenInteractiveTransition!
    var percentage: CGFloat = 0.0

    init(imageName: String) {
        super.init(nibName: nil, bundle: nil)
        self.imageName = imageName

        view.backgroundColor = .white

        //view.addGestureRecognizer(UIPinchGestureRecognizer(target: self, action: #selector(onPinch(sender:))))
        view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(viewTapped)))
    }

    @objc func viewTapped() {
        dismiss(animated: true)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()
    }

Now add this extension for you ViewController :

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let vc = DetailsViewController(imageName: data[indexPath.row])

        let cellTapped = tableView.cellForRow(at: indexPath) as! ColorTableViewCell // Mark 1
        tempImageView.image = UIImage(systemName: data[indexPath.row])
        tempImageView.frame = view.convert(cellTapped.emojiImageView.frame, from: cellTapped) // Mark 1


        vc.transitioningDelegate = transitionController // Mark 2
        vc.modalPresentationStyle = .custom

        present(vc, animated: true)
    }

On Mark 1 what we are doing is to create a clone image view of the ColorTableViewCell. With this new image we attribute it to a UIIMageView that is exactly above the frame of the UIImageView, to do that we use the convert function to give the exact position of it in relation of the ViewController.view. With this set we can attribute on Mark 2 the transitionController to the viewController that we want to show.

The summary of our logic is:

  1. Find where the image inside the ColorTableViewCell is in relation to the from ViewController.
  2. Now we can attribute other imageView ( the tempImageView) the same frame but this we can use to animate.
  3. We add this tempImageView to the next ViewController and animate the position with a fixed frame (the emojiImageViewFinalFrame).

And the dismiss process is more or less the same but the we just animate the tempImageView to the tempImageInitialFrame. And it's done!

Bonus points: Close Pinch Gesture to dismiss animated!

This is achieved using the UIPercentDrivenInteractiveTransition object. We need to pass it to the DetailsViewController, because this object controls how much of the animation is passed.

If you want to see how can you dismiss with animation using a gesture, you can uncomment the interactionControllerForDismissal in TransitionDetailsController.

    func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        interactiveTransition.completionSpeed = 0.99
        return interactiveTransition
    }

In the DetailsViewController we uncomment this:

        view.addGestureRecognizer(UIPinchGestureRecognizer(target: self, action: #selector(onPinch(sender:))))
        //view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(viewTapped))) 
///and comment this

And add a gesture recogniser to it:

  @objc func onPinch(sender: UIPinchGestureRecognizer) {
        let scale = sender.scale
        if scale < 1 { // Mark 1
            if (sender.state == .began) { // Mark 2
                dismiss(animated: true)
            } else if (sender.state == .changed) { // Mark 3
                percentage += scale/50
                interactiveTransition.update(percentage) 
            } else if sender.state == .ended { // Mark 4
                    interactiveTransition.finish()
            }
        }
    }
}

The Mark 1 is important to select only the closing pinch movement that are what we are looking for here. The Mark 2 is the start of the dismiss, don't forget to put this as animated = true. In the Mark 3 we adjust the percentage of finish to the interactiveTransition, we need to divide by 50 because if not, it's too fast, and can also test your own values for this. And the last Mark 4 is when we tell to the interactiveTransition object that we finish the transition. I this case it is always the user stop the movement.

The pinch movement should be something like this:

Simulator Screen Recording - iPhone 13 Pro - 2021-10-13 at 21.04.23.gif

And we are done!

Summary

In this week post you could see how you can animate the transition to create very vivid experiences for yours users. Of course you have to use animation with parsimony and always looking into the Apple Human Interface Guidelines, the famous HIG animations section.

The link to the project is in my GitHub. Feel free to clone it and modify it!

That's all my people, I hope you liked as I enjoyed write this article. If you want to support this blog you can Buy Me a Coffee or just leave a comment saying hello. You can also sponsor posts! Just reach me in LinkedIn or Twitter for details.

Thanks for the reading and... That's all folks.

credits: image

Interested in reading more such articles from Leonardo Maia Pugliese?

Support the author by donating an amount of your choice.

 
Share this