ベン・ギルド (Ben Guild)


The simplest “pull-to-dismiss” implementation that I could come up with for Swift 4! (`UIScrollView` friendly)

I'd looked around, but man… I just couldn't find any good code online for this. Is everyone worried about their implementations? 😂 …Am I overthinking this code? — Regardless, I wanted to “bite the bullet” here and at least open-source what I'd written so far (it's beta right now), but hopefully this is something that we can all iterate on a bit together! 🤓

It's not quite perfect (yet), but is at least modern enough to use `UIPercentDrivenInteractiveTransition` and `UIViewControllerAnimatedTransitioning`… and not manual view manipulation as I've seen others suggest and implement. 🤦🏼‍

As we slowly move away from primarily using buttons to more use of taps and gestures (only) to navigate between content and screens within apps, “pull-to-dismiss” is becoming more and more common. It's so familiar-feeling that it might just be something that you want to “drop into” an app at some point as if it were a native Apple or Google API.

… Since it isn't officially available (yet?) in Apple's UIKit, let's come up with some great code for this! 👍🏻

What is “pull-to-dismiss” in 2018? …Why is it difficult?

One of the nicest examples that I've seen is in the redesigned App Store included with iOS 11. 💯

When browsing featured apps on the “Today” screen, pulling downward on the modal that appears when tapping an article produces a fluid and seamless transition that will “scale” the content inward and blend it back into the related cell displayed on the view below.

Transitions like this can have various quirks and side-effects, but Apple pulls it off quite nicely:

Pull-to-dismiss in the App Store in iOS 11
“Pull-to-dismiss” in the App Store since iOS 11.

Pull-to-dismiss” can present a challenge, in that, if the content you're displaying is already vertically scrollable, you're risking the takeover of the normal behavior of the “top-edge” of the scrollable container. — Traditionally, on iOS, this edge will “bounce” (as patented) and display a rubber-banding effect. Naturally, this effect is not compatible with pull-to-dismiss… as, typically, the user, if seeking to engage the dismissing transition, would expect the view to slide or scale rather than bounce.

Implementing logic to correctly guess what the user would want based on a single gesture is more complex than you'd think.

Juggling bounce

Because of this particular issue with the seamlessness of “bounce,” Apple themselves actually decided to disable it when approaching the top-edge of the content in iOS 11's App Store, and instead mimic it elsewhere in the content below the top image. (as seen above)

This unfortunately causes the top content to “slam” at the vertical offset of zero, which can feel unnatural on iOS. However, it is consistent with the other instances of this within the App Store, of course, and otherwise this implementation is very fluid and nice. 🆗

Building another implementation of “pull-to-dismiss” introduces questions, such as:

  • When you reach the top, should it bounce, or should it always begin to dismiss?
  • How does the velocity of the gesture affect this logic?
  • What if there are competing gestures that could trigger a bounce in one instance, but a dismissal in another? …How can this still feel natural?
  • … Should we even just disable bounce on the “top-edge,” all together?

More commonly than not, I've seen apps choose to disable the “top-edge bounce” as Apple also opted to, but the decision to do so may also depend largely on the design of your content and whether there are other situations in which you'd only “slam” or “freeze” the content's vertical position in order to accurately handle the gesture and resulting transition.

A typical issue with a “slide” transition gesture needing to catch when the logic flips to a dismiss from a bounce.
A typical challenge with a “slide” transition gesture needing to “catch” when the logic flips to a dismiss from a bounce.

This code so far aims to better document these challenges and simultaneously provide the most compatible and generic base behaviors possible to all interested developers. — Feedback and pull-requests are of course welcome to help expand on its behaviors, functionality, and its overall compatibility as an open-source project! 👍🏻

The challenge with sliding

So, assuming the gesture handling and “bounce rebounding” are implemented correctly, there's also the question of what style of transition to use. ⬇️ — Apple went with an inward-scaling 3D-like effect as mentioned (an iteration of which is also included in this library/code), but in many instances you may initially feel that you'd prefer to “slide” your content vertically instead as if you were scrollling the view off of the screen.

A major issue with sliding is that, when we aim for a “natural” feel with the transition, the interruption or blocking of the vertical bounce in some instances can be a bit jarring. — When we see the view instead begin to scale inward, it can almost feel refreshing in a way, in that it no longer feels like vertically-scrolling content… but instead that the content itself has transitioned to falling inward in a 3D space. 🌐

This transition of the content seems to alleviate some of the lack of “bounce” as long as it's noticeable-enough even during small gestures that may trigger it, so you may want to consider scaling as long as your app's design fits the look of the rounded corners of the view during said transition from the corners of the device… which, post-iPhone X, are generally quite rounded.

If you choose to retain the default “slide” behavior, just remember that the lack of a transition between axis or dimension can perhaps cause a break in fluidness for the end-user.

The code, the project

Feel free to experiment with the library I've released in your own project, submit a pull-request with changes/fixes, or even just check out the example project included with it in the Xcode iOS Simulator.

It's fairly simple to implement. — Refer to the “README” file in the repository for the latest information, but at this time of writing… when initiating your view controller, it's just:

var viewController = MyAwesomeViewController()
viewController.isPullToDismissEnabled = true

viewController.modalPresentationCapturesStatusBarAppearance = true
viewController.modalPresentationStyle = .overFullScreen

self.present(viewController)

Then, typically you'd have your view controller conform to the bundled “PullToDismissable” protocol:

import PullToDismissTransition
import UIKit

class MyAwesomeViewController: UIViewController, PullToDismissable {
    private lazy var pullToDismissTransition: PullToDismissTransition = {
        let pullToDismissTransition = PullToDismissTransition(
            viewController: self,
            transitionType: .slideStatic
        )

        ////
        // NOTE: Optional, unless you implement any of the delegate methods:
        pullToDismissTransition.delegate = self

        return pullToDismissTransition
    }()

    override func didMove(toParentViewController parent: UIViewController?) {
        super.didMove(toParentViewController: parent)
        setupPullToDismiss()
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        setupPullToDismiss()

        ////
        // NOTE: Optional, unless you've navigated to a scroll-view within a navigation
        //  flow (but the same context), and therefore must toggle monitoring to it:

        pullToDismissTransition.monitorActiveScrollView(scrollView: scrollView)
    }
}

extension MyAwesomeViewController: UIViewControllerTransitioningDelegate {
    func animationController(forDismissed dismissed: UIViewController)
        -> UIViewControllerAnimatedTransitioning? {
            guard isPullToDismissEnabled else { return nil }
            return pullToDismissTransition
    }

    func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning)
        -> UIViewControllerInteractiveTransitioning? {
            guard isPullToDismissEnabled else { return nil }
            return pullToDismissTransition
    }
}

There are delegate callbacks as documented in the “README” as well, and currently three bundled transition types at this time of writing: “slideStatic,” “slideDynamic,” and “scale,” the last of which being most similar to the iOS 11 App Store's built-in transition.

As notated in the code above, if you're primarily presenting a `UIScrollView` (or other derivative, such as a `UITableView` or `UICollectionView`) in your modal(s), you'll need to keep the transition up-to-date by calling this method whenever a new instance appears on screen:

pullToDismissTransition.monitorActiveScrollView(scrollView: myAwesomeScrollView)

So far, that's pretty much it! — By the time you read this, this documentation may be slightly out of date, but you can always refer to the latest documentation bundled alongside the code. 👍🏻

Once I'm able to fix a few final minor issues with this, I'll most likely also release it as a Cocoa Pod for everyone's convenience and discovery. Stay tuned! ☕️

UPDATE (2018/09/18): I still need to sort out a couple of remaining issues (as logged on the repo), but the project is now released on Cocoa Pods:

In addition, I also tweeted out a link to the slides from a presentation that I did at the Tokyo iOS Meetup about this subject, as well as horizontal screen/page swiping: