Photo Transitions Just Like Apple & Facebook | In Swift

We build professional apps for Android, iOS and mobile web.
Have a look

Creating Photo Transitions

In Swift

Photos are EVERYWHERE! But especially in our apps! With the need for photo galleries in apps increasing, wouldn't it be nice if we could create some great animations and transitions just like Apple and Facebook use? Well guess what folks, you can. Your time has come.

Sooooo, What are we doing?

We are going to be creating a photo gallery app, that allows users to see a collection of photos, then view them individually. But more importantly, what we're going to go through in depth is how to create intuitive transitions between these two screens.

Currently in iOS, the default animation when presenting a view controller is a basic slide up animation, as can be seen in the first example below. What we're going to be making is a more natural, intuitive "image growing" transition, where by the image selected grows to fill the screen, then shrinks back when dismissed. This can be seen second example below.

[Vanila Transition], [Our Transition]
Default TransitionOur Transition

Getting Started

To get started, download the source code from our github repo here. You'll notice after opening it up that there are two different projects.
Starting Project: Contains the gallery and image viewer with no custom transitions.
Final Project: Contains the final implementation with the super cool image transition.

In this blog, we're going to be going through how to turn the simple Start Project into the final app, using a custom transition.

Step 1: Writing An Animation Controller

The heart of how this transition works is in the use of the protocol UIViewControllerAnimatedTransitioning. This protocol has two required methods.

func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval {  
    return speed
}

func animateTransition(transitionContext: UIViewControllerContextTransitioning){  
    // do animation work
}

The transitionDuration method should return how long the animation should take.
The animateTransition method is where the actual transition effect takes place.

To keep things clean and simple, we will create a whole new class to house these two methods. Create a file in the Transition Animation group and name it AnimationController.swift. Then, replace all of its contents with the following.

import UIKit

class AnimationController: NSObject, UIViewControllerAnimatedTransitioning {

    // MARK: UIViewControllerAnimatedTransitioning

    // 1: Set animation speed
    func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval {
        return 1
    }

    func animateTransition(transitionContext: UIViewControllerContextTransitioning) {

        // 2: Get view controllers involved
        guard let containerView = transitionContext.containerView(),
            let fromVC = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey),
            let toVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey)
            else {
                return
        }

        // 3: Set the destination view controllers frame
        toVC.view.frame = fromVC.view.frame

        // 4: Create transition image view
        let imageView = UIImageView(image: nil)
        imageView.contentMode = .ScaleAspectFill
        imageView.frame = CGRectMake(0, 0, 0, 0)
        imageView.clipsToBounds = true
        containerView.addSubview(imageView)

        // 5: Create from screen snapshot
        let fromSnapshot = fromVC.view.snapshotViewAfterScreenUpdates(true)
        fromSnapshot.frame = fromVC.view.frame
        containerView.addSubview(fromSnapshot)

        // 6: Create to screen snapshot
        let toSnapshot = toVC.view.snapshotViewAfterScreenUpdates(true)
        toSnapshot.frame = fromVC.view.frame
        containerView.addSubview(toSnapshot)
        toSnapshot.alpha = 0

        // 7: Bring the image view to the front and get the final frame
        containerView.bringSubviewToFront(imageView)
        let toFrame = CGRectMake(0, 0, 0, 0)

        // 8: Animate change
        UIView.animateWithDuration(transitionDuration(transitionContext), delay: 0, usingSpringWithDamping: 0.85, initialSpringVelocity: 0.8, options: .CurveEaseOut, animations: {
            toSnapshot.alpha = 1
            imageView.frame = toFrame

        }, completion:{ [weak self] (finished) in

            // 9: Remove transition views
            imageView.removeFromSuperview()
            fromSnapshot.removeFromSuperview()
            toSnapshot.removeFromSuperview()

            // 10: Complete transition
            if !transitionContext.transitionWasCancelled() {
                containerView.addSubview(toVC.view)
            }
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled())
        })
    }
}

Wow, so there's quite a bit of stuff here so let's go through it.

  1. The transitionDuration method has been implemented to return a constant of 1 second for our animation speed. This is a bit slow for the real world, but will be useful while debugging.
  2. Our animateTransition method begins by grabbing some information from the transitionContex parameter. The first one, containerView will hold all of the views involved in our animation. We also grab the two view controllers involved in the transition, in order to use their views.
  3. Here, we are setting the frame of the view we are transitioning too, to make sure it is the same as the current views frame.
  4. Now the interesting stuff. We are creating the image view that will eventually hold the image user selects. This imageView will then be moved in our animation block to it's position on the next screen.
  5. We now create snapshot views of the fromVC.view. This lets us manipulate its view in the animation without worrying about weather we are effecting the actual view.
  6. Same as 5, except for the toVC.view
  7. It's important to ensure the imageView appears above all other views, in order for the transition to work correctly. This is what is being done here.
  8. Animation Time! Here we perform the transition animation, fading in the new view while also making some frame changes. (We'll come back to the imageView.frame later)
  9. Finally, we conclude the animation be removing the views we used for it an calling the appropriate completion call.

Step 2: Adding The Delegate

Now that we have the backbone of our AnimationController setup, it's time to connect it to our view controllers. To do this, open up the GalleryViewController.swift file and add the following properties to the top of the file.

private var animationController: AnimationController = AnimationController()  
var hideSelectedCell: Bool = false  

The animationController property houses the implementation of our transition animation
The hideSelectedCell property is an interesting one, which we will come back to later.

Next, we need a way to tell iOS that our animationController object is responsible for handling our transitions. To do this, we need to implement the UIViewControllerTransitioningDelegate protocol, which lets us tell iOS what objects to use for a transitions to and from a given view controller. As this is a fairly basic app, we're going to make the GalleryViewController implement this protocol. Add the following to the bottom of the GalleryViewController.swift file.

// 1: Conforming to protocol
extension GalleryViewController: UIViewControllerTransitioningDelegate {

    // 2: presentation controller
    func animationControllerForPresentedController(presented: UIViewController,
                                                   presentingController presenting: UIViewController,
                                                                        sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return animationController
    }

    // 3: dismissing controller
    func animationControllerForDismissedController(dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return animationController
    }
}
  1. The first thing we do here is make sure the GalleryViewController class conforms to the UIViewControllerTransitioningDelegate protocol.
  2. The first method implemented here tells the transition what object to use for its presentation animation.
  3. The second method tells the transition what object to use for the dismiss animation.

Now that we've implemented the protocol, the important step now is to actually set the GalleryViewController as the transition delegate. Because it is the PhotoViewController that is being presented and dismissed, we must set the transitioningDelegate property of this view controller. To do this, add the following line to to prepareForSegue method.

destination.transitioningDelegate = self  

Your transition is now all connected! Build and run your app and you will see a noticeable difference.

Somewhat Different Transition
Default Transition

Still not really the effect we were after, but not to worry. We'll fix that up next!

Step 3: Getting Things Talking

You may have noticed that in our previous work in the AnimationController, the imageView which we were using in our transition animation, had an unusual frame set to it.

imageView.frame = CGRectMake(0, 0, 0, 0)  

This was just a placeholder value, as we in fact don't know the frame position of our imageView. On top of this, we also don't know what our image is! It's time to get things talking! To do this, we're going to enable our view controllers to talk to our AnimationController via delegation, while we're also going to enable the passing of some initial data at animation setup time.

To start with, add the following code to the bottom of your AnimationController.swift file

protocol ImageTransitionProtocol {  
    func tranisitionSetup()
    func tranisitionCleanup()
    func imageWindowFrame() -> CGRect
}

This protocol is what we're going to get our two view controllers to implement!

The tranisitionSetup() will be called by the animation controller before the snapshots for the animation are taken. This enables the view controllers to make changes that will only appear in the animation.
The tranisitionCleanup() method will be called after the transition is complete. This lets view controllers undo any changes they may have made purely for the transition.
The imageWindowFrame() method will return the frame of the selected image in window coordinates. For the Gallery Screen, this will be the image cell's window frame, while on the photo viewer screen, this will be the frame of the full screen image.

We'll come back to conforming to this protocol in our View Controllers in just a bit. For now, staying in the AnimationController class, add the following method and properties to the top of the class.

private var image: UIImage?  
private var fromDelegate: ImageTransitionProtocol?  
private var toDelegate: ImageTransitionProtocol?

// MARK: Setup Methods

func setupImageTransition(image image: UIImage, fromDelegate: ImageTransitionProtocol, toDelegate: ImageTransitionProtocol){  
    self.image = image
    self.fromDelegate = fromDelegate
    self.toDelegate = toDelegate
}

The properties we've just added will be used to store both the image that is being used in the transition, as well as the objects that implement the ImageTransitionProtocol protocol for each of the screens. The method added, setupImageTransition, is simply used to set these properties.

Now that this is all setup, we can update the code in our animateTransition method to use the correct image, as well as the corret start and end image coordinates. To do this, make the following replacements.

In Section 4... Replace this let imageView = UIImageView(image: nil)
With this let imageView = UIImageView(image: image)

In Section 4... Replace this imageView.frame = CGRectMake(0, 0, 0, 0)
With this imageView.frame = (fromDelegate == nil) ? CGRectMake(0, 0, 0, 0) : fromDelegate!.imageWindowFrame()

In Section 7... Replace this let toFrame = CGRectMake(0, 0, 0, 0)
With this let toFrame = (self.toDelegate == nil) ? CGRectMake(0, 0, 0, 0) : self.toDelegate!.imageWindowFrame()

These changes should be fairly straightforward. We are simply setting the correct image, as well as the correct window coordinates, assuming the delegates are set.
The final thing to add to our animateTransition method are the delegate calls to the methods tranisitionSetup and tranisitionCleanup.

Add the following right before Section 5 in our animateTransition method, right before we take our first snapshot.

fromDelegate!.tranisitionSetup()  
toDelegate!.tranisitionSetup()  

Lastly, add the following cleanup calls to the start of our animation completion block.

self?.toDelegate!.tranisitionCleanup()  
self?.fromDelegate!.tranisitionCleanup()  

Great work! Our AnimationController is now completely finished! Now there is just one last step, which we'll get to next.

Step 4: Talking Back

Now that we have created a way for our AnimationController to talk to our view controllers, the last step is to implement the protocol we created, and pass the delegate objects onto the AnimationController via the setup method we just wrote.

To start with, we're going to implement the ImageTransitionProtocol in our view controllers. Jump over to GalleryViewController.swift and add the following to the bottom of the file.

// MARK: ImageTransitionProtocol

extension GalleryViewController: ImageTransitionProtocol {

    // 1: hide selected cell for tranisition snapshot
    func tranisitionSetup(){
        hideSelectedCell = true
        collectionView.reloadData()
    }

    // 2: unhide selected cell after tranisition snapshot is taken
    func tranisitionCleanup(){
        hideSelectedCell = false
        collectionView.reloadData()
    }

    // 3: return window frame of selected image
    func imageWindowFrame() -> CGRect{
        let indexPath = NSIndexPath(forRow: selectedIndex!, inSection: 0)
        let attributes = collectionView.layoutAttributesForItemAtIndexPath(indexPath)
        let cellRect = attributes!.frame
        return collectionView.convertRect(cellRect, toView: nil)
    }
}

The first thing we are doing here is conforming to our ImageTransitionProtocol. To do this, we implement the three required methods.
1. In here, we are setting that boolean property we added before, hideSelectedCell, to true, then reloading our collection view. The purpose of this will be to hide the selected cell while the transition is being performed, to create the effect that the image is actually moving, not that a copy of the image is.
2. In here, we undo the changes we made in the setup, to ensure that our image is visible again for when we return to the screen.
3. Finally, we return the window coordinate of the selected cell. This refers to where the cell is located, relative to the window, not its superview.

To get our selectedCell actually being hidden when our Bool property is set, make the following replacement in the method cellForItemAtIndexPath.

Replace cell.imageView.image = photo.image
With cell.imageView.image = (indexPath.row == selectedIndex && hideSelectedCell) ? nil : photo.image

Now our GalleryViewController completely conforms to our protocol! Next step is to jump over to the PhotoViewController.swift file and add the following to the bottom of the file.

// MARK: ImageTransitionProtocol

extension PhotoViewController: ImageTransitionProtocol {

    // 1: hide scroll view containing images
    func tranisitionSetup(){
        let photo = allPhotos[currentPhotoIndex]
        titleLabel.text = photo.title
        scrollView.hidden = true
    }

    // 2; unhide images and set correct image to be showing
    func tranisitionCleanup(){
        scrollView.hidden = false
        let xOffset = CGFloat(currentPhotoIndex) * scrollView.frame.size.width
        scrollView.contentOffset = CGPointMake(xOffset, 0)
    }

    // 3: return the imageView window frame
    func imageWindowFrame() -> CGRect{

        let photo = allPhotos[currentPhotoIndex]
        let scrollWindowFrame = scrollView.superview!.convertRect(scrollView.frame, toView: nil)

        let scrollViewRatio = scrollView.frame.size.width / scrollView.frame.size.height
        let imageRatio = photo.image.size.width / photo.image.size.height
        let touchesSides = (imageRatio > scrollViewRatio)

        if touchesSides {
            let height = scrollWindowFrame.size.width / imageRatio
            let yPoint = scrollWindowFrame.origin.y + (scrollWindowFrame.size.height - height) / 2
            return CGRectMake(scrollWindowFrame.origin.x, yPoint, scrollWindowFrame.size.width, height)
        } else {
            let width = scrollWindowFrame.size.height * imageRatio
            let xPoint = scrollWindowFrame.origin.x + (scrollWindowFrame.size.width - width) / 2
            return CGRectMake(xPoint, scrollWindowFrame.origin.y, width, scrollWindowFrame.size.height)
        }
    }
}

This should all look familiar, just like the GalleryVierController, we are conforming to the protocol and doing some basic setup and cleanup.

1. Here, we are making sure our ViewController has the correct title, but is hiding the scrollview containing images.
2. To cleanup, we are undoing the hiding of the imageView, while also ensuring the correct image is being displayed by adjusting the content offset.
3. The final method, imageWindowFrame, is working out the exact frame of the image view, then returning it.

With this now done there is ONE FINAL STEP! (I promise, this is it). We just need to call the setupImageTransition method we implemented before. Jump back to our GalleryViewController and add the following to the UIViewControllerTransitioningDelegate extension methods.

In the method animationControllerForPresentedController, add...

let photoViewController = presented as! PhotoViewController  
animationController.setupImageTransition( image: allPhotos[selectedIndex!].image,  
                                                  fromDelegate: self,
                                                  toDelegate: photoViewController)

In the method animationControllerForDismissedController, add...

let photoViewController = dismissed as! PhotoViewController  
animationController.setupImageTransition( image: allPhotos[selectedIndex!].image,  
                                                  fromDelegate: photoViewController,
                                                  toDelegate: self)

That's it folks! We're all done! This final step passes the correct image to the AnimationController before it begins, as well as passing delegate objects the controller can use to get the images position and run setup/cleanup code. Build and run and you should see the following effect.

We've now implemented our protocol in both view controllers and we've passed the appropriate objects to our animationController via our setup methods. Build and run and see the magic happen.

Our Transition

Conclusion

Well, you made it to the end! 🎉 I hope this tutorial was helpful. There's a million ways to enhance and improve what we've built here, but hopefully this acted as good introduction as to how to create interesting transition effects.
Download the final project here.