Written by Alex Gibson, on March 31, 2017


You are Ready for UIViewController Animated Transitions

I remember when I first started developing and paying attention to the detail in apps how much I wanted to be able to pull off smooth animations between view controllers.  Since iOS 7 that is getting easier and easier.  If you know how to use the basic UIView animations then you can create a custom transition between your viewcontrollers.  I am going to walk you through the steps that show you how to create a simple transition.  The fundamentals you learn will guide you into building your own more complex animations and animated transitions.  First download the starter project to save time in setting everything up.

In the starter project we have a tableview that will show some images and a detail view that allows zooming.  Our goal is to build an animator that does not flood our viewcontrollers with code and is reusable.  That will animate the image from the tableview to the detail imageview in a smooth way.  Open the project and go to the top menu File->New->File.  Choose CocoaTouch

 

Make it a subclass of NSObject and name it Animator

 

Now just to make sure you understand the project run it one time and select an image.  Obviously right now it is just a modal presentation.  Our Goal is to have it look like the image pops out of the current cell and glides into the detail imageview.  After you finish the project you can go back an add some springs for some bounce. Since iOS 7 Apple has provided UIViewControllerAnimatedTransitioning to help us accomplish these transitions.  I suggest you follow along with the tutorial and the documentation in tandem.  Open the new Animator file.  To make our code self contained we will need to make it conform to the protocol of UIViewControllerAnimatedTransitioning and UIViewControllerAnimatedTransitioningDelegate.  Your file will now look like this.

 


import UIKit

class Animator: NSObject,UIViewControllerTransitioningDelegate, UIViewControllerAnimatedTransitioning {
    
}

Immediately, Xcode is giving us a warning that we do not have the required functions to conform to the protocol but looking through the documentation we only have to implement 4 methods.  The first one we are going to implement is transition​Duration(using:​).  

 


func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return self.duration
    }

Now you may notice that I am returning a local variable so let’s declare that in our file at the top along with another variable we will use to tailor the animation for when the destination view is being presented or dismissed.


var duration = 0.5
fileprivate var isPresenting = true

The next function we are going to implement is animate​Transition(using:​). Right now we are just going to declare it empty because this function is where the actual animation will occur.  It will be empty and look like this.


    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
    
    }

Xcode is still complaining at us and that is because we have satisfied UIViewControllerAnimatedTransitioning but not UIViewControllerAnimatedTransitioningDelegate.  The two functions we have to adopt are func animation​Controller(for​Presented:​ UIView​Controller, presenting:​ UIView​Controller, source:​ UIView​Controller) and func animation​Controller(for​Dismissed:​ UIView​Controller).  Implementing these two methods our file now looks like this.

 


import UIKit

class Animator: NSObject,UIViewControllerTransitioningDelegate, UIViewControllerAnimatedTransitioning {
    
    var duration = 0.5
    fileprivate var isPresenting = true
    
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        //heavy work to be done
    }
    
    
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return self.duration
    }
    
    
    
    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        isPresenting = false
        return self
    }
    
    
    func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        isPresenting = true
        return self
    }
    
}

So we just added the animationController forPresented and dismissed.  We are returning self and we are also changing our fileprivate var isPresenting.  This will come in handy when we tailor the animation for presenting or dismissing.  Let’s create our first animation.  Go to the animateTransition function and under the comment //heavy work to be done we are going to write our first bit of code that is going to get us somewhere.


func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        //heavy work to be done
        guard let toViewController = transitionContext.viewController(forKey: .to) else{return}
        guard let fromViewController = transitionContext.viewController(forKey: .from) else{return}
        let container = transitionContext.containerView
        
        if isPresenting{
            
        }else{
            
        }
    }

What does this mean?  Pretty simply the transitionContext will give us all the information that we need.  You can see I am able to get the toViewController,fromViewController, and the container.  We will use these and create a simple transition to test that everything is working.  One thing to know is that you need to add the toViewController view or you will get a black screen.   The language from the documents states “All animations must take place in the view specified by the container​View property of transition​Context. Add the view being presented (or revealed if the transition involves dismissing a view controller) to the container view’s hierarchy and set up any animations you want to make that view move into position.”

So now putting this to code we are going to make the view slide in from the top just to know that it is working and that we are not just seeing a simple modal presentation.  Using UIView animations we are just  going to set the transform of the toViewController.view off the screen and animate it in and do the reverse when we animate it out.


func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        //heavy work to be done
        guard let toViewController = transitionContext.viewController(forKey: .to) else{return}
        guard let fromViewController = transitionContext.viewController(forKey: .from) else{return}
        let container = transitionContext.containerView
        
        if isPresenting{
            toViewController.view.frame = transitionContext.finalFrame(for: toViewController)
            container.addSubview(toViewController.view)
            //preset
            toViewController.view.transform = CGAffineTransform(translationX: 0, y: -toViewController.view.bounds.height)
            //bring to the front
            container.bringSubview(toFront: toViewController.view)
            //now just back to identity
            UIView.animate(withDuration: self.duration, animations: { 
                toViewController.view.transform = .identity
            }, completion: { (finished) in
                transitionContext.completeTransition(true)
            })
            
        }else{ 
            toViewController.view.frame = transitionContext.finalFrame(for: toViewController)
            container.addSubview(toViewController.view)
            container.sendSubview(toBack: toViewController.view)
            UIView.animate(withDuration: self.duration, animations: {
                fromViewController.view.transform = CGAffineTransform(translationX: 0, y: -toViewController.view.bounds.height)
            }, completion: { (finished) in
                transitionContext.completeTransition(true)
                fromViewController.view.transform = .identity
            })
        }
    }

So just looking through the code you can see that we add the toViewController.view to the container in both isPresenting and dismissing.  In presenting we set a transform off the screen and bring the subview to the front before animating it to identity which will bring it down in this case and in dismissing we animate it back up and out of the screen on the top.  Let’s go to the ViewController file and add a few necessary things to make this work.

At the top of the ViewController file under the images array add this line


var animator = Animator()

This simply declares our animator file to be used.  Next in prepare(for segue:,sender:Any?) under where we are setting the dvc.image we need to add


dvc.transitioningDelegate = animator

Now run the project and you should see this animation.

 

Not much but at least we know we have control over the animation.  So now we know we want to animate the image from the tableview to the location in the detail so we are going to have to have two pieces of information for this to happen.  First we are going to need the frame of the imageview in the table and the frame of the imageview in the ImageDetailViewController.  The easiest way to do this in the detail view is to tag it with an integer.  Go to storyboard and give the imgView a tag of 100.

 

Now to the Animator file and add a property. This will be the imageview from the cell in the tableview. Since we tagged the detail imageView we do not need a property for that view.


var cellImageView : UIView?

In the ViewController file in didSelectRowAt set the animator cellImageView and the method will look like this.


func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        if let cell = tableView.cellForRow(at: indexPath) as? FeedTableViewCell{
            let image = cell.imgView.image
            self.performSegue(withIdentifier: "_toImageDetail", sender: image)
            animator.cellImageView = cell.imgView
        }
    }

We are passing the selected cells imageview to animator so that we will know what imageview to animate.

Now when i do these kind of animations I like to animate a copy of the view and not the actual view.  It generally is best to not alter the layout of a view that is governed by Auto Layout.  So go to File->New->Swift File and name it UIView + SnapshotExtensions.  You guessed it we are going to create a quick extension of UIView that will help us snapshot and get a copy view with a snapshot.  The code for this extension is below.


extension UIView{
    func snapshotImage() -> UIImage? {
        UIGraphicsBeginImageContextWithOptions(bounds.size, isOpaque, 0)
        guard let context = UIGraphicsGetCurrentContext() else{ return nil}
        context.translateBy(x: -bounds.origin.x, y: -bounds.origin.y)
        self.layoutIfNeeded()
        layer.render(in: UIGraphicsGetCurrentContext()!)
        let image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return image
    }
    
    func snapshotView() -> UIView? {
        if let snapshotImage = snapshotImage() {
            let v = UIImageView(image: snapshotImage)
            v.bounds = self.bounds
            v.autoresizingMask = [.flexibleWidth,.flexibleHeight]
            v.layer.masksToBounds = true
            return v
        } else {
            return nil
        }
    }
}

This is a simple extension that will allow us to snapshot a view.  Yes, I am aware that there are specific methods already for doing this but I have found that they have been unreliable in iOS 10 so I have resorted to these extensions(slow snapshot).  So now to put it all together into a beautiful animation.  Open the Animator delete your old animation code and in the animateTransition function let’s create two copies of our our cell imageview.  Why two copies? We will add one to the fromViewController.view and one to the toViewController.view.  That way we can hide both the cell imageview and the detail imageview.  This will also allow us to add an alpha animation on the both views and our imageview copy will always appear at an alpha at 1.  If this does not make sense now hopefully it will after you start the animation.


func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        //heavy work to be done
        guard let toViewController = transitionContext.viewController(forKey: .to) else{return}
        guard let fromViewController = transitionContext.viewController(forKey: .from) else{return}
        let container = transitionContext.containerView
        
        let toTargetCopy = cellImageView?.snapshotView()
        let fromTargetCopy = cellImageView?.snapshotView()
        
        if isPresenting{ 
            toViewController.view.frame = transitionContext.finalFrame(for: toViewController)
            container.addSubview(toViewController.view)
            toViewController.view.alpha = 0
            container.bringSubview(toFront: toViewController.view)
            toViewController.view.layoutIfNeeded()
            UIView.animate(withDuration: duration, animations: {
                toViewController.view.alpha = 1
                
            }, completion: { (finished) in
                transitionContext.completeTransition(true)
            })
            
        }else{
            toViewController.view.frame = transitionContext.finalFrame(for: toViewController)
            container.addSubview(toViewController.view)
            container.sendSubview(toBack: toViewController.view)
            
            UIView.animate(withDuration: duration, animations: {
                fromViewController.view.alpha = 0
                
            }, completion: { (finished) in
                transitionContext.completeTransition(true)
            })
        }
    }

So all that is going on here is we create 2 copies of the cell imgView but we are not doing anything with them.  We are then adding the toViewController’s view to the container.  In the presenting we bring it to the front and fade it in and in the dismiss we are sending the toViewController(table) to the back and fading out the fromViewController(detailImage).  We will keep these animations as our base and we will run a second animation that we will move our imageview copies.

You could run the project with this code and you should see a simple fade between the controllers.

In isPresenting block let’s start using our cell imgView copies.  In real Swift fashion or probably more objective c style we need to make sure our views are not nil because right now the animation does not need to know anything else and it will fade the views. Rather than keep you guessing i added my entire animateTransition  function.  I have added comments in the section that checks for the views to be nil and where our future animation code will go.


  func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        //heavy work to be done
        guard let toViewController = transitionContext.viewController(forKey: .to) else{return}
        guard let fromViewController = transitionContext.viewController(forKey: .from) else{return}
        let container = transitionContext.containerView
        
        let toTargetCopy = cellImageView?.snapshotView()
        let fromTargetCopy = cellImageView?.snapshotView()
        
        if isPresenting{
            toViewController.view.frame = transitionContext.finalFrame(for: toViewController)
            container.addSubview(toViewController.view)
            toViewController.view.alpha = 0
            container.bringSubview(toFront: toViewController.view)
            toViewController.view.layoutIfNeeded()

            if fromTargetCopy != nil && toTargetCopy != nil && cellImageView != nil && toViewController.view.viewWithTag(100) != nil{
                
                //detailed animation code will go here
            }
            
            //alpha animation
            UIView.animate(withDuration: duration, animations: {
                toViewController.view.alpha = 1
            }, completion: { (finished) in
                transitionContext.completeTransition(true)
            })
            
        }else{
            toViewController.view.frame = transitionContext.finalFrame(for: toViewController)
            container.addSubview(toViewController.view)
            container.sendSubview(toBack: toViewController.view)
            
            if fromTargetCopy != nil && toTargetCopy != nil && cellImageView != nil && toViewController.view.viewWithTag(100) != nil{
               //return from detail animation will go here
            }
            
            //alpha animation
            UIView.animate(withDuration: duration, animations: {
                fromViewController.view.alpha = 0
                
            }, completion: { (finished) in
                transitionContext.completeTransition(true)
            })
        }
    }

This basically checks for nil on our views that are copies.  The cool thing is if for some reason they are nil a fade transition that will still occur.  Good to know and a bit safer.  Run the project and you will still see a fade between the views.  Now to the real animation.  First we need to get the frame of the imgView inside the cell in relation to the window and we need to get the detail ImageView in relation to the window.  Inside the check we just created in isPresenting the code we need to add the code to do this.


if fromTargetCopy != nil && toTargetCopy != nil && cellImageView != nil && toViewController.view.viewWithTag(100) != nil{
                //get the window that we will use to convert the coordinates.
                let keyWindow = UIApplication.shared.keyWindow
                
                //set our view copies to start at the coordinates of the imgView in the cell
                toTargetCopy?.frame = cellImageView!.convert(cellImageView!.bounds, to: keyWindow)
                fromTargetCopy?.frame = cellImageView!.convert(cellImageView!.bounds, to: keyWindow)
                let toDetailImageView = toViewController.view.viewWithTag(100)!
                //final frame in the detail imagview
                let targetFrame = toViewController.view.viewWithTag(100)!.convert(toDetailImageView.bounds, to: keyWindow)
                
            }

Here we are converting the coordinates to the main window.  This makes animating much easier and gives us coordinates that make sense when compared across viewcontrollers.

So what do we have left to do?  Add the copies to the views of the view controllers.  We will add one to the from view and one to the to view.  We will also hide the real view from the cell imgView and the to detail imgView and we will animate to the target frame that we just found in the code above. Just below the targetFrame we will add

 


// we need to hide the real views
                cellImageView?.alpha = 0
                toDetailImageView.alpha = 0
                //perform our animation with target frame
                UIView.animate(withDuration: duration, animations: {
                    toTargetCopy?.frame = targetFrame
                    fromTargetCopy?.frame = targetFrame
                }, completion: nil)

Our isPresenting part is mostly finished other than some clean up.  In the animation that is handling the alpha we need to do some clean up in the completion like removing our copies.  Otherwise they will persist and that is not good.  We need to remove our copies and make the real view visible with an alpha of 1.  In the completion block of the alpha animation add


                toViewController.view.viewWithTag(100)?.alpha = 1
                self.cellImageView?.alpha = 1
                toTargetCopy?.removeFromSuperview()
                fromTargetCopy?.removeFromSuperview()

Above the line transitionContext.completeTransition(true). 

Now we need to finish our isPresenting == false part and for that we need to think in reverse. Everything is mostly the same except our starting frame is the imageview frame in the detail viewcontroller and our target frame is the cell imgView.  We will convert them to the window coordinate system like before.  Inside the check for nil in the else statement add


if fromTargetCopy != nil && toTargetCopy != nil && cellImageView != nil{
                let keyWindow = UIApplication.shared.keyWindow
                
                let detailImageView = fromViewController.view.viewWithTag(100)!
                toTargetCopy?.frame = detailImageView.convert(detailImageView.bounds, to: keyWindow)
                fromTargetCopy?.frame = detailImageView.convert(detailImageView.bounds, to: keyWindow)
                
                let targetFrame = cellImageView!.convert(cellImageView!.bounds, to: keyWindow)
                
                
                
            }

Now we need to add our copies, hide our real views, and animate like we did in isPresenting.  Below the targetFrame add the code that will hide and animate.


                //add from to the from view and the toTarget to the toViewController
                fromViewController.view.addSubview(fromTargetCopy!)
                toViewController.view.addSubview(toTargetCopy!)
                
                cellImageView?.alpha = 0
                detailImageView.alpha = 0
                
                UIView.animate(withDuration: duration, animations: {
                    toTargetCopy?.frame = targetFrame
                    fromTargetCopy?.frame = targetFrame
                }, completion: nil)

Now all we have left is the clean up in the completion of the alpha animation.  Above the transitionContext.completeTransition(true) in the completion block add the code that will unhide our real views and remove the copies.  


                toViewController.view.viewWithTag(100)?.alpha = 1
                self.cellImageView?.alpha = 1
                toTargetCopy?.removeFromSuperview()
                fromTargetCopy?.removeFromSuperview()

 

The final animator file is below.


import UIKit

class Animator: NSObject,UIViewControllerTransitioningDelegate, UIViewControllerAnimatedTransitioning {
    
    var duration = 0.5
    fileprivate var isPresenting = true
    var cellImageView : UIView?
    
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        //heavy work to be done
        guard let toViewController = transitionContext.viewController(forKey: .to) else{return}
        guard let fromViewController = transitionContext.viewController(forKey: .from) else{return}
        let container = transitionContext.containerView
        
        let toTargetCopy = cellImageView?.snapshotView()
        let fromTargetCopy = cellImageView?.snapshotView()
        
        if isPresenting{
            toViewController.view.frame = transitionContext.finalFrame(for: toViewController)
            container.addSubview(toViewController.view)
            toViewController.view.alpha = 0
            container.bringSubview(toFront: toViewController.view)
            toViewController.view.layoutIfNeeded()

            //add a copy at the right location in both views
            //set our frame based on the window
            if fromTargetCopy != nil && toTargetCopy != nil && cellImageView != nil && toViewController.view.viewWithTag(100) != nil{
                let keyWindow = UIApplication.shared.keyWindow
                toTargetCopy?.frame = cellImageView!.convert(cellImageView!.bounds, to: keyWindow)
                fromTargetCopy?.frame = cellImageView!.convert(cellImageView!.bounds, to: keyWindow)
                //add from to the from view and the toTarget to the toViewController
                fromViewController.view.addSubview(fromTargetCopy!)
                toViewController.view.addSubview(toTargetCopy!)
                
                // we need to hide the real views
                cellImageView?.alpha = 0
                let toDetailImageView = toViewController.view.viewWithTag(100)!
                toDetailImageView.alpha = 0
                //perform our animation with target frame
                let targetFrame = toViewController.view.viewWithTag(100)!.convert(toDetailImageView.bounds, to: keyWindow)
                UIView.animate(withDuration: duration, animations: { 
                    toTargetCopy?.frame = targetFrame
                    fromTargetCopy?.frame = targetFrame
                }, completion: nil)
            }
            
            
            UIView.animate(withDuration: duration, animations: {
                toViewController.view.alpha = 1
                
            }, completion: { (finished) in
                transitionContext.completeTransition(true)
                toTargetCopy?.removeFromSuperview()
                fromTargetCopy?.removeFromSuperview()
                toViewController.view.viewWithTag(100)?.alpha = 1
                self.cellImageView?.alpha = 1
            })
            
        }else{
            toViewController.view.frame = transitionContext.finalFrame(for: toViewController)
            container.addSubview(toViewController.view)
            container.sendSubview(toBack: toViewController.view)
            
            if fromTargetCopy != nil && toTargetCopy != nil && cellImageView != nil{
                let keyWindow = UIApplication.shared.keyWindow
                
                let detailImageView = fromViewController.view.viewWithTag(100)!
                toTargetCopy?.frame = detailImageView.convert(detailImageView.bounds, to: keyWindow)
                fromTargetCopy?.frame = detailImageView.convert(detailImageView.bounds, to: keyWindow)
                
                let targetFrame = cellImageView!.convert(cellImageView!.bounds, to: keyWindow)
                
                //add from to the from view and the toTarget to the toViewController
                fromViewController.view.addSubview(fromTargetCopy!)
                toViewController.view.addSubview(toTargetCopy!)
                
                cellImageView?.alpha = 0
                detailImageView.alpha = 0
                
                UIView.animate(withDuration: duration, animations: {
                    toTargetCopy?.frame = targetFrame
                    fromTargetCopy?.frame = targetFrame
                }, completion: nil)
                
            }
            
            UIView.animate(withDuration: duration, animations: {
                fromViewController.view.alpha = 0
                
            }, completion: { (finished) in
                toViewController.view.viewWithTag(100)?.alpha = 1
                self.cellImageView?.alpha = 1
                toTargetCopy?.removeFromSuperview()
                fromTargetCopy?.removeFromSuperview()
                transitionContext.completeTransition(true)
            })
        }
    }
    
    
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return self.duration
    }
    
    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        isPresenting = false
        return self
    }
    
    
    func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        isPresenting = true
        return self
    }
}

 

Run the project and you should see an animation like this.

 

The final project is on Apptillery Github. Happy coding.  I will leave the refactoring and percent driven animation up to you.  Let me know what you think.