Home iOS UIViewController Transition Techniques
Post
Cancel

iOS UIViewController Transition Techniques

iOS UIViewController Transition Techniques

Complete guide to pull-down to close, pull-up to appear, and full-page right swipe back effects in UIViewController

Introduction

I’ve always been curious about how commonly used apps like Facebook, Line, Spotify, etc., implement effects such as “pull-down to close a presented UIViewController,” “pull-up to gradually appear a UIViewController,” and “full-page support for right swipe back.”

These effects are not built-in, and the pull-down to close feature only has system card style support starting from iOS 13.

Exploration Journey

Whether it’s due to not knowing the right keywords or the difficulty in finding the information, I could never find a clear implementation method for these features. The information I found was always vague and scattered, requiring piecing together from various sources.

When I first researched the method, I found the UIPresentationController API. Without delving deeper into other resources, I used this method combined with UIPanGestureRecognizer to achieve the pull-down to close effect in a rather crude way. It always felt off, like there should be a better way.

Recently, while working on a new project, I came across this article which broadened my horizons and revealed more elegant and flexible APIs.

This post serves as both a personal record and a guide for those who share my confusion.

The content is quite extensive. If you’re in a hurry, you can skip to the end for examples or directly download the GitHub project for study!

iOS 13 Card Style Presentation

First, let’s talk about the latest built-in effect. From iOS 13 onwards, UIViewController.present(_:animated:completion:) defaults to the modalPresentationStyle effect of UIModalPresentationAutomatic for card style presentation. If you want to maintain the previous full-page presentation, you need to specifically set it back to UIModalPresentationFullScreen.

Built-in Calendar Add Effect

Built-in Calendar Add Effect

How to Disable Pull-Down to Close? Confirmation on Close?

A better user experience should check for unsaved data when triggering the pull-down to close action, prompting the user whether to discard changes before leaving.

Apple has thought of this for us. Simply implement the methods in UIAdaptivePresentationControllerDelegate.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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 close method (1):
        self.isModalInPresentation = true;
        
    }
    
}

// Delegate implementation
extension DetailViewController: UIAdaptivePresentationControllerDelegate {
    // Disable pull-down to close method (2):
    func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
        return false;
    }
    
    // Triggered when pull-down to close is canceled
    func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
        if (onEdit) {
          let alert = UIAlertController(title: "Unsaved Data", 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 cancel the dismissal by swipe down, you can either set the UIViewController variable isModalInPresentation to false or implement the UIAdaptivePresentationControllerDelegate method presentationControllerShouldDismiss and return true.

The method UIAdaptivePresentationControllerDelegate presentationControllerDidAttemptToDismiss is only called when the dismissal by swipe down is canceled.

By the way…

For the system, a card-style presentation page is considered a Sheet, which behaves differently from FullScreen.

Assuming that RootViewController is HomeViewController

In a card-style presentation (UIModalPresentationAutomatic):

When HomeViewController Presents DetailViewController

HomeViewController viewWillDisAppear / viewDidDisAppear will not be triggered.

When DetailViewController Dismisses

HomeViewController viewWillAppear / viewDidAppear will not be triggered.

⚠️ Since XCODE 11, iOS ≥ 13 apps packaged by default use the card style (UIModalPresentationAutomatic) for Presentations

If you previously placed some logic in viewWillAppear/viewWillDisappear/viewDidAppear/viewDidDisappear, be sure to check carefully! ⚠️

After looking at the built-in system, let’s get to the main point of this article! How to achieve these effects yourself?

Where can you perform transition animations?

First, let’s organize where you can perform window transition animations.

UITabBarController/UIViewController/UINavigationController

UITabBarController/UIViewController/UINavigationController

When switching UITabBarController

We can set the delegate for UITabBarController and implement the animationControllerForTransitionFrom method to apply custom transition effects when switching UITabBarController.

The system default has no animation. The above demonstration shows a fade-in fade-out transition effect.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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/Dismissing a UIViewController, you can specify the animation effect to apply; otherwise, this article wouldn’t exist XD. However, it’s worth mentioning that if you only want to create a Present animation without gesture control, you can directly use UIPresentationController for convenience and speed (see references at the end of the article).

The system default is slide up to appear and slide down to disappear! If you customize it yourself, you can add effects such as fade-in, rounded corners, control of appearance position, etc.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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, etc. can all do this.

UINavigationController Push/Pop

UINavigationController is probably the one that needs animation customization the least, because the system’s default left-slide to appear and right-slide to return animations are already the best effects. Customizing this part might be used to create seamless UIViewController left-right switching effects.

Since we want to enable full-page gesture returns, we need to implement a custom POP animation effect.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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 returning
        } 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 talk about what interactive and non-interactive mean.

Interactive Animation: Gesture-triggered animations, such as UIPanGestureRecognizer

Non-interactive Animation: System-triggered animations, such as self.present( )

How to Implement Animation Effects?

After discussing where animations can be applied, let’s look at how to create animation effects.

We need to implement the UIViewControllerAnimatedTransitioning protocol and animate the view within it.

General Transition Animation: UIView.animate

Directly use UIView.animate for animation handling. At this point, UIViewControllerAnimatedTransitioning needs to implement two methods: transitionDuration to specify the duration of the animation, and animateTransition to implement the animation content.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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 content of the target UIViewController to be displayed:
        let toView = transitionContext.view(forKey: .to)
        // Get the target UIViewController to be displayed:
        let toViewController = transitionContext.viewController(forKey: .to)
        // Get the initial frame information of the target UIViewController's view:
        let toInitalFrame = transitionContext.initialFrame(for: toViewController!)
        // Get the final frame information of the target UIViewController's view:
        let toFinalFrame = transitionContext.finalFrame(for: toViewController!)
        
        // Get the view content of the current UIViewController:
        let fromView = transitionContext.view(forKey: .from)
        // Get the current UIViewController:
        let fromViewController = transitionContext.viewController(forKey: .from)
        // Get the initial frame information of the current UIViewController's view:
        let fromInitalFrame = transitionContext.initialFrame(for: fromViewController!)
        // Get the final frame information of the current UIViewController's view: (can get the final frame from the previous display animation when closing the animation)
        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 interrupted
            }
            
            // Notify the system that the animation is complete
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        }
        
    }
    
}

To and From:

Assume today HomeViewController needs to Present/Push DetailViewController,

From = HomeViewController / To = DetailViewController

When DetailViewController needs to Dismiss/Pop,

From = DetailViewController / To = HomeViewController

⚠️⚠️⚠️⚠️⚠️

It is recommended by the official documentation to use the view from transitionContext.view rather than from transitionContext.viewController.view.

However, there is an issue when performing Present/Dismiss animations with modalPresentationStyle = .custom;

Using transitionContext.view(forKey: .from) during Present will be nil, and

Using transitionContext.view(forKey: .to) during Dismiss will also be nil;

You still need to get the value from viewController.view.

⚠️⚠️⚠️⚠️⚠️

transitionContext.completeTransition(!transitionContext.transitionWasCancelled) must be called when the animation is complete, otherwise the screen will freeze;

However, if UIView.animate has no executable animation, it will not call completion, causing the aforementioned method not to be called; so make sure the animation will execute (e.g., y from 100 to 0).

ℹ️ℹ️ℹ️ℹ️ℹ️

For ToView/FromView involved in the animation, if the view is more complex or there are some issues during the animation; you can use snapshotView(afterScreenUpdates:) to take a screenshot for the animation display. First, take a screenshot and then transitionContext.containerView.addSubview(snapShotView) to the layer, then hide the original ToView/FromView (isHidden = true), and at the end of the animation, snapShotView.removeFromSuperview() and restore the original ToView/FromView (isHidden = true).

Interruptible and Continuable Transition Animations: UIViewPropertyAnimator

You can also use the new animation class introduced in iOS ≥ 10 to implement animation effects. Choose based on personal preference or the level of detail required for the animation. Although the official recommendation is to use UIViewPropertyAnimator for interactive animations, generally, both interactive and non-interactive (gesture control) animations can be done using UIView.animate; UIViewPropertyAnimator allows for interruptible and continuable transition animations, though I’m not sure where it can be practically applied. Interested readers can refer to this article.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import UIKit

class FadeInFadeOutTransition: NSObject, UIViewControllerAnimatedTransitioning {
    
    private var animatorForCurrentTransition: UIViewImplicitlyAnimating?

    func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
        
        // Return the current transition animator if it exists
        if let animatorForCurrentTransition = animatorForCurrentTransition {
            return animatorForCurrentTransition
        }
        
        // Parameters as mentioned before
        
        // 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)
        }
        
        // Hold onto 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 animator
        let animator = self.interruptibleAnimator(using: transitionContext)
        animator.startAnimation()
    }
    
    func animationEnded(_ transitionCompleted: Bool) {
        // Clear the animator when the animation is complete
        self.animatorForCurrentTransition = nil
    }
    
}

In interactive scenarios (detailed later in the control section), the interruptibleAnimator method is used for animations; in non-interactive scenarios, the animateTransition method is still used.

Due to its ability to continue and interrupt, the interruptibleAnimator method might be called repeatedly; hence, we need to use a global variable to store and access the return value.

Murmur… Actually, I initially wanted to switch entirely to the new UIViewPropertyAnimator and recommend everyone to use it, but I encountered a very strange issue. When performing a full-page gesture return Pop animation, if the gesture is released and the animation returns to its original position, the items on the Navigation Bar above will flicker with a fade-in and fade-out effect… I couldn’t find a solution, but reverting to UIView.animate resolved the issue. If there’s something I missed, please let me know <( _ _ )>.

Problem image; + button is from the previous page

Problem image; + button is from the previous page

So, to be safe, let’s stick with the old method!

In practice, different animation effects will be created in separate classes. If you find the files too cluttered, you can refer to the packaged solution at the end of the article or group related (Present + Dismiss) animations together.

transitionCoordinator

Additionally, if you need more precise control, such as having a specific component within the ViewController change along with the transition animation, you can use the transitionCoordinator in UIViewController for coordination. I didn’t use this part; if you’re interested, you can refer to this article.

How to control the animation?

This is the aforementioned “interactive” part, which is essentially gesture control. This is the most important section of this article because we aim to achieve the functionality of gesture operations linked with transition animations, enabling us to implement pull-to-close and full-page return features.

Control delegate setup:

Similar to the ViewController delegate animation design mentioned earlier, the interactive handling class also needs to inform the ViewController in the delegate.

UITabBarController: None UINavigationController (Push/Pop):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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 returning
        } else if operation == .push {
            return //UIViewControllerAnimatedTransitioning animation to apply when pushing
        }
        //Returning nil will use the default animation
        return nil
    }
    
    //Add interactive delegate method:
    func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        //Cannot determine if it's Pop or Push here, can only judge from the animation itself
        if animationController is animation applied during push {
            return //UIPercentDrivenInteractiveTransition interactive control method for push animation
        } else if animationController is animation applied during return {
            return //UIPercentDrivenInteractiveTransition interactive control method for pop animation
        }
        //Returning nil means no interactive handling
        return nil
    }
}

UIViewController (Present/Dismiss):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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 method for interactive control during Dismiss
    }
    
    func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        //return nil means no interactive handling
        return //UIPercentDrivenInteractiveTransition method for interactive control during Present
    }
    
    func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        //return nil means using default animation
        return //UIViewControllerAnimatedTransitioning animation to apply during Present
    }
    
    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        //return nil means using default animation
        return //UIViewControllerAnimatedTransitioning animation to apply during Dismiss
    }
    
}

⚠️⚠️⚠️⚠️⚠️

If you implement interactionControllerFor… methods, even if the animation is non-interactive (e.g., self.present system call transition), these methods will still be called for handling; we need to control the wantsInteractiveStart parameter inside (introduced below).

Animation Interactive Handling Class UIPercentDrivenInteractiveTransition:

Next, let’s talk about the core implementation of UIPercentDrivenInteractiveTransition.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
import UIKit

class PullToDismissInteractive: UIPercentDrivenInteractiveTransition {
    
    //UIView to add gesture control interaction
    private var interactiveView: UIView!
    //Current UIViewController
    private var presented: UIViewController!
    //Threshold percentage to complete execution, otherwise revert
    private let thredhold: CGFloat = 0.4
    
    //Different transition effects may require different information, customizable
    convenience init(_ presented: UIViewController, _ interactiveView: UIView) {
        self.init()
        self.interactiveView = interactiveView
        self.presented = presented
        setupPanGesture()
        
        //Default value, informs the system that the current animation is non-interactive
        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 the system that the current animation is triggered by a gesture
            wantsInteractiveStart = true
            
            //Call the transition effect to be performed during gesture began (won't execute directly, system will hold it)
            //Then the corresponding animation for the transition effect will jump to UIViewControllerAnimatedTransitioning for handling
            // 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 the gesture sliding position corresponding to the animation completion percentage 0~1
            //Actual calculation method varies depending on the 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 the gesture is released, check if the completion percentage exceeds the threshold
            wantsInteractiveStart = false
            if percentComplete >= thredhold {
              //Yes, inform the animation to complete
              finish()
            } else {
              //No, inform the animation to revert
              cancel()
            }
        case .cancelled, .failed:
          //On cancel or error
          wantsInteractiveStart = false
          cancel()
        default:
          wantsInteractiveStart = false
          return
        }
    }
}

//When there are UIScrollView components (UITableView/UICollectionView/WKWebView....) inside UIViewController, prevent gesture conflicts
//When the UIScrollView component inside has scrolled to the top, enable the gesture operation for interactive transition
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
    }
    
}

*About the reason for sender.setTranslation( .zero, in:interactiveView) supplement point I<

We need to implement different Classes based on different gesture operation effects; if it is the same continuous (Present+Dismii) operation, it can also be wrapped together.

⚠️⚠️⚠️⚠️⚠️

wantsInteractiveStart must be in a compliant state. If wantsInteractiveStart = false is notified during interactive animation, it will also cause the screen to freeze;

You need to exit and re-enter the APP to restore it.

⚠️⚠️⚠️⚠️⚠️

interactiveView must also be isUserInteractionEnabled = true

You can set it more to ensure it!

Combination

When we set up this Delegate and build the Class, we can achieve the functionality we want. Let’s not waste any more time and go straight to the completed example.

Custom pull-down to close page effect

The advantage of custom pull-down is that it supports all iOS versions on the market, can control the overlay percentage, control the trigger close position, and customize the animation effect.

Click the top right + Present page

Click the top right + Present page

This is an example of HomeViewController presenting HomeAddViewController and HomeAddViewController dismissing.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
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 specified to handle 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 interactive information
        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 Present operation gesture here
        return nil
    }
}
import UIKit

class PullToDismissInteractive: UIPercentDrivenInteractiveTransition {
    
    private var interactiveView: UIView!
    private var presented: UIViewController!
    private var completion: (() -> Void)?
    private let threshold: 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 >= threshold {
                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
    }
    
}

With the above, you can achieve the effect shown in the image. The code here is quite messy due to the simplicity of the tutorial, and there is much room for optimization and integration.

Worth mentioning…

iOS ≥ 13, if the View contains a UITextView, during the pull-down close animation, the text content of the UITextView will be blank; causing a flicker in the experience (video example)

The solution here is to use snapshotView(afterScreenUpdates:) to replace the original View layer during the animation.

Full-page right swipe back

When looking for a solution to enable right swipe back gesture for the entire screen, I found a Tricky method: Directly add a UIPanGestureRecognizer to the screen and then set the target and action to the native interactivePopGestureRecognizer, action:handleNavigationTransition. *Detailed method click me<

That’s right! It looks like a Private API, and it feels like it might get rejected during review; also, it’s uncertain if it works with Swift, as it might use Runtime features specific to Objective-C.

Let’s go the proper way:

Using the same method as in this article, we handle the navigationController POP back ourselves; add a full-page right swipe gesture control with a custom right swipe animation!

Other parts are omitted, only the key animation and interaction handling class is posted:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import UIKit

class SwipeBackInteractive: UIPercentDrivenInteractiveTransition {
    
    private var interactiveView: UIView!
    private var navigationController: UINavigationController!

    private let threshold: 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 >= threshold {
                finish()
            } else {
                wantsInteractiveStart = false
                cancel()
            }
        case .cancelled, .failed:
            wantsInteractiveStart = false
            cancel()
        default:
            wantsInteractiveStart = false
            return
        }
    }
}

Pull-up fade-in UIViewController

On the View, pull up to fade in + pull down to close, which creates a transition effect similar to Spotify’s player!

This part is more tedious, but the principle is the same. I won’t post it here, but interested friends can refer to the GitHub example content.

One thing to note is that when pulling up to fade in, the animation must ensure that it uses “.curveLinear” linear, otherwise there will be a problem where the pull-up does not follow the hand; the degree of pull and the displayed position are not proportional.

Completed!

Completed Image

Completed Image

This article is very long and took me a long time to organize and produce. Thank you for your patience in reading.

Full GitHub example download:

References:

  1. Draggable view controller? Interactive view controller!
  2. Systematic study of iOS animations part four: View controller transition animations
  3. Systematic study of iOS animations part five: Using UIViewPropertyAnimator
  4. Using UIPresentationController to write a simple and beautiful bottom pop-up control (Simply for Present animation effects, you can directly use this)

For elegant code encapsulation references:

  1. Swift: https://github.com/Kharauzov/SwipeableCards
  2. Objective-C: https://github.com/saiday/DraggableViewControllerDemo

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here


This post is licensed under CC BY 4.0 by the author.

iOS Deferred Deep Link Implementation (Swift)

Mi Home APP / Xiao Ai Speaker Region Issues