Custom UIViewController Transitions: Getting Started
This tutorial will teach you to create custom UIViewController transitions for presenting and dismissing, and how to make them interactive! By Richard Critz.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Custom UIViewController Transitions: Getting Started
25 mins
Wiring Up the Animator
UIKit expects a transitioning delegate to vend the animation controller for a transition. To do this, you must first provide an object which conforms to UIViewControllerTransitioningDelegate
. In this example, CardViewController
will act as the transitioning delegate.
Open CardViewController.swift and add the following extension at the bottom of the file.
extension CardViewController: UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController,
presenting: UIViewController,
source: UIViewController)
-> UIViewControllerAnimatedTransitioning? {
return FlipPresentAnimationController(originFrame: cardView.frame)
}
}
Here you return an instance of your custom animation controller, initialized with the frame of the current card.
The final step is to mark CardViewController
as the transitioning delegate. View controllers have a transitioningDelegate
property, which UIKit will query to see if it should use a custom transition.
Add the following to the end of prepare(for:sender:)
just below the card assignment:
destinationViewController.transitioningDelegate = self
It’s important to note that it is the view controller being presented that is asked for a transitioning delegate, not the view controller doing the presenting!
Build and run your project. Tap on a card and you should see the following:
And there you have it — your first custom transition!
Dismissing the View Controller
You have a great presentation transition but that’s only half the job. You’re still using the default dismissal transition. Time to fix that!
From the menu, select File\New\File…, choose iOS\Source\Cocoa Touch Class, and click Next. Name the file FlipDismissAnimationController, make it a subclass of NSObject and set the language to Swift. Click Next and set the Group to Animation Controllers. Click Create.
Replace the class definition with the following.
class FlipDismissAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
private let destinationFrame: CGRect
init(destinationFrame: CGRect) {
self.destinationFrame = destinationFrame
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.6
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
}
}
This animation controller’s job is to reverse the presenting animation so that the UI feels symmetric. To do this it must:
- Shrink the displayed view to the size of the card;
destinationFrame
holds this value. - Flip the view around to reveal the original card.
Add the following lines to animateTransition(using:)
.
// 1
guard let fromVC = transitionContext.viewController(forKey: .from),
let toVC = transitionContext.viewController(forKey: .to),
let snapshot = fromVC.view.snapshotView(afterScreenUpdates: false)
else {
return
}
snapshot.layer.cornerRadius = CardViewController.cardCornerRadius
snapshot.layer.masksToBounds = true
// 2
let containerView = transitionContext.containerView
containerView.insertSubview(toVC.view, at: 0)
containerView.addSubview(snapshot)
fromVC.view.isHidden = true
// 3
AnimationHelper.perspectiveTransform(for: containerView)
toVC.view.layer.transform = AnimationHelper.yRotation(-.pi / 2)
let duration = transitionDuration(using: transitionContext)
This should all look familiar. Here are the important differences:
- This time it’s the “from” view you must manipulate so you take a snapshot of that.
- Again, the ordering of layers is important. From back to front, they must be in the order: “to” view, “from” view, snapshot view. While it may not seem important in this particular transition, it is vital in others, particularly if the transition can be cancelled.
- Rotate the “to” view to be edge-on so that it isn’t immediately revealed when you rotate the snapshot.
All that’s needed now is the actual animation itself. Add the following code to the end of animateTransition(using:)
.
UIView.animateKeyframes(
withDuration: duration,
delay: 0,
options: .calculationModeCubic,
animations: {
// 1
UIView.addKeyframe(withRelativeStartTime: 0.0, relativeDuration: 1/3) {
snapshot.frame = self.destinationFrame
}
UIView.addKeyframe(withRelativeStartTime: 1/3, relativeDuration: 1/3) {
snapshot.layer.transform = AnimationHelper.yRotation(.pi / 2)
}
UIView.addKeyframe(withRelativeStartTime: 2/3, relativeDuration: 1/3) {
toVC.view.layer.transform = AnimationHelper.yRotation(0.0)
}
},
// 2
completion: { _ in
fromVC.view.isHidden = false
snapshot.removeFromSuperview()
if transitionContext.transitionWasCancelled {
toVC.view.removeFromSuperview()
}
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
This is exactly the inverse of the presenting animation.
- First, scale the snapshot view down, then hide it by rotating it 90˚. Next, reveal the “to” view by rotating it back from its edge-on position.
- Clean up your changes to the view hierarchy by removing the snapshot and restoring the state of the “from” view. If the transition was cancelled — it isn’t yet possible for this transition, but you will make it possible shortly — it’s important to remove everything you added to the view hierarchy before declaring the transition complete.
Remember that it’s up to the transitioning delegate to vend this animation controller when the pet picture is dismissed. Open CardViewController.swift and add the following method to the UIViewControllerTransitioningDelegate
extension.
func animationController(forDismissed dismissed: UIViewController)
-> UIViewControllerAnimatedTransitioning? {
guard let _ = dismissed as? RevealViewController else {
return nil
}
return FlipDismissAnimationController(destinationFrame: cardView.frame)
}
This ensures that the view controller being dismissed is of the expected type and then creates the animation controller giving it the correct frame for the card it will reveal.
It’s no longer necessary to have the presentation animation run slowly. Open FlipPresentAnimationController.swift and change the duration from 2.0
to 0.6
so that it matches your new dismissal animation.
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.6
}
Build and run. Play with the app to see your fancy new animated transitions.
Making It Interactive
Your custom animations look really sharp. But, you can improve your app even further by adding user interaction to the dismissal transition. The Settings app in iOS has a great example of an interactive transition animation:
Your task in this section is to navigate back to the card’s face-down state with a swipe from the left edge of the screen. The progress of the transition will follow the user’s finger.
How Interactive Transitions Work
An interaction controller responds either to touch events or programmatic input by speeding up, slowing down, or even reversing the progress of a transition. In order to enable interactive transitions, the transitioning delegate must provide an interaction controller. This can be any object that implements UIViewControllerInteractiveTransitioning
.
You’ve already made the transition animation. The interaction controller will manage this animation in response to gestures rather than letting it play like a video. Apple provides the ready-made UIPercentDrivenInteractiveTransition
class, which is a concrete interaction controller implementation. You’ll use this class to make your transition interactive.
From the menu, select File\New\File…, choose iOS\Source\Cocoa Touch Class, and click Next. Name the file SwipeInteractionController, make it a subclass of UIPercentDrivenInteractiveTransition and set the language to Swift. Click Next and set the Group to Interaction Controllers. Click Create.
Add the following to the class.
var interactionInProgress = false
private var shouldCompleteTransition = false
private weak var viewController: UIViewController!
init(viewController: UIViewController) {
super.init()
self.viewController = viewController
prepareGestureRecognizer(in: viewController.view)
}
These declarations are fairly straightforward.
-
interactionInProgress
, as the name suggests, indicates whether an interaction is already happening. -
shouldCompleteTransition
will be used internally to control the transition. You’ll see how shortly. -
viewController
is a reference to the view controller to which this interaction controller is attached.
Next, set up the gesture recognizer by adding the following method to the class.
private func prepareGestureRecognizer(in view: UIView) {
let gesture = UIScreenEdgePanGestureRecognizer(target: self,
action: #selector(handleGesture(_:)))
gesture.edges = .left
view.addGestureRecognizer(gesture)
}
The gesture recognizer is configured to trigger when the user swipes from the left edge of the screen and is added to the view.
The final piece of the interaction controller is handleGesture(_:)
. Add that to the class now.
@objc func handleGesture(_ gestureRecognizer: UIScreenEdgePanGestureRecognizer) {
// 1
let translation = gestureRecognizer.translation(in: gestureRecognizer.view!.superview!)
var progress = (translation.x / 200)
progress = CGFloat(fminf(fmaxf(Float(progress), 0.0), 1.0))
switch gestureRecognizer.state {
// 2
case .began:
interactionInProgress = true
viewController.dismiss(animated: true, completion: nil)
// 3
case .changed:
shouldCompleteTransition = progress > 0.5
update(progress)
// 4
case .cancelled:
interactionInProgress = false
cancel()
// 5
case .ended:
interactionInProgress = false
if shouldCompleteTransition {
finish()
} else {
cancel()
}
default:
break
}
}
Here’s the play-by-play:
- You start by declaring local variables to track the progress of the swipe. You fetch the
translation
in the view and calculate theprogress
. A swipe of 200 or more points will be considered enough to complete the transition. - When the gesture starts, you set
interactionInProgress
totrue
and trigger the dismissal of the view controller. - While the gesture is moving, you continuously call
update(_:)
. This is a method onUIPercentDrivenInteractiveTransition
which moves the transition along by the percentage amount you pass in. - If the gesture is cancelled, you update
interactionInProgress
and roll back the transition. - Once the gesture has ended, you use the current progress of the transition to decide whether to
cancel()
it orfinish()
it for the user.
Now, you must add the plumbing to actually create your SwipeInteractionController
. Open RevealViewController.swift and add the following property.
var swipeInteractionController: SwipeInteractionController?
Next, add the following to the end of viewDidLoad()
.
swipeInteractionController = SwipeInteractionController(viewController: self)
When the picture view of the pet card is displayed, an interaction controller is created and connected to it.
Open FlipDismissAnimationController.swift and add the following property after the declaration for destinationFrame
.
let interactionController: SwipeInteractionController?
Replace init(destinationFrame:)
with:
init(destinationFrame: CGRect, interactionController: SwipeInteractionController?) {
self.destinationFrame = destinationFrame
self.interactionController = interactionController
}
The animation controller needs a reference to the interaction controller so it can partner with it.
Open CardViewController.swift and replace animationController(forDismissed:)
with:
func animationController(forDismissed dismissed: UIViewController)
-> UIViewControllerAnimatedTransitioning? {
guard let revealVC = dismissed as? RevealViewController else {
return nil
}
return FlipDismissAnimationController(destinationFrame: cardView.frame,
interactionController: revealVC.swipeInteractionController)
}
This simply updates the creation of FlipDismissAnimationController
to match the new initializer.
Finally, UIKit queries the transitioning delegate for an interaction controller by calling interactionControllerForDismissal(using:)
. Add the following method at the end of the UIViewControllerTransitioningDelegate
extension.
func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning)
-> UIViewControllerInteractiveTransitioning? {
guard let animator = animator as? FlipDismissAnimationController,
let interactionController = animator.interactionController,
interactionController.interactionInProgress
else {
return nil
}
return interactionController
}
This checks first that the animation controller involved is a FlipDismissAnimationController
. If so, it gets a reference to the associated interaction controller and verifies that a user interaction is in progress. If any of these conditions are not met, it returns nil
so that the transition will proceed without interactivity. Otherwise, it hands the interaction controller back to UIKit so that it can manage the transition.
Build and run. Tap a card, then swipe from the left edge of the screen to see the final result.
Congratulations! You’ve created a interesting and engaging interactive transition!