iOS UIViewController Transition Essentials
Complete Guide to UIViewController Pull-Down Dismissal / Pull-Up Presentation / Full-Page Right-Swipe Back Effects
Introduction

I have always been curious about how popular apps like Facebook, Line, Spotify, and others implement effects such as “presented UIViewController can be dismissed by pulling down,” “UIViewController fades in with an upward swipe,” and “full-screen support for interactive right-swipe back gestures.”
Because these effects are not built-in, the pull-down-to-close feature only has system card style support starting from iOS 13.
The Journey of Exploration
I’m not sure if it’s because I don’t know the right keywords or the information itself is hard to find, but I’ve been unable to locate practical implementations of this feature. The materials I found are vague and scattered, so I can only piece them together bit by bit.
At first, when I was exploring on my own, I found the UIPresentationController API and, without digging deeper, used it along with UIPanGestureRecognizer in a very rough way to achieve the pull-down-to-close effect. I always felt something was off and that there must be a better approach.
Until recently, when working on a new project, I read the author’s article and broadened my perspective to discover other APIs that offer more elegant and flexible solutions.
This article is partly for self-recording and partly to help friends who share the same confusion as me.
The content is a bit long; if you find it troublesome, you can scroll down to see the examples or directly download the GitHub project to study!
iOS 13 Card Style Presentation Page
First, let’s talk about the built-in effect in the latest system
In iOS ≥ 13, the default modalPresentationStyle of UIViewController.present(_:animated:completion:) is UIModalPresentationAutomatic, which presents the page in a card style. If you want to keep the previous full-screen presentation, you need to explicitly set it back to UIModalPresentationFullScreen.

Built-in Calendar Add Effect
How to Disable Pull-Down to Dismiss? Dismiss Confirmation?
A better user experience should check for any input data when triggering a pull-down to close. If there is input, the user should be prompted to confirm if they want to discard changes and leave.
Apple has already taken care of this part for us; we just need to implement the methods in UIAdaptivePresentationControllerDelegate.
import UIKit
class DetailViewController: UIViewController {
private var onEdit:Bool = true;
override func viewDidLoad() {
super.viewDidLoad()
// Set delegate
self.presentationController?.delegate = self
// if UIViewController is embedded in navigationController:
// self.navigationController?.presentationController?.delegate = self
// Disable pull-down to dismiss method (1):
self.isModalInPresentation = true;
}
}
// Delegate implementation
extension DetailViewController: UIAdaptivePresentationControllerDelegate {
// Disable pull-down to dismiss method (2):
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
return false;
}
// Pull-down gesture triggered when dismissal is cancelled
func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
if (onEdit) {
let alert = UIAlertController(title: "Data not saved yet", message: nil, preferredStyle: .actionSheet)
alert.addAction(UIAlertAction(title: "Discard and Leave", style: .default) { _ in
self.dismiss(animated: true)
})
alert.addAction(UIAlertAction(title: "Continue Editing", style: .cancel, handler: nil))
self.present(alert, animated: true)
} else {
self.dismiss(animated: true, completion: nil)
}
}
}
To disable pull-down dismissal, you can either set the UIViewController variable isModalInPresentation to false or implement the UIAdaptivePresentationControllerDelegate method presentationControllerShouldDismiss and return true. Either approach works.
The UIAdaptivePresentationControllerDelegate presentationControllerDidAttemptToDismiss method is only called when a dismissal is canceled by pulling down.
By the way…
A card-style presented page is considered a Sheet by the system, which behaves differently from FullScreen.
Assuming today
RootViewControllerisHomeViewController
In card-style presentation (UIModalPresentationAutomatic):
When
HomeViewControllerPresentsDetailViewController…
HomeViewControllerdoes not triggerviewWillDisappear/viewDidDisappear.
When
DetailViewControllerisDismiss…
HomeViewControllerdoes not triggerviewWillAppear/viewDidAppear.
⚠️ Since XCODE 11, iOS ≥ 13 apps use card style (UIModalPresentationAutomatic) by default for Present
If you previously placed some logic in viewWillAppear/viewWillDisappear/viewDidAppear/viewDidDisappear, be sure to carefully check and pay attention! ⚠️
After reviewing the system’s built-in features, let’s get to the main part! How to create these effects yourself?
Where Can Transition Animations Be Done?
First, let’s organize where window transition animations can be applied.

UITabBarController/UIViewController/UINavigationController
When Switching UITabBarController
We can set the delegate of UITabBarController and implement the animationControllerForTransitionFrom method to apply custom transition effects when switching between UITabBarController tabs.
The system default has no animation. The image above shows a crossfade transition effect.
import UIKit
class MainTabBarViewController: UITabBarController {
override func viewDidLoad() {
super.viewDidLoad()
self.delegate = self
}
}
extension MainTabBarViewController: UITabBarControllerDelegate {
func tabBarController(_ tabBarController: UITabBarController, animationControllerForTransitionFrom fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
//return UIViewControllerAnimatedTransitioning
}
}
When UIViewController Presents/Dismisses
Naturally, when presenting or dismissing a UIViewController, you can specify the animation effect; otherwise, this article wouldn’t exist XD. However, it’s worth mentioning that if you only need a simple present animation without gesture control, you can directly use UIPresentationController for convenience and speed (see references at the end).
The system default is swipe up to present and swipe down to dismiss! When customizing, you can add fade-in, rounded corners, control the appearance position, and more.
import UIKit
class HomeAddViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
self.modalPresentationStyle = .custom
self.transitioningDelegate = self
}
}
extension HomeAddViewController: UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
// Return nil to use the default animation
return // UIViewControllerAnimatedTransitioning animation to apply when presenting
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
// Return nil to use the default animation
return // UIViewControllerAnimatedTransitioning animation to apply when dismissing
}
}
Any
UIViewControllercan implementtransitioningDelegateto specifyPresent/Dismissanimations;UITabBarViewController,UINavigationController,UITableViewController, and others are all supported
When UINavigationController Push/Pop Occurs
UINavigationController is probably the one that rarely needs custom animations, because the system’s default swipe-left to push and swipe-right to pop animations are already the best. If you want to customize this part, it might be used to create seamless left-right UIViewController switching effects.
Since we want full-page gesture back support, we need to implement a custom POP animation, so we must create our own back animation effect.
import UIKit
class HomeNavigationController: UINavigationController {
override func viewDidLoad() {
super.viewDidLoad()
self.delegate = self
}
}
extension HomeNavigationController: UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if operation == .pop {
return // UIViewControllerAnimatedTransitioning animation to apply when popping
} else if operation == .push {
return // UIViewControllerAnimatedTransitioning animation to apply when pushing
}
// Return nil to use the default animation
return nil
}
}
Interactive vs Non-Interactive Animations?
Before discussing animation implementation and gesture control, let’s first explain what interactive and non-interactive mean.
Interactive Animation: Animations triggered by gestures, such as UIPanGestureRecognizer
Non-interactive Animation: System-triggered animations, such as self.present()
How to Implement Animation Effects?
After explaining where it can be done, let’s now look at how to create the animation effects.
We need to implement the UIViewControllerAnimatedTransitioning protocol and perform animations on the window within it.
Basic Transition Animation: UIView.animate
Using UIView.animate directly for animation, the UIViewControllerAnimatedTransitioning must implement transitionDuration to specify the animation duration and animateTransition to define the animation content.
import UIKit
class SlideFromLeftToRightTransition: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.4
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
// Available parameters:
// Get the view of the destination UIViewController to present:
let toView = transitionContext.view(forKey: .to)
// Get the destination UIViewController to present:
let toViewController = transitionContext.viewController(forKey: .to)
// Get the initial frame of the destination UIViewController's view:
let toInitalFrame = transitionContext.initialFrame(for: toViewController!)
// Get the final frame of the destination UIViewController's view:
let toFinalFrame = transitionContext.finalFrame(for: toViewController!)
// Get the current UIViewController's view:
let fromView = transitionContext.view(forKey: .from)
// Get the current UIViewController:
let fromViewController = transitionContext.viewController(forKey: .from)
// Get the initial frame of the current UIViewController's view:
let fromInitalFrame = transitionContext.initialFrame(for: fromViewController!)
// Get the final frame of the current UIViewController's view: (can get the final frame from the previous display animation when closing)
let fromFinalFrame = transitionContext.finalFrame(for: fromViewController!)
//toView.frame.origin.y = UIScreen.main.bounds.size.height
UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveLinear], animations: {
//toView.frame.origin.y = 0
}) { (_) in
if (!transitionContext.transitionWasCancelled) {
// Animation was not cancelled
}
// Notify the system that the animation is complete
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
}
}
To and From:
Assuming today
HomeViewControllerneeds toPresent/PushDetailViewController,
From = HomeViewController / To = DetailViewController
When
DetailViewControllerneeds toDismiss/Pop,
From = DetailViewController / To = HomeViewController
⚠️⚠️⚠️⚠️⚠️
The official recommendation is to get the view from
transitionContext.viewinstead of using .view fromtransitionContext.viewController.
But here is an issue when doing Present/Dismiss animations with
modalPresentationStyle = .custom;
Using
transitionContext.view(forKey: .from)during Present will be nil,
Using
transitionContext.view(forKey: .to)during Dismiss will also be nil ;
Still need to get values from viewController.view.
⚠️⚠️⚠️⚠️⚠️
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)Must be called when the animation finishes, otherwise the screen will freeze ;
However, since
UIView.animatewill not callcompletionif no animation is performed, the aforementioned method may not be called; therefore, ensure the animation actually runs (e.g., y from 100 to 0).
ℹ️ℹ️ℹ️ℹ️ℹ️
If the
ToView/FromViewinvolved in the animation is complex or causes issues during the animation, you can usesnapshotView(afterScreenUpdates:)to create a snapshot for the animation. First, take a snapshot and add it to the layer withtransitionContext.containerView.addSubview(snapShotView). Then hide the originalToView/FromView (isHidden = true). After the animation ends, remove thesnapShotViewwithsnapShotView.removeFromSuperview()and restore the visibility of the originalToView/FromView (isHidden = false).
Interruptible and Resumable Transition Animation: UIViewPropertyAnimator
You can also use the new animation class introduced in iOS ≥ 10 to implement animation effects.
Choose based on your preference or the level of detail needed for the animation.
Although the official recommendation is to use UIViewPropertyAnimator for interactive animations, in most cases, whether interactive or not (gesture-controlled), UIView.animate is sufficient.
UIViewPropertyAnimator supports pausing and continuing animations, but I’m not sure where this is practically applied. Interested readers can refer to this article.
import UIKit
class FadeInFadeOutTransition: NSObject, UIViewControllerAnimatedTransitioning {
private var animatorForCurrentTransition: UIViewImplicitlyAnimating?
func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
// Return the current animator if a transition animation is ongoing
if let animatorForCurrentTransition = animatorForCurrentTransition {
return animatorForCurrentTransition
}
// Parameters as described earlier
//fromView.frame.origin.y = 100
let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), curve: .linear)
animator.addAnimations {
//fromView.frame.origin.y = 0
}
animator.addCompletion { (position) in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
// Retain the animator
self.animatorForCurrentTransition = animator
return animator
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.4
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
// For non-interactive transitions, use the interactive animation as well
let animator = self.interruptibleAnimator(using: transitionContext)
animator.startAnimation()
}
func animationEnded(_ transitionCompleted: Bool) {
// Clear the animator after the animation completes
self.animatorForCurrentTransition = nil
}
}
In interactive cases (explained later), animations use the
interruptibleAnimatormethod; non-interactive cases still use theanimateTransitionmethod.
Because
interruptibleAnimatorcan be called multiple times due to the ability to continue and interrupt, we need to store and return it using a global variable.
Murmur…
Actually, I originally wanted to switch everything to the new UIViewPropertyAnimator and recommend everyone to use it, but I encountered a strange issue. When doing a full-page gesture pop animation, if the gesture is released and the animation resets, the navigation bar items at the top flicker with a fade in and out… I couldn’t find a solution. However, using UIView.animate does not have this problem. If I missed anything, feel free to let me know <( _ _ )>.

Problem image; + button is the previous page’s
So to be safe, let’s stick with the old method!
In practice, create separate classes for different animation effects. If you find the files too scattered, you can refer to the bundled solution at the end of the article; or group the related (Present + Dismiss) animations together.
transitionCoordinator
If you need finer control, such as a specific component inside the ViewController changing along with the transition animation, you can use transitionCoordinator in UIViewController to coordinate. I didn’t use this part; if interested, refer to this article.
How to Control Animations?
This is the “interactive” part mentioned earlier, which means gesture control in practice. This is the most important section of the article because what we need to achieve is the linkage between gesture operations and transition animations to realize the pull-down close and full-page back functions.
Delegate Setup:
Similar to the previous ViewController delegate animation design, the interactive handler class also needs to inform the ViewController through the delegate.
UITabBarController: None
UINavigationController (Push/Pop):
import UIKit
class HomeNavigationController: UINavigationController {
override func viewDidLoad() {
super.viewDidLoad()
self.delegate = self
}
}
extension HomeNavigationController: UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if operation == .pop {
return // UIViewControllerAnimatedTransitioning animation to apply on pop
} else if operation == .push {
return // UIViewControllerAnimatedTransitioning animation to apply on push
}
// Return nil to use the default animation
return nil
}
// Add interactive delegate method:
func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
// Here we can't tell if it's Pop or Push, so we must judge by the animation itself
if animationController is push animation {
return // UIPercentDrivenInteractiveTransition for interactive push animation
} else if animationController is pop animation {
return // UIPercentDrivenInteractiveTransition for interactive pop animation
}
// Return nil to disable interactive handling
return nil
}
}
UIViewController (Present/Dismiss):
import UIKit
class HomeAddViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
self.modalPresentationStyle = .custom
self.transitioningDelegate = self
}
}
extension HomeAddViewController: UIViewControllerTransitioningDelegate {
func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
// return nil means no interactive handling
return // UIPercentDrivenInteractiveTransition interactive control method for Dismiss
}
func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
// return nil means no interactive handling
return // UIPercentDrivenInteractiveTransition interactive control method for Present
}
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
// returning nil uses the default animation
return // UIViewControllerAnimatedTransitioning animation to apply when presenting
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
// returning nil uses the default animation
return // UIViewControllerAnimatedTransitioning animation to apply when dismissing
}
}
⚠️⚠️⚠️⚠️⚠️
If you implement methods like interactionControllerFor…, these methods will be called even for non-interactive animations (e.g., system-triggered transitions like self.present); what we need to control is the
wantsInteractiveStartparameter inside (explained below).
Animation Interaction Handler Class UIPercentDrivenInteractiveTransition:
Next, let’s talk about the core implementation of UIPercentDrivenInteractiveTransition.
import UIKit
class PullToDismissInteractive: UIPercentDrivenInteractiveTransition {
// The UIView to add gesture control for interaction
private var interactiveView: UIView!
// The current UIViewController
private var presented: UIViewController!
// Threshold percentage to complete the transition; otherwise revert
private let thredhold: CGFloat = 0.4
// Different transitions may need different info, customizable
convenience init(_ presented: UIViewController, _ interactiveView: UIView) {
self.init()
self.interactiveView = interactiveView
self.presented = presented
setupPanGesture()
// Default value, tells the system this is not an interactive animation yet
wantsInteractiveStart = false
}
private func setupPanGesture() {
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
panGesture.maximumNumberOfTouches = 1
panGesture.delegate = self
interactiveView.addGestureRecognizer(panGesture)
}
@objc func handlePan(_ sender: UIPanGestureRecognizer) {
switch sender.state {
case .began:
// Reset gesture position
sender.setTranslation(.zero, in: interactiveView)
// Inform system that the current animation is interactive and gesture-driven
wantsInteractiveStart = true
// Call the transition effect when gesture begins (won't execute immediately, system takes control)
// If the transition has a corresponding animation, it will jump to UIViewControllerAnimatedTransitioning
// animated must be true, otherwise no animation
// Dismiss:
self.presented.dismiss(animated: true, completion: nil)
// Present:
//self.present(presenting,animated: true)
// Push:
//self.navigationController.push(presenting)
// Pop:
//self.navigationController.pop(animated: true)
case .changed:
// Calculate gesture movement corresponding to animation completion percentage 0~1
// Calculation varies depending on animation type
let translation = sender.translation(in: interactiveView)
guard translation.y >= 0 else {
sender.setTranslation(.zero, in: interactiveView)
return
}
let percentage = abs(translation.y / interactiveView.bounds.height)
// Update UIViewControllerAnimatedTransitioning animation percentage
update(percentage)
case .ended:
// When gesture ends, check if completion exceeds threshold
wantsInteractiveStart = false
if percentComplete >= thredhold {
// Yes, inform animation to finish
finish()
} else {
// No, inform animation to revert
cancel()
}
case .cancelled, .failed:
// On cancel or failure
wantsInteractiveStart = false
cancel()
default:
wantsInteractiveStart = false
return
}
}
}
// When UIViewController contains UIScrollView components (UITableView/UICollectionView/WKWebView, etc.), prevent gesture conflicts
// Enable interactive transition gesture only when inner UIScrollView is scrolled to top
extension PullToDismissInteractive: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if let scrollView = otherGestureRecognizer.view as? UIScrollView {
if scrollView.contentOffset.y <= 0 {
return true
} else {
return false
}
}
return true
}
}
*Additional notes on why sender.setTranslation(.zero, in:interactiveView) is needed<
We need to implement different classes based on different gesture interactions; if the operations are part of the same flow (Present + Dismiss), they can be combined together.
⚠️⚠️⚠️⚠️⚠️
wantsInteractiveStartmust be set appropriately; settingwantsInteractiveStart = falseduring an interactive animation can also cause the screen to freeze;
You need to quit and reopen the app to restore it properly.
⚠️⚠️⚠️⚠️⚠️
interactiveView must also have isUserInteractionEnabled = true
You can add more settings to ensure it!
Composition
Once we have set up the Delegate here and created the Class, we can achieve the desired functionality.
Next, without further ado, let’s jump straight to the complete example.
Custom Pull-Down to Close Page Effect
The advantage of custom pull-down is that it supports all iOS versions on the market, allows control over the overlay percentage, controls the trigger position for closing, and enables customized animation effects.

Tap the + button at the top right to Present the page
This is an example of HomeViewController presenting HomeAddViewController and HomeAddViewController dismissing.
import UIKit
class HomeViewController: UIViewController {
@IBAction func addButtonTapped(_ sender: Any) {
guard let homeAddViewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(identifier: "HomeAddViewController") as? HomeAddViewController else {
return
}
// transitioningDelegate can be assigned to the target ViewController or the current ViewController
homeAddViewController.transitioningDelegate = homeAddViewController
homeAddViewController.modalPresentationStyle = .custom
self.present(homeAddViewController, animated: true, completion: nil)
}
}
import UIKit
class HomeAddViewController: UIViewController {
private var pullToDismissInteractive:PullToDismissInteractive!
override func viewDidLoad() {
super.viewDidLoad()
// Bind transition interaction info
self.pullToDismissInteractive = PullToDismissInteractive(self, self.view)
}
}
extension HomeAddViewController: UIViewControllerTransitioningDelegate {
func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
return pullToDismissInteractive
}
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return PresentAndDismissTransition(false)
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return PresentAndDismissTransition(true)
}
func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
// No gesture for Present here
return nil
}
}
import UIKit
class PullToDismissInteractive: UIPercentDrivenInteractiveTransition {
private var interactiveView: UIView!
private var presented: UIViewController!
private var completion:(() -> Void)?
private let thredhold: CGFloat = 0.4
convenience init(_ presented: UIViewController, _ interactiveView: UIView,_ completion:(() -> Void)? = nil) {
self.init()
self.interactiveView = interactiveView
self.completion = completion
self.presented = presented
setupPanGesture()
wantsInteractiveStart = false
}
private func setupPanGesture() {
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
panGesture.maximumNumberOfTouches = 1
panGesture.delegate = self
interactiveView.addGestureRecognizer(panGesture)
}
@objc func handlePan(_ sender: UIPanGestureRecognizer) {
switch sender.state {
case .began:
sender.setTranslation(.zero, in: interactiveView)
wantsInteractiveStart = true
self.presented.dismiss(animated: true, completion: self.completion)
case .changed:
let translation = sender.translation(in: interactiveView)
guard translation.y >= 0 else {
sender.setTranslation(.zero, in: interactiveView)
return
}
let percentage = abs(translation.y / interactiveView.bounds.height)
update(percentage)
case .ended:
if percentComplete >= thredhold {
finish()
} else {
wantsInteractiveStart = false
cancel()
}
case .cancelled, .failed:
wantsInteractiveStart = false
cancel()
default:
wantsInteractiveStart = false
return
}
}
}
extension PullToDismissInteractive: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if let scrollView = otherGestureRecognizer.view as? UIScrollView {
if scrollView.contentOffset.y <= 0 {
return true
} else {
return false
}
}
return true
}
}
import UIKit
// Semi-transparent overlay view covering the original view
class DimmingView:UIView {
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = UIColor.black
self.alpha = 0
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
class PresentAndDismissTransition: NSObject, UIViewControllerAnimatedTransitioning {
private var isDismiss:Bool!
convenience init(_ isDismiss:Bool) {
self.init()
self.isDismiss = isDismiss
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.4
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let toViewController = transitionContext.viewController(forKey: .to),let fromViewController = transitionContext.viewController(forKey: .from) else {
return
}
if !self.isDismiss {
// Present
toViewController.view.frame.size.height -= 50
toViewController.view.frame.origin.y = UIScreen.main.bounds.size.height
transitionContext.containerView.addSubview(toViewController.view)
let toViewpath = UIBezierPath(roundedRect: toViewController.view.bounds, byRoundingCorners: [.topLeft, .topRight], cornerRadii: CGSize(width: 6, height: 6))
let toViewmask = CAShapeLayer()
toViewmask.path = toViewpath.cgPath
toViewController.view.layer.mask = toViewmask
let fromViewpath = UIBezierPath(roundedRect: fromViewController.view.bounds, byRoundingCorners: [.topLeft, .topRight], cornerRadii: CGSize(width: 6, height: 6))
let fromViewmask = CAShapeLayer()
fromViewmask.path = fromViewpath.cgPath
fromViewController.view.layer.mask = fromViewmask
let dimmingView = DimmingView(frame: fromViewController.view.frame)
transitionContext.containerView.insertSubview(dimmingView, belowSubview: toViewController.view)
fromViewController.view.transform = CGAffineTransform(scaleX: 1.0, y: 1.0)
UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveEaseOut], animations: {
dimmingView.alpha = 0.7
fromViewController.view.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
toViewController.view.frame.origin.y = 50
}) { (_) in
fromViewController.view.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
} else {
// Dismiss
let dimmingView = transitionContext.containerView.subviews.first(where: { (view) -> Bool in
return view is DimmingView
})
fromViewController.view.frame.origin.y = 50 // or use finalFrame
let fromViewSnpaShot = fromViewController.view.snapshotView(afterScreenUpdates: false)
if let fromViewSnpaShot = fromViewSnpaShot {
fromViewController.view.isHidden = true
fromViewSnpaShot.frame = fromViewController.view.frame
transitionContext.containerView.addSubview(fromViewSnpaShot)
}
dimmingView?.alpha = 0.7
toViewController.view.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveLinear], animations: {
dimmingView?.alpha = 0
fromViewSnpaShot?.frame.origin.y = UIScreen.main.bounds.size.height
toViewController.view.transform = CGAffineTransform(scaleX: 1.0, y: 1.0)
}) { (_) in
if (!transitionContext.transitionWasCancelled) {
toViewController.view.transform = .identity
dimmingView?.removeFromSuperview()
toViewController.view.layer.mask = nil
}
fromViewSnpaShot?.removeFromSuperview()
fromViewController.view.isHidden = false
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
}
}
}
The above achieves the effect shown in the image. Since this is a tutorial demonstration, the code is quite messy and there is plenty of room for optimization and integration.
It is worth mentioning…
iOS ≥ 13, if the View contains a UITextView, during the pull-down close animation, the text content in the UITextView will appear blank; this causes a flickering experience (video example) …
The solution here is to use
snapshotView(afterScreenUpdates:)to capture a snapshot instead of the original view layer during animation.
Full-Page Right-Swipe Back
When looking for a solution to enable full-screen right-swipe back gesture, I found a tricky method:
Add a UIPanGestureRecognizer directly to the view, then set its target and action to the native interactivePopGestureRecognizer with action:handleNavigationTransition.
*Click here for detailed method<
Exactly! It definitely looks like a Private API and might get rejected during review. Also, it’s unclear if Swift can use it since it probably relies on Objective-C runtime features.
Let’s stick to the standard way:
Using the same approach as in this article, we handle the navigationController POP ourselves; just add a full-screen right-swipe gesture with a custom right-swipe animation!
Other parts omitted, only key animation and interaction handling classes are shown:
import UIKit
class SwipeBackInteractive: UIPercentDrivenInteractiveTransition {
private var interactiveView: UIView!
private var navigationController: UINavigationController!
private let thredhold: CGFloat = 0.4
convenience init(_ navigationController: UINavigationController, _ interactiveView: UIView) {
self.init()
self.interactiveView = interactiveView
self.navigationController = navigationController
setupPanGesture()
wantsInteractiveStart = false
}
private func setupPanGesture() {
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
panGesture.maximumNumberOfTouches = 1
interactiveView.addGestureRecognizer(panGesture)
}
@objc func handlePan(_ sender: UIPanGestureRecognizer) {
switch sender.state {
case .began:
sender.setTranslation(.zero, in: interactiveView)
wantsInteractiveStart = true
self.navigationController.popViewController(animated: true)
case .changed:
let translation = sender.translation(in: interactiveView)
guard translation.x >= 0 else {
sender.setTranslation(.zero, in: interactiveView)
return
}
let percentage = abs(translation.x / interactiveView.bounds.width)
update(percentage)
case .ended:
if percentComplete >= thredhold {
finish()
} else {
wantsInteractiveStart = false
cancel()
}
case .cancelled, .failed:
wantsInteractiveStart = false
cancel()
default:
wantsInteractiveStart = false
return
}
}
}
import UIKit
class SlideFromLeftToRightTransition: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.4
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let toView = transitionContext.view(forKey: .to), let fromView = transitionContext.view(forKey: .from) else {
return
}
toView.frame.origin.x = -(UIScreen.main.bounds.size.width / 2)
fromView.frame.origin.x = 0
transitionContext.containerView.insertSubview(toView, belowSubview: fromView)
let shadowRect: CGRect = CGRect(x: -4, y: -20, width: 4, height: fromView.frame.height)
let shadowPath: UIBezierPath = UIBezierPath(rect: shadowRect)
fromView.layer.shadowPath = shadowPath.cgPath
fromView.layer.shadowOpacity = 0.8
UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveLinear], animations: {
toView.frame.origin.x = 0
fromView.frame.origin.x = UIScreen.main.bounds.size.width
}) { (_) in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
}
}
Pull-Up Fade-In UIViewController
Pull up to fade in + pull down to close on the view is similar to the transition effect in Spotify’s player!
This part is more complex, but the principle is the same. It won’t be posted here. Interested readers can refer to the GitHub example.
The main point to note is when performing an upward slide-in animation, make sure to use the “.curveLinear” timing curve; otherwise, the slide-up won’t follow the finger properly. The drag distance and the displayed position won’t be proportional.
Done!

Completed Diagram
This article is very long and took me quite some time to compile. Thank you for your patience in reading.
Full GitHub Example Download:
References:
-
Systematic Learning of iOS Animations Part 4: View Controller Transition Animations
-
Systematic Learning of iOS Animations Part 5: Using UIViewPropertyAnimator
-
Using UIPresentationController to create a simple and elegant bottom popup control (If you only need the Present animation effect, you can directly use this)
If you need to refer to elegant code encapsulation:



Comments