The Power of Custom Transitions
Standard UIKit transitions like push, pop, present, and dismiss are functional, but they can often feel generic. Custom view controller transitions open up a world of creative possibilities, allowing you to design animations that perfectly complement your app's brand and user experience. Whether you're building a unique onboarding flow, a custom photo viewer, or a game with distinct scene changes, custom transitions can make your app stand out.
Why Custom Transitions Matter:
- Enhanced User Experience: Smooth, intuitive animations guide users and make interactions feel more natural and responsive.
- Brand Identity: Imbue your app with a unique visual language, reinforcing your brand's aesthetics.
- Storytelling: Transitions can tell a story, leading the user's eye and highlighting important elements as they navigate your app.
- Performance: While standard transitions are optimized, custom transitions give you granular control, allowing you to fine-tune performance for specific use cases.
UIKit provides a robust framework for managing these transitions, primarily relying on protocols that you conform to. This article will focus on non-interactive custom transitions first, then touch upon interactive dismissal.
Understanding the Core Protocols
At the heart of custom view controller transitions are three key protocols:
UIViewControllerAnimatedTransitioning: This protocol defines the animator object responsible for performing the actual animation. It specifies how long the animation should last and how to animate the views during the transition.
UIViewControllerTransitioningDelegate: When presenting a view controller modally, this delegate is responsible for providing the animator objects for presentation, dismissal, and optionally, interaction controllers. It acts as the central coordinator.
UINavigationControllerDelegate: For transitions within a UINavigationController (push/pop), this delegate provides the animator object. It's the navigation controller's counterpart to UIViewControllerTransitioningDelegate.
Let's break down UIViewControllerAnimatedTransitioning first, as it's fundamental to both modal and navigation-based transitions. An object conforming to this protocol is often referred to as an "animator object" or "transition animator."
Creating a Custom Transition Animator
Your custom animator object will conform to UIViewControllerAnimatedTransitioning. This object will be responsible for orchestrating the visual changes that occur during your transition. Let's create a simple "fade in" effect for a presentation.
First, define a custom animator class. This class will implement two required methods:
transitionDuration(using:): Returns the total duration of your animation.
animateTransition(using:): This is where the magic happens. You'll manipulate view hierarchies and animate changes within this method.
import UIKit
class FadeAnimator: NSObject, UIViewControllerAnimatedTransitioning {
let isPresenting: Bool
init(isPresenting: Bool) {
self.isPresenting = isPresenting
super.init()
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.7 // Our animation will last 0.7 seconds
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
// 1. Get references to the view controllers and their views involved in the transition
let containerView = transitionContext.containerView
guard let fromVC = transitionContext.viewController(forKey: .from),
let toVC = transitionContext.viewController(forKey: .to) else { return }
let fromView = fromVC.view!
let toView = toVC.view!
// 2. Add the destination view to the container view
// For presentation, 'toView' will appear; for dismissal, 'fromView' will disappear.
if isPresenting {
containerView.addSubview(toView)
toView.alpha = 0.0 // Start invisible
} else {
// For dismissal, 'toView' (the presenting view controller's view) is already in container
// 'fromView' (the presented view controller's view) will be removed
}
// Get the final frame for 'toView' (important for iOS 13+ where views might not start at full size)
let finalFrameForToView = transitionContext.finalFrame(for: toVC)
toView.frame = finalFrameForToView // Ensure 'toView' has its final position
// 3. Perform the UIView animation
UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: {
if self.isPresenting {
toView.alpha = 1.0 // Fade in
} else {
fromView.alpha = 0.0 // Fade out
}
}) { _ in
// 4. Complete the transition! VERY IMPORTANT!
// This tells UIKit that your animation is done and it can clean up.
// If you don't call this, your app will be stuck.
let didComplete = !transitionContext.transitionWasCancelled
if !didComplete && self.isPresenting { // If cancelled during presentation, remove toView
toView.removeFromSuperview()
}
transitionContext.completeTransition(didComplete)
}
}
}
Compatibility Notes: This pattern is robust across all modern iOS versions (iOS 9+ up to the latest). Pay close attention to transitionContext.finalFrame(for:) for toVC, especially for iOS 13+ and modal presentations, as the view's initial frame might not always be what you expect without explicit setup.
import UIKit
class FadeAnimator: NSObject, UIViewControllerAnimatedTransitioning {
let isPresenting: Bool
init(isPresenting: Bool) {
self.isPresenting = isPresenting
super.init()
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.7 // Our animation will last 0.7 seconds
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
// 1. Get references to the view controllers and their views involved in the transition
let containerView = transitionContext.containerView
guard let fromVC = transitionContext.viewController(forKey: .from),
let toVC = transitionContext.viewController(forKey: .to) else { return }
let fromView = fromVC.view!
let toView = toVC.view!
// 2. Add the destination view to the container view
// For presentation, 'toView' will appear; for dismissal, 'fromView' will disappear.
if isPresenting {
containerView.addSubview(toView)
toView.alpha = 0.0 // Start invisible
} else {
// For dismissal, 'toView' (the presenting view controller's view) is already in container
// 'fromView' (the presented view controller's view) will be removed
}
// Get the final frame for 'toView' (important for iOS 13+ where views might not start at full size)
let finalFrameForToView = transitionContext.finalFrame(for: toVC)
toView.frame = finalFrameForToView // Ensure 'toView' has its final position
// 3. Perform the UIView animation
UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: {
if self.isPresenting {
toView.alpha = 1.0 // Fade in
} else {
fromView.alpha = 0.0 // Fade out
}
}) { _ in
// 4. Complete the transition! VERY IMPORTANT!
// This tells UIKit that your animation is done and it can clean up.
// If you don't call this, your app will be stuck.
let didComplete = !transitionContext.transitionWasCancelled
if !didComplete && self.isPresenting { // If cancelled during presentation, remove toView
toView.removeFromSuperview()
}
transitionContext.completeTransition(didComplete)
}
}
}
Implementing a Custom Modal Presentation Transition
Now that we have our FadeAnimator, let's integrate it into a modal presentation. For modal presentations, you'll use UIViewControllerTransitioningDelegate. This delegate is set on the presented view controller.
Let's assume you have FirstViewController (presenting) and SecondViewController (to be presented).
import UIKit
// FirstViewController.swift
class FirstViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBlue
let presentButton = UIButton(type: .system)
presentButton.setTitle("Present Second VC", for: .normal)
presentButton.setTitleColor(.white, for: .normal)
presentButton.addTarget(self, action: #selector(presentSecondVC), for: .touchUpInside)
presentButton.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(presentButton)
NSLayoutConstraint.activate([
presentButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
presentButton.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
@objc func presentSecondVC() {
let secondVC = SecondViewController()
// Set the modal presentation style to .custom
// This tells UIKit to look for a custom transitioning delegate.
secondVC.modalPresentationStyle = .custom
// Assign self (or another object) as the transitioning delegate.
// It is CRUCIAL to hold a strong reference to the delegate.
// If not, it can be deallocated before the transition starts.
secondVC.transitioningDelegate = self
present(secondVC, animated: true, completion: nil)
}
}
// Conform FirstViewController to UIViewControllerTransitioningDelegate
extension FirstViewController: UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return FadeAnimator(isPresenting: true)
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return FadeAnimator(isPresenting: false)
}
}
// SecondViewController.swift
class SecondViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemRed
let dismissButton = UIButton(type: .system)
dismissButton.setTitle("Dismiss", for: .normal)
dismissButton.setTitleColor(.white, for: .normal)
dismissButton.addTarget(self, action: #selector(dismissVC), for: .touchUpInside)
dismissButton.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(dismissButton)
NSLayoutConstraint.activate([
dismissButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
dismissButton.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
@objc func dismissVC() {
dismiss(animated: true, completion: nil)
}
}
Important Considerations:
modalPresentationStyle = .custom: This is essential. Without it, UIKit will use its default modal presentation style and ignore your transitioningDelegate.
- Strong Reference to Delegate: The
transitioningDelegate property is weak. If you set it to a local object, it will be immediately deallocated. In the example above, FirstViewController sets itself as the delegate for secondVC, ensuring it has a strong reference for the duration of the transition. For more complex scenarios, you might use a dedicated delegate object and hold a strong reference to it on the presenting view controller or in your app's flow coordinator.
Compatibility Notes: This delegate pattern works consistently across all recent iOS versions.
import UIKit
// FirstViewController.swift
class FirstViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBlue
let presentButton = UIButton(type: .system)
presentButton.setTitle("Present Second VC", for: .normal)
presentButton.setTitleColor(.white, for: .normal)
presentButton.addTarget(self, action: #selector(presentSecondVC), for: .touchUpInside)
presentButton.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(presentButton)
NSLayoutConstraint.activate([
presentButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
presentButton.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
@objc func presentSecondVC() {
let secondVC = SecondViewController()
// Set the modal presentation style to .custom
// This tells UIKit to look for a custom transitioning delegate.
secondVC.modalPresentationStyle = .custom
// Assign self (or another object) as the transitioning delegate.
// It is CRUCIAL to hold a strong reference to the delegate.
// If not, it can be deallocated before the transition starts.
secondVC.transitioningDelegate = self
present(secondVC, animated: true, completion: nil)
}
}
// Conform FirstViewController to UIViewControllerTransitioningDelegate
extension FirstViewController: UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return FadeAnimator(isPresenting: true)
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return FadeAnimator(isPresenting: false)
}
}
// SecondViewController.swift
class SecondViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemRed
let dismissButton = UIButton(type: .system)
dismissButton.setTitle("Dismiss", for: .normal)
dismissButton.setTitleColor(.white, for: .normal)
dismissButton.addTarget(self, action: #selector(dismissVC), for: .touchUpInside)
dismissButton.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(dismissButton)
NSLayoutConstraint.activate([
dismissButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
dismissButton.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
@objc func dismissVC() {
dismiss(animated: true, completion: nil)
}
}
Custom Navigation Controller Transitions
For UINavigationController push and pop operations, you'll use UINavigationControllerDelegate. The navigation controller itself will typically be its own delegate or you can set a separate object as its delegate.
import UIKit
// Assuming FadeAnimator is defined as in the previous section
class RootNavigationController: UINavigationController {
override func viewDidLoad() {
super.viewDidLoad()
self.delegate = self // Set self as the navigation controller's delegate
// Set initial view controller
let firstVC = FirstNavViewController()
setViewControllers([firstVC], animated: false)
}
}
extension RootNavigationController: UINavigationControllerDelegate {
// This method is called when a push or pop transition is about to occur.
func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
switch operation {
case .push:
return FadeAnimator(isPresenting: true) // Use our fade animator for push
case .pop:
return FadeAnimator(isPresenting: false) // Use our fade animator for pop
case .none:
return nil // No custom animation needed for other operations
@unknown default:
return nil
}
}
}
// FirstNavViewController.swift - A simple VC to push another
class FirstNavViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemCyan
title = "First Nav VC"
let pushButton = UIButton(type: .system)
pushButton.setTitle("Push Second Nav VC", for: .normal)
pushButton.setTitleColor(.white, for: .normal)
pushButton.addTarget(self, action: #selector(pushSecondNavVC), for: .touchUpInside)
pushButton.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(pushButton)
NSLayoutConstraint.activate([
pushButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
pushButton.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
@objc func pushSecondNavVC() {
let secondVC = SecondNavViewController()
navigationController?.pushViewController(secondVC, animated: true)
}
}
// SecondNavViewController.swift - A simple VC to be pushed
class SecondNavViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemPink
title = "Second Nav VC"
let popButton = UIButton(type: .system)
popButton.setTitle("Pop", for: .normal)
popButton.setTitleColor(.white, for: .normal)
popButton.addTarget(self, action: #selector(popVC), for: .touchUpInside)
popButton.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(popButton)
NSLayoutConstraint.activate([
popButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
popButton.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
@objc func popVC() {
navigationController?.popViewController(animated: true)
}
}
Key Difference: Unlike modal presentations where transitioningDelegate is set on the presented UIViewController, UINavigationControllerDelegate is set on the UINavigationController itself. This single delegate handles all push and pop operations for that navigation stack.
Compatibility Notes: This pattern has been stable and effective since iOS 7.
import UIKit
// Assuming FadeAnimator is defined as in the previous section
class RootNavigationController: UINavigationController {
override func viewDidLoad() {
super.viewDidLoad()
self.delegate = self // Set self as the navigation controller's delegate
// Set initial view controller
let firstVC = FirstNavViewController()
setViewControllers([firstVC], animated: false)
}
}
extension RootNavigationController: UINavigationControllerDelegate {
// This method is called when a push or pop transition is about to occur.
func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
switch operation {
case .push:
return FadeAnimator(isPresenting: true) // Use our fade animator for push
case .pop:
return FadeAnimator(isPresenting: false) // Use our fade animator for pop
case .none:
return nil // No custom animation needed for other operations
@unknown default:
return nil
}
}
}
// FirstNavViewController.swift - A simple VC to push another
class FirstNavViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemCyan
title = "First Nav VC"
let pushButton = UIButton(type: .system)
pushButton.setTitle("Push Second Nav VC", for: .normal)
pushButton.setTitleColor(.white, for: .normal)
pushButton.addTarget(self, action: #selector(pushSecondNavVC), for: .touchUpInside)
pushButton.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(pushButton)
NSLayoutConstraint.activate([
pushButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
pushButton.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
@objc func pushSecondNavVC() {
let secondVC = SecondNavViewController()
navigationController?.pushViewController(secondVC, animated: true)
}
}
// SecondNavViewController.swift - A simple VC to be pushed
class SecondNavViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemPink
title = "Second Nav VC"
let popButton = UIButton(type: .system)
popButton.setTitle("Pop", for: .normal)
popButton.setTitleColor(.white, for: .normal)
popButton.addTarget(self, action: #selector(popVC), for: .touchUpInside)
popButton.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(popButton)
NSLayoutConstraint.activate([
popButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
popButton.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
@objc func popVC() {
navigationController?.popViewController(animated: true)
}
}
Beyond Basics: Interactive Transitions
While non-interactive transitions are great for many scenarios, interactive transitions allow users to control the animation, such as swiping to dismiss a view controller. This involves another protocol: UIViewControllerInteractiveTransitioning.
You typically combine an UIPercentDrivenInteractiveTransition with a gesture recognizer (UIScreenEdgePanGestureRecognizer for dismissal from an edge, or UIPanGestureRecognizer for full-screen dragging). The UIViewControllerTransitioningDelegate or UINavigationControllerDelegate then provides an interactionControllerForPresentation or interactionControllerForDismissal based on whether an interactive gesture is active.
Implementing interactive transitions can be more complex, requiring careful management of gesture states (.began, .changed, .ended, .cancelled) to trigger update(_:), finish(), or cancel() on your UIPercentDrivenInteractiveTransition instance. When transitionWasCancelled is true in your animator, your cleanup logic needs to account for this.
For a deeper dive into interactive transitions, consider exploring tutorials specifically focused on UIPercentDrivenInteractiveTransition and gesture handling, as integrating them robustly requires more boilerplate code than can be covered in a basic introduction.
Tips for Great Custom Transitions
Creating compelling custom transitions goes beyond just making things move. Here are some pro tips:
- Keep it brief: Animations should be noticeable but not annoy the user. Aim for 0.3 to 0.7 seconds for most transitions.
- Maintain Context: Animate similar elements together. For example, if an image on screen A expands to become the main image on screen B, animate that transformation.
- Performance: Avoid heavy computations or image processing during animation. UIKit's
UIView.animate is highly optimized. If you need more control or complex physics, explore UIDynamicAnimator or CADisplayLink.
- Interruption Handling: Consider how your animation behaves if the user taps furiously or triggers another interaction. Ensure
completeTransition(_:) is always called.
- Reusability: Design your animator objects to be reusable, perhaps by passing configuration options (like animation direction or specific views to animate) during initialization.