Drag to dismiss a UIPresentationController
As your description about the dragging experience you wanted is not that clear, hope I didn't get you wrong.
I'm trying to have the drag feel natural and responsive like the drag experience for "Top Stories" on the Apple iOS 13 stocks app.
What I get is, you want to be able to drag the presented view, dismiss it if it reach certain point, else go back to its original position (and of coz you can bring the view to any position you wanted).
To achieve this, we can add a UIPanGesture to the presentedViewController, then
move the presentedView according to the gesture
dismiss / move back the presentedView
class SlideUpPresentationController: UIPresentationController {
// MARK: - Variables
private var dimmingView: UIView!
private var originalX: CGFloat = 0
//MARK: - View functions
override init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?) {
super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
setupDimmingView()
}
override func containerViewWillLayoutSubviews() {
presentedView?.frame = frameOfPresentedViewInContainerView
}
override var frameOfPresentedViewInContainerView: CGRect {
guard let container = containerView else { return super.frameOfPresentedViewInContainerView }
let width = container.bounds.size.width
let height : CGFloat = 300.0
return CGRect(x: 0, y: container.bounds.size.height - height, width: width, height: height)
}
override func presentationTransitionWillBegin() {
guard let dimmingView = dimmingView else { return }
containerView?.insertSubview(dimmingView, at: 0)
// add PanGestureRecognizer for dragging the presented view controller
let viewPan = UIPanGestureRecognizer(target: self, action: #selector(viewPanned(_:)))
containerView?.addGestureRecognizer(viewPan)
NSLayoutConstraint.activate(NSLayoutConstraint.constraints(withVisualFormat: "V:|[dimmingView]|", options: [], metrics: nil, views: ["dimmingView": dimmingView]))
NSLayoutConstraint.activate(NSLayoutConstraint.constraints(withVisualFormat: "H:|[dimmingView]|", options: [], metrics: nil, views: ["dimmingView": dimmingView]))
guard let coordinator = presentedViewController.transitionCoordinator else {
dimmingView.alpha = 1.0
return
}
coordinator.animate(alongsideTransition: { _ in
self.dimmingView.alpha = 1.0
})
}
@objc private func viewPanned(_ sender: UIPanGestureRecognizer) {
// how far the pan gesture translated
let translate = sender.translation(in: self.presentedView)
switch sender.state {
case .began:
originalX = presentedViewController.view.frame.origin.x
case .changed:
// move the presentedView according to pan gesture
// prevent it from moving too far to the right
if originalX + translate.x < 0 {
presentedViewController.view.frame.origin.x = originalX + translate.x
}
case .ended:
let presentedViewWidth = presentedViewController.view.frame.width
let newX = presentedViewController.view.frame.origin.x
// if the presentedView move more than 0.75 of the presentedView's width, dimiss it, else bring it back to original position
if presentedViewWidth * 0.75 + newX > 0 {
setBackToOriginalPosition()
} else {
moveAndDismissPresentedView()
}
default:
break
}
}
private func setBackToOriginalPosition() {
// ensure no pending layout change in presentedView
presentedViewController.view.layoutIfNeeded()
UIView.animate(withDuration: 0.25, delay: 0.0, options: .curveEaseIn, animations: {
self.presentedViewController.view.frame.origin.x = self.originalX
self.presentedViewController.view.layoutIfNeeded()
}, completion: nil)
}
private func moveAndDismissPresentedView() {
// ensure no pending layout change in presentedView
presentedViewController.view.layoutIfNeeded()
UIView.animate(withDuration: 0.25, delay: 0.0, options: .curveEaseIn, animations: {
self.presentedViewController.view.frame.origin.x = -self.presentedViewController.view.frame.width
self.presentedViewController.view.layoutIfNeeded()
}, completion: { _ in
// dimiss when the view is completely move outside the screen
self.presentingViewController.dismiss(animated: true, completion: nil)
})
}
override func dismissalTransitionWillBegin() {
guard let coordinator = presentedViewController.transitionCoordinator else {
dimmingView.alpha = 0.0
return
}
coordinator.animate(alongsideTransition: { _ in
self.dimmingView.alpha = 0.0
})
}
func setupDimmingView() {
dimmingView = UIView()
dimmingView.translatesAutoresizingMaskIntoConstraints = false
dimmingView.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
dimmingView.alpha = 0.0
let recognizer = UITapGestureRecognizer(target: self,
action: #selector(handleTap(recognizer:)))
dimmingView.addGestureRecognizer(recognizer)
}
@objc func handleTap(recognizer: UITapGestureRecognizer) {
presentingViewController.dismiss(animated: true)
}
}
The above code is just an example based on the code you provide, but I hope that explain what's happening under the hood of what you called a drag experience. Hope this helps ;)
Here is the example result:
via GIPHY
Catch when user drags down modal in iOS Swift
As described in Apple's docs, you can implement UIAdaptivePresentationControllerDelegate
and use presentationControllerDidAttemptToDismiss(_ :)
to "intercept" the pull-down action.
Here is a bare-bones example:
class ConfirmDismissViewController: UIViewController, UIAdaptivePresentationControllerDelegate {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemYellow
// add a dismiss button
let b = UIButton(type: .system)
b.setTitle("Dismiss", for: [])
b.addTarget(self, action: #selector(btnTapped(_:)), for: .touchUpInside)
b.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(b)
NSLayoutConstraint.activate([
b.centerXAnchor.constraint(equalTo: view.centerXAnchor),
b.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
// this will trigger a call to presentationControllerDidAttemptToDismiss() on drag-down
isModalInPresentation = true
presentationController?.delegate = self
}
func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
// Only ask if the user wants to save if they attempt to pull to dismiss, not if they tap Cancel.
alert.addAction(UIAlertAction(title: "Discard Changes", style: .destructive) { _ in
self.dismiss(animated: true, completion: nil)
})
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
present(alert, animated: true, completion: nil)
}
@objc func btnTapped(_ sender: Any?) -> Void {
// dismiss WITHOUT prompt
dismiss(animated: true, completion: nil)
}
}
When you present this view controller, the user can tap the "Dismiss" button to explicitly dismiss the VC, or, if the user drags-down, you'll be prompted.
How can I implement a pull down to dismiss a modal view in SwiftUI?
UIImagePickerController
overlaps entire sheet's area and does not allow to handle drag, so, as a workaround, a possible approach could be to add some kind of notch (concept is recognizable so the usability will be clear)
Here is a demo (you can construct style/background according to your app design)
VStack(spacing: 0) {
RoundedRectangle(cornerRadius: 2)
.frame(width: 60, height: 4).foregroundColor(.gray)
.padding(.vertical, 4)
ImagePicker(sourceType: .photoLibrary)
}
Disable gesture to pull down form/page sheet modal presentation
In general, you shouldn't try to disable the swipe to dismiss functionality, as users expect all form/page sheets to behave the same across all apps. Instead, you may want to consider using a full-screen presentation style. If you do want to use a sheet that can't be dismissed via swipe, set isModalInPresentation = true
, but note this still allows the sheet to be pulled down vertically and it'll bounce back up upon releasing the touch. Check out the UIAdaptivePresentationControllerDelegate documentation to react when the user tries to dismiss it via swipe, among other actions.
If you have a scenario where your app's gesture or touch handling is impacted by the swipe to dismiss feature, I did receive some advice from an Apple engineer on how to fix that.
If you can prevent the system's pan gesture recognizer from beginning, this will prevent the gestural dismissal. A few ways to do this:
If your canvas drawing is done with a gesture recognizer, such as your own
UIGestureRecognizer
subclass, enter thebegan
phase before the sheet’s dismiss gesture does. If you recognize as quickly asUIPanGestureRecognizer
, you will win, and the sheet’s dismiss gesture will be subverted.If your canvas drawing is done with a gesture recognizer, setup a dynamic failure requirement with
-shouldBeRequiredToFailByGestureRecognizer:
(or the related delegate method), where you returnNO
if the passed in gesture recognizer is aUIPanGestureRecognizer
.If your canvas drawing is done with manual touch handling (e.g.
touchesBegan:
), override-gestureRecognizerShouldBegin
on your touch handling view, and returnNO
if the passed in gesture recognizer is aUIPanGestureRecognizer
.
With my setup #3 proved to work very well. This allows the user to swipe down anywhere outside of the drawing canvas to dismiss (like the nav bar), while allowing the user to draw without moving the sheet, just as one would expect.
I cannot recommend trying to find the gesture to disable it, as it seems to be rather dynamic and can reenable itself when switching between different size classes for example, and this could change in future releases.
Drag down to dismiss a ViewController
These view controller transitions are, sigh, unfortunately very complicated to do but there are lots of good tutorials online about them. You are correct in putting the subview into its own view controller. There are several other moving parts required. Fully detailing them would be enough material to write a book on the topic, but the high level overview is this:
Chapter 1: The Container
Think of the view controller with the map view as view controller A. Think of the slide-up/down view as view controller B. We want to transition seamlessly from A to B (and B to A). Cocoa provides an API for doing this, but first you'll need a container view controller that contains both A and B as a child view controller. This could be a navigation controller or your own custom container controller.
Chapter 2: Noninteractive Custom Container View Controller Transition
Once you have the container view controller, code it up so that you can go from A to B and back by pressing a button. (Throw a debug button somewhere into your app.) So you tap a button and you go from A to B and then you tap it again to go from B to A. To do this, there are the noninteractive custom container view controller transition APIs (it's a mouthful). This will require implementing an animation controller and a transitioning delegate, as detailed in that excellent objc.io tutorial and its corresponding GitHub repository.
Chapter 3: Interactive Custom Container View Controller Transition
But, of course, you want users to swipe up and down to trigger the transition. This will take you into the world of interactive blah blah blah APIs, where you implement an interaction controller and connect it to a pan gesture recognizer. The corresponding GitHub repository to that blog post actually uses a pan gesture like you would. This is particularly finicky work, as you would want the transition to be canceled should the user decide to stop panning midway through, but all that is detailed in the code and article.
Chapter 4: View Sharing
I'm actually not sure what this is called. The problem here is that you want the Google Maps / Spotify look and feel where a part of view controller B's view actually is visible while you're in view controller A. And the pan gesture recognizer is attached to B's view so that when you slide it up, B's view gets bigger and bigger until the interactive animation finishes and you've transitioned from A to B. Is there a good tutorial on this? I haven't found one, but it's becoming more and more common as an iOS UX idiom.
I myself have a small demo project illustrating how to do this. What I do is I have a navigation controller as my container view controller. The navigation controller's root view controller is my home view controller (view controller A). In view controller, I have view controller B as a child. So that's navigation controller owns A owns B.
When the user taps on B, I push B onto the navigation stack, thus reparenting it. At that point, my noninteractive animation controller kicks in as a custom navigation controller push animation. And so it looks like B is smoothly transmogrifying from its small bounds to its larger bounds. (I recommend running the xcodeproj to see the effect as I have done a terrible job of describing it.)
This technique would work just as well for an interactive animation. As to whether it's the best technique, I think that's up for debate — there are lots of ways of doing what you see in the Google Maps and Spotify apps. Apple could certainly do a lot better job of documenting the APIs for complex, interactive animations.
I would caution you to avoid using Auto Layout until you have your animations tweaked and ready, as it doesn't really interact well with your animation controllers. Good ol' frame rectangle math is best here until you find your bearings. Auto Layout is magic and, as you might be able to tell from how exhaustingly long this answer is, there's already enough magic to go around.
See Also
Session 803 from WWDC 2015: Designing with Animation
Session 214 from WWDC 2014: View Controller Advancements
Session 218 from WWDC 2013: Custom Transitions Using View Controllers
You can click on the purple Apple logo in the top right of asciiwwdc.com
pages to watch the videos of the talks or grab the PDFs from the Resources tabs. Some of the best documentation for iOS is locked up in these WWDC talks.
Related Topics
"From View Controller" Disappears Using Uiviewcontrollercontexttransitioning
How to Get the Console Logs from the iOS Simulator
Uitextview That Expands to Text Using Auto Layout
Get Size of a View in React Native
Presenting a Uialertcontroller Properly on an iPad Using iOS 8
Uilabel Layer Cornerradius Negatively Impacting Performance
How to Draw a Line Programmatically from a View Controller
Xcode Stuck at "Your Application Is Being Uploaded"
iOS 8 - Can't Install Enterprise App
Difference Between Protocol and Delegates
How to Check If a View Controller Is Presented Modally or Pushed on a Navigation Stack
Chain Multiple Alamofire Requests
Module Compiled with Swift 5.1 Cannot Be Imported by the Swift 5.1.2 Compiler
Logging Data on Device and Retrieving the Log
Best Way to Cache Images on iOS App
How to Determine Which Apps Are Background and Which App Is Foreground on iOS by Application Id
Getting User Location Every N Minutes After App Goes to Background