Implementing a custom UINavigationController transition

The view-controller transition technique that you just explored is very nice when you want to create a custom modal presentation. However, if you want to customize transitions in UINavigationController or UITabBarController that persist throughout your app, you need to implement the transitions in a slightly different way.

Let's take a look at how the setup for animating push transitions for UINavigationController differs from the setup that is used for the transition you saw earlier:

The depicted flow is one for an interactive transition. It's very similar to the way a normal view-controller transition works, except that for this type of transition, UINavigationControllerDelegate is the object that provides the UIViewControllerAnimatedTransitioning and UIViewControllerInteractiveTransitioning objects that are used to perform transitions between views as they are pushed onto the navigation stack and when they are popped off.

Because the delegate that is responsible for the transitions is set on the navigation controller instead of on a displayed view controller, every push and pop that is performed by the navigation controller that has a custom delegate will use the same custom transition. This can come in handy when you want to have consistent behavior throughout your app without manually assigning transitioning delegates all of the time.

To see how a custom navigation controller transition can be implemented, you will create a custom transition that zooms in on a contact from the contact overview page. When a user taps a contact, the contact's detail page will expand and grow from the contact's picture until the detail page covers the entire window, like it's supposed to. Pressing the Back button will shrink the view back down onto the tapped contact's image. Swiping from the left edge of the screen will interactively shrink the view, using the same animation that is triggered by tapping the back button.

To implement this custom transition, you will implement three classes. A NavigationDelegate class will implement the UINavigationController delegate and it will contain the UIPercentDrivenInteractiveTransition object to manage the interactive transition to go back to the overview page. The other two classes are the animator classes; they both implement the UIViewControllerAnimatedTransitioning protocol. One is responsible for the hide transition; the other will handle the show transition. Create three files and name them NavigationDelegate, ContactDetailShowAnimator, and ContactDetailHideAnimator. All three should have NSObject as their superclass.

Let's begin by implementing ContactDetailShowAnimator. The first thing you should do with this class is added conformance to the UIViewControllerAnimatedTransitioning protocol by adding an extension in ContactDetailShowAnimator.swift. Just like you did for the regular view-controller transition, you have to implement two methods: one that returns the transition duration and one that performs the animation. Add the following implementation to your extension:

extension ContactDetailShowAnimator: UIViewControllerAnimatedTransitioning {
  func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
    return 0.3
  }

  func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
    // 1
    guard let toViewController = transitionContext.viewController(forKey: .to),
      let fromViewController = transitionContext.viewController(forKey: .from),
      let overviewViewController = fromViewController as? ViewController,
      let tappedIndex = overviewViewController.collectionView.indexPathsForSelectedItems?.first,
      let tappedCell = overviewViewController.collectionView.cellForItem(at: tappedIndex) as? ContactCollectionViewCell
      else { return }

    // 2
    let contactImageFrame = tappedCell.contactImage.frame
    let startFrame = overviewViewController.view.convert(contactImageFrame, from: tappedCell)

    toViewController.view.frame = startFrame
    toViewController.view.layer.cornerRadius = startFrame.height / 2

    transitionContext.containerView.addSubview(toViewController.view)

    let animationTiming = UICubicTimingParameters(animationCurve: .easeInOut)

    let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), timingParameters: animationTiming)

    animator.addAnimations {
      toViewController.view.frame = transitionContext.finalFrame(for: toViewController)
      toViewController.view.layer.cornerRadius = 0
    }

    animator.addCompletion { finished in
      transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
    }

    animator.startAnimation()
  }
}

The first step in the preceding snippet shows how you can extract information about the tapped cell by casting fromViewController to an instance of ViewController, which is the page that contains an overview of all the contacts. This view controller's collection view is asked for for the selected index path, which is then used to determine the cell that the user has tapped on. All of the work that's done in this first part of the code deals with Optionals, which means that the values might not be present according to the compiler. Even though under normal conditions none of these operations should return nil, they are safely unwrapped and accessed using a guard statement.

Then, the detail view controller's initial frame is set up. To determine this frame, the frame for contactImage in sourceCell is extracted. Then, this frame is converted to the coordinates of overviewViewController. If you don't do this, the position of the frame will typically be off by about 64 points. That's because the collection view has a content inset of 64 so it can extend beneath the navigation bar. Converting to the proper coordinate space ensures that this won't be a problem for you.

After converting the image's frame, it's used as the starting frame for the target view. The target also gets rounded corners to aid the zooming-in effect. The animation is set up to remove the rounded corners and to adjust the frame to the planned end frame so the detail page covers the screen.

The next step is to implement the back transition. This transition is nearly identical to the "show" transition. Open the ContactDetailHideAnimator.swift file and add an extension to make ContactDetailHideAnimator conform to UIViewControllerAnimatedTransitioning. After adding the delegate, you should be able to implement transitionDuration(using:) on your own. Make sure it returns a duration of 0.3 seconds.

The following snippet contains the code you need to implement the back animation. Try implementing this on your own first; you can deduct all the code you need from the show animation and the custom modal hide transition you built:

func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
if let currentAnimator = self.animator {
return currentAnimator
}

guard let toViewController = transitionContext.viewController(forKey: .to),
let fromViewController = transitionContext.viewController(forKey: .from),
let overviewViewController = toViewController as? ViewController,
let tappedIndex = overviewViewController.collectionView.indexPathsForSelectedItems?.first,
let tappedCell = overviewViewController.collectionView.cellForItem(at: tappedIndex) as? ContactCollectionViewCell
else { return UIViewPropertyAnimator() }

transitionContext.containerView.addSubview(toViewController.view)
transitionContext.containerView.addSubview(fromViewController.view)

let animationTiming = UICubicTimingParameters(animationCurve: .easeInOut)

let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), timingParameters: animationTiming)

animator.addAnimations {
let imageFrame = tappedCell.contactImage.frame

let targetFrame = overviewViewController.view.convert(imageFrame, from: tappedCell)

fromViewController.view.frame = targetFrame
fromViewController.view.layer.cornerRadius = tappedCell.contactImage.frame.height / 2
}

animator.addCompletion { finished in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}

self.animator = animator

return animator
}

func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let animator = interruptibleAnimator(using: transitionContext)
animator.startAnimation()
}

The animations are now fully implemented. The last object you need to implement is UINavigationControllerDelegate. As discussed before, this delegate is responsible for providing animations and managing interactive back gestures. First, you will implement the basics for your navigation delegate. Add the following code to the NavigationDelegate class:

let navigationController: UINavigationController   
var interactionController: UIPercentDrivenInteractiveTransition?   

init(withNavigationController navigationController: UINavigationController) {   
    self.navigationController = navigationController   

    super.init()   

    let panRecognizer = UIPanGestureRecognizer(target: self,   
                         action: #selector(handlePan(gestureRecognizer:)))   
    navigationController.view.addGestureRecognizer(panRecognizer)   
} 

The initializer for NavigationDelegate takes a navigation controller as an argument. This immediately associates a navigation controller with the navigation delegate instance. UIPanGestureRecognizer is added to the view of the navigation controller directly. This gesture recognizer will drive the interactive transition. The next step is to implement the handler for the pan gesture-recognizer:

@objc func handlePan(gestureRecognizer: UIPanGestureRecognizer) {
  guard let view = self.navigationController.view
    else { return }

  switch gestureRecognizer.state {
  case .began:
    let location = gestureRecognizer.location(in: view)
    if location.x < view.bounds.midX &&
      navigationController.viewControllers.count > 1 {

      interactionController = UIPercentDrivenInteractiveTransition()
      navigationController.popViewController(animated: true)
    }
    break
  case .changed:
    let panTranslation = gestureRecognizer.translation(in: view)
    let animationProgress = fabs(panTranslation.x / view.bounds.width)
    interactionController?.update(animationProgress)
    break
  default:
    if gestureRecognizer.velocity(in: view).x > 0 {
      interactionController?.finish()
    } else {
      interactionController?.cancel()
    }

    interactionController = nil
  }
}

This method is very similar to the one you saw before for the regular view-controller transition. The major difference here is that you create UIPercentDrivenInteractiveTransition when the gesture begins. The percentage-driven interactive transition is then destroyed when the gesture ends.

Add the following extension to NavigationDelegate to make it conform to UINavigationControllerDelegate:

extension NavigationDelegate: UINavigationControllerDelegate {
  func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
    if operation == .pop {
      return ContactDetailHideAnimator()
    } else {
      return ContactDetailShowAnimator()
    }
  }

  func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
    return interactionController
  }
}

These two methods are responsible for providing the required objects for the animations. Previously, we had one method that got called whenever a view controller was shown, and one when it was dismissed. UINavigationControllerDelegate has only one method for this. You can check whether the navigation controller is pushing or popping a view controller and, based on that, you can return a different animator.

The final step is to connect the animators to ViewController. Declare the following variable in ViewController.swift:

var navigationDelegate: NavigationDelegate?

Next, add the following lines of code to viewDidLoad():

if let navigationController = self.navigationController {  
    navigationDelegate = NavigationDelegate(withNavigationController: navigationController)  
    navigationController.delegate = navigationDelegate  
}

If you run the app now, you can see your custom transition in action. However, there's still one problem. You'll notice that the drawer you added to the contact detail view controller earlier is visible outside the boundaries of the detail view controller. Also, the rounded-corner effect you added to the show and hide animations doesn't seem to be working yet. Add the following line of code to the viewDidLoad() method in ContactDetailViewController:

view.clipsToBounds = true

That's it! You have successfully implemented an interactive transition for a navigation controller. Build and run your app, tap on a cell, and see the freshly-created zoom-in and -out effect in action. Also, try swiping from the left edge of the screen to go back to the overview page. Pretty awesome, right?

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset