ZhgChg.Li

iOS UIViewController Transitions|Master Pull-Down Close, Pull-Up Present & Full-Page Swipe Back

Discover how to implement seamless UIViewController transitions in iOS, including pull-down to close, pull-up to present, and full-page right-swipe back gestures, enhancing user interaction and navigation flow efficiently.

iOS UIViewController Transitions|Master Pull-Down Close, Pull-Up Present & Full-Page Swipe Back
This article was AI-translated — please let me know if anything looks off.

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

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 RootViewController is HomeViewController

In card-style presentation (UIModalPresentationAutomatic):

When HomeViewController Presents DetailViewController

HomeViewController does not trigger viewWillDisappear / viewDidDisappear.

When DetailViewController is Dismiss

HomeViewController does not trigger viewWillAppear / 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

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 UIViewController can implement transitioningDelegate to specify Present/Dismiss animations; 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 HomeViewController needs to Present/Push DetailViewController,

From = HomeViewController / To = DetailViewController

When DetailViewController needs to Dismiss/Pop,

From = DetailViewController / To = HomeViewController

⚠️⚠️⚠️⚠️⚠️

The official recommendation is to get the view from transitionContext.view instead of using .view from transitionContext.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.animate will not call completion if 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/FromView involved in the animation is complex or causes issues during the animation, you can use snapshotView(afterScreenUpdates:) to create a snapshot for the animation. First, take a snapshot and add it to the layer with transitionContext.containerView.addSubview(snapShotView). Then hide the original ToView/FromView (isHidden = true). After the animation ends, remove the snapShotView with snapShotView.removeFromSuperview() and restore the visibility of the original ToView/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 interruptibleAnimator method; non-interactive cases still use the animateTransition method.

Because interruptibleAnimator can 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; the + button is the previous page

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 wantsInteractiveStart parameter 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.

⚠️⚠️⚠️⚠️⚠️

wantsInteractiveStart must be set appropriately; setting wantsInteractiveStart = false during 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 top-right + to Present the page

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!

Final result

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:

  1. Draggable view controller? Interactive view controller!

  2. Systematic Learning of iOS Animations Part 4: View Controller Transition Animations

  3. Systematic Learning of iOS Animations Part 5: Using UIViewPropertyAnimator

  4. 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:

  1. Swift: https://github.com/Kharauzov/SwipeableCards

  2. Objective-C: https://github.com/saiday/DraggableViewControllerDemo

Improve this page
Edit on GitHub
Originally published on Medium
Read the original
Share this essay
Copy link · share to socials
ZhgChgLi
Author

ZhgChgLi

An iOS, web, and automation developer from Taiwan 🇹🇼 who also loves sharing, traveling, and writing.

Comments