How to Create a Navigation Transition Like the Apple News App

We at Rocket recently developed a new app for Barstool Sports which allows their users to consume its blog posts, podcasts, and videos. For this app, we decided to try to improve upon the default push/pop navigation animation by implementing a custom Apple News-like transition between the story feeds and story detail pages.

Apple News Transition

Here is the zoom transition that Apple uses within their News app:
Zoom animation

Default Push/Pop Transition

And here's an example of the default push/pop animation that we started with within the Barstool app:
Push/pop animation

The zoom transition gives the user a clearer sense of their context - where are they navigating to and where are they navigating from - by establishing hierarchy and depth. This results in a better user experience.

How Custom Navigations Work

You can create this awesome animation in Swift in 2 simple steps:

  1. Implement your custom animation within an animator object that adopts the UIViewControllerAnimatedTransitioning protocol
  • Adopt the UINavigationControllerDelegate protocol within your master controller and return your animator object within the animationControllerForOperation method

When you push or pop a view controller onto or off of the stack, the navigation controller will ask the UINavigationControllerDelegate to provide a transition animation for it to use. It does this through the animationControllerForOperation method. If that method returns an object that adopts the UIViewControllerAnimatedTransitioning protocol, then the navigation controller will use that object to perform its custom transition animation. If that method instead returns nil, then the navigation controller will use the default push/pop animation.

Implement Your Custom Animation

First, create an animator object that adopts the UIViewControllerAnimatedTransitioning protocol:

class ThumbnailZoomTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {

}

Then add a few properties to the top of the class:

private let duration: NSTimeInterval = 0.5
var operation: UINavigationControllerOperation = .Push
var thumbnailFrame = CGRect.zero

duration will determine how long the animation will last. operation will determine whether to provide the animation for a push or a pop. thumbnailFrame will reference the thumbnail of the cell that was selected from the master controller. That thumbnail will be used as the origin frame for the zoom animation.

Next, add the UIViewControllerAnimatedTransitioning methods transitionDuration() and animateTransition() to the class so that we can provide the navigation controller with our custom animation. The comments within the code below will explain what’s going on in more detail:

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

func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
    let presenting = operation == .Push
    
    // Determine which is the master view and which is the detail view that we're navigating to and from. The container view will house the views for transition animation.
    let containerView = transitionContext.containerView()
    guard let toView = transitionContext.viewForKey(UITransitionContextToViewKey) else { return }
    guard let fromView = transitionContext.viewForKey(UITransitionContextFromViewKey) else { return }
    let storyFeedView = presenting ? fromView : toView
    let storyDetailView = presenting ? toView : fromView
    
    // Determine the starting frame of the detail view for the animation. When we're presenting, the detail view will grow out of the thumbnail frame. When we're dismissing, the detail view will shrink back into that same thumbnail frame.
    var initialFrame = presenting ? thumbnailFrame : storyDetailView.frame
    let finalFrame = presenting ? storyDetailView.frame : thumbnailFrame
    
    // Resize the detail view to fit within the thumbnail's frame at the beginning of the push animation and at the end of the pop animation while maintaining it's inherent aspect ratio.
    let initialFrameAspectRatio = initialFrame.width / initialFrame.height
    let storyDetailAspectRatio = storyDetailView.frame.width / storyDetailView.frame.height
    if initialFrameAspectRatio > storyDetailAspectRatio {
        initialFrame.size = CGSize(width: initialFrame.height * storyDetailAspectRatio, height: initialFrame.height)
    }
    else {
        initialFrame.size = CGSize(width: initialFrame.width, height: initialFrame.width / storyDetailAspectRatio)
    }
    
    let finalFrameAspectRatio = finalFrame.width / finalFrame.height
    var resizedFinalFrame = finalFrame
    if finalFrameAspectRatio > storyDetailAspectRatio {
        resizedFinalFrame.size = CGSize(width: finalFrame.height * storyDetailAspectRatio, height: finalFrame.height)
    }
    else {
        resizedFinalFrame.size = CGSize(width: finalFrame.width, height: finalFrame.width / storyDetailAspectRatio)
    }
    
    // Determine how much the detail view needs to grow or shrink.
    let scaleFactor = resizedFinalFrame.width / initialFrame.width
    let growScaleFactor = presenting ? scaleFactor: 1/scaleFactor
    let shrinkScaleFactor = 1/growScaleFactor
    
    if presenting {
        // Shrink the detail view for the initial frame. The detail view will be scaled to CGAffineTransformIdentity below.
        storyDetailView.transform = CGAffineTransformMakeScale(shrinkScaleFactor, shrinkScaleFactor)
        storyDetailView.center = CGPoint(x: thumbnailFrame.midX, y: thumbnailFrame.midY)
        storyDetailView.clipsToBounds = true
    }
    
    // Set the initial state of the alpha for the master and detail views so that we can fade them in and out during the animation.
    storyDetailView.alpha = presenting ? 0 : 1
    storyFeedView.alpha = presenting ? 1 : 0
    
    // Add the view that we're transitioning to to the container view that houses the animation.
    containerView.addSubview(toView)
    containerView.bringSubviewToFront(storyDetailView)
    
    // Animate the transition.
    UIView.animateWithDuration(duration, delay: 0.0, usingSpringWithDamping: 1, initialSpringVelocity: 1.0, options: .CurveEaseInOut, animations: {
        // Fade the master and detail views in and out.
        storyDetailView.alpha = presenting ? 1 : 0
        storyFeedView.alpha = presenting ? 0 : 1
        
        if presenting {
            // Scale the master view in parallel with the detail view (which will grow to its inherent size). The translation gives the appearance that the anchor point for the zoom is the center of the thumbnail frame.
            let scale = CGAffineTransformMakeScale(growScaleFactor, growScaleFactor)
            let translate = CGAffineTransformTranslate(storyFeedView.transform, storyFeedView.frame.midX - self.thumbnailFrame.midX, storyFeedView.frame.midY - self.thumbnailFrame.midY)
            storyFeedView.transform = CGAffineTransformConcat(translate, scale)
            storyDetailView.transform = CGAffineTransformIdentity
        }
        else {
            // Return the master view to its inherent size and position and shrink the detail view.
            storyFeedView.transform = CGAffineTransformIdentity
            storyDetailView.transform = CGAffineTransformMakeScale(shrinkScaleFactor, shrinkScaleFactor)
        }
        
        // Move the detail view to the final frame position.
        storyDetailView.center = CGPoint(x: finalFrame.midX, y: finalFrame.midY)
    }) { finished in
        transitionContext.completeTransition(finished)
    }
}

Adopt the UINavigationControllerDelegate Protocol

The StoryFeed is the master controller for the Barstool app so it needs to adopt the UINavigationControllerDelegate protocol:

class StoryFeed: UITableViewController, UINavigationControllerDelegate {
	
    var thumbnailZoomTransitionAnimator: ThumbnailZoomTransitionAnimator?
    var transitionThumbnail: UIImageView?
    
    func navigationController(navigationController: UINavigationController, animationControllerForOperation operation: UINavigationControllerOperation, fromViewController fromVC: UIViewController, toViewController toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        if operation == .Push {
            // Pass the thumbnail frame to the transition animator.
            guard let transitionThumbnail = transitionThumbnail, let transitionThumbnailSuperview = transitionThumbnail.superview else { return nil }
            thumbnailZoomTransitionAnimator = ThumbnailZoomTransitionAnimator()
            thumbnailZoomTransitionAnimator?.thumbnailFrame = transitionThumbnailSuperview.convertRect(transitionThumbnail.frame, toView: nil)
        }
        thumbnailZoomTransitionAnimator?.operation = operation
        
        return thumbnailZoomTransitionAnimator
    }
}

If the navigation controller is presenting a detail view controller, then this will return a new animator object with a reference to the thumbnail frame of the cell that was selected by the user. When the navigation controller dismisses a detail controller, this will return the same animator object that was created for the presentation. This ensures that the same thumbnail frame is used for the custom animation for both the push and pop of the detail view controller.

Next, add the implementation for didSelectRowAtIndexPath below. This will update the reference to the frame of the selected thumbnail and then push the story detail view controller onto the navigation stack.

func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
    tableView.deselectRowAtIndexPath(indexPath, animated: true)
    
    let cell = tableView.cellForRowAtIndexPath(indexPath) as? FeedCell
    transitionThumbnail = cell?.thumbnailImageView
    
    guard let storyDetailController = StoryDetailController(fetchedResults: fetchedResults, selectedIndexPath: indexPath) else { return }
    navigationController?.pushViewController(storyDetailController, animated: true)
}

In order for our custom animation to be called, however, the master controller (StoryFeed) needs to be set as the navigation controller’s delegate:

override func viewDidLoad() {
    super.viewDidLoad()
    
    navigationController?.delegate = self
}

And that’s it! So simple. You’re now animating your transitions like an Apple pro.

Happy animating and stay hydrated.