Animating View Transitions in Swift

Animating View Transitions in Swift example image

Hallo Dames en Heren, Leo hier. The topic today is animating View Transitions in Swift, and how to do smooth view animation transitions with UIKit.

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 APIs that we can manipulate the animation. This can give life to your apps and make them more vibrant and enjoyable.

Animations and view transitions use 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!

 

Painting of The Day

This painting is a mural called “Sorting the Mail” a 1936 art piece from Reginald Marsh at the Ariel Rios Federal Building, Washington, D.C. He was born on March 14 of 1898 and died on July 3 of 1954. 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 from manual labor work to the industrial revolution machines. At the top, you can see automatic tracks carrying over the mail and at the bottom, all the workers have to sort each of those by hand.

 

The Problem – Animating View Transitions in Swift

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):

Animation

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

 

UITableView Setup

Let’s go. First, let’s set up 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 UIKit UIViewController Transition Animations

It’s not the scope of this article to explain how the UIViewControllerTransitioningDelegate and UIViewControllerAnimatedTransitioning works, 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 makes it available so we can manipulate it as an intermediary view between the actual View Controller.
  4. UIKit queries the controller for the duration of its animation by calling transitionDuration function and invoke invoke animateTransition on the animation, the controller performs 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 work, we can make this happen. So let’s do it!

 

UIViewControllerTransitioningDelegate and UIViewControllerAnimatedTransitioning

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 we 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 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 FromView Controller is inverted.
  6. Mark 6 – This is the animation block of the dismissing part.
  7. Mark 7 – These two functions 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-paste 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()
    }
}

 

Selecting a table view cell to Animate Transition

Now add an extension for you ViewController.swift file:

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 to 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 fromViewController.
  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 we just animate the tempImageView to the tempImageInitialFrame.

And it’s done!

 

Extra Mile: Close Pinch Gesture to dismiss animated UIViewController

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 recognizer to it:

@objc func onPinch(sender: UIPinchGestureRecognizer) {
    let scale = sender.scale
    if scale < 1 {
        if sender.state == .began {
            dismiss(animated: true)
        } else if sender.state == .changed {
            percentage = 1 - scale
            interactiveTransition.update(percentage)
        } else if sender.state == .ended {
            interactiveTransition.finish()
        }
    } else if sender.state == .ended {
        interactiveTransition.finish()
    }
}

Mark 1 is important to select only the closing pinch movement that is what we are looking for here. Mark 2 is the start of the dismisses, don’t forget to put this as animated = true. In 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 the interactiveTransition object that we finish the transition. In this case, it is always the user who stops the movement. This piece of code was improved by the user @frankie-baron, thanks bro!

The pinch movement should be something like this:

Animation

And we are done!

 

Summary – Animating View Transitions in Swift

In this week’s article, you could see how you can animate the transition to creating very vivid experiences for your users.You have to use animation with parsimony and always look into the Apple Human Interface Guidelines, the famous HIG animations section.

That’s all my people, I hope you liked reading this article as much as I enjoyed writing it. If you want to support this blog you can Buy Me a Coffee or leave a comment saying hello. You can also sponsor posts and I’m open to freelance writing! You can reach me on LinkedIn or Twitter and send me an e-mail through the contact page.

Thanks for reading and… That’s all folks.

Credits:

title image

Share this post:

Related posts

Sponsor