iOS 13 introduces a new design of modalPresentationStyle .pageSheet (and its sibling .formSheet) for modally presented view controllers…
…and we can dismiss these sheets by sliding the presented view controller down (interactive dismissal). Although the new "pull-to-dismiss" feature is pretty useful, it may not always be desirable.
THE QUESTION: How can we turn the interactive dismissal off?
- Bear in mind we keep the presentation style the same.
Option 1:
viewController.isModalInPresentation = true
(Disabled interactive .pageSheet dismissal acts like this.)
Since the iOS 13, UIViewController contains a new property called isModalInPresentation which must be set to true to prevent the interactive dismissal.
It basically ignores events outside the view controller's bounds. Bear that in mind if you are using not only the automatic style but also presentation styles like .popover etc.
This property is false by default.
From the official docs: If true, UIKit ignores events outside the view controller's bounds and prevents the interactive dismissal of the view controller while it is onscreen.
Option 2:
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
return false
}
Since the iOS 13, UIAdaptivePresentationControllerDelegate contains a new method called presentationControllerShouldDismiss.
This method is called only if the presented view controller is not dismissed programmatically and its isModalInPresentation property is set to false.
Tip: Don't forget to assign presentationController's delegate. But be aware, it is known that even just accessing the presentationController can cause a memory leak.
If you want the same behaviour as it's in previous iOS version (< iOS13) like model presentation in fullscreen, just set the presentation style of your destination view controller to UIModalPresentationStyle.fullScreen
let someViewController = \*VIEW CONTROLLER*\
someViewController.modalPresentationStyle = .fullScreen
And if you are using storyboard just select the segua and select Full Screen form the Presentation dropdown.
If you just want to disable the interactive dismissal and keep the new presentation style set UIViewController property isModalInPresentation to true.
if #available(iOS 13.0, *) {
someViewController.isModalInPresentation = true // available in IOS13
}
The property isModalInPresentation might help.
From the documentation:
When you set it to true, UIKit ignores events outside the view controller's bounds and prevents the interactive dismissal of the view controller while it is onscreen.
You can use it like this:
let controller = MyViewController()
controller.isModalInPresentation = true
self.present(controller, animated: true, completion: nil)
If you have some business logic, something like all fields should be filled before dismissing, you should:
On ViewDidLoad if your ViewController has been presented within a Navigation Controller:
func viewDidLoad() {
self.navigationController?.presentationController?.delegate = self
}
If not, simply use
func viewDidLoad() {
self.presentationController?.delegate = self
}
Then implement the delegate method:
extension ViewController: UIAdaptivePresentationControllerDelegate {
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
guard let text = firstName.text, text.isEmpty else { return false }
guard let text = lastName.text, text.isEmpty else { return false }
...
return true
}
}
If you are using storyboards to layout your UI I have found the best way to disable this interactive dismissal when using a navigation controller is to change the presentation of the Navigation Controller in the attribute inspector from Automatic to Full Screen. All view controllers in your navigation stack will then be full screen and will not be able to be dismissed by the user.
Attribute Inspector showing presentation option for the navigation controller
Apple shared a sample code about it at this link
It uses isModalInPresentation as many users suggestion.
All solutions are good, but in my case, I need an option to stop movement.
So this is a code for that.
if you want to block movement:
self.yourViewController?.presentedView?.gestureRecognizers?[0].isEnabled = false
And if you want to unblock movement:
self.yourViewController?.presentedView?.gestureRecognizers?[0].isEnabled = true
Related
Before presenting a view controller, we can set the modalPresentationStyle to .formSheet, and then set a preferredContentSize to control the size of the presented modal:
let controller = ModalViewController()
controller.modalPresentationStyle = .formSheet
controller.preferredContentSize = CGSize(width: 200, height: 200)
present(controller, animated: true)
Once displayed, is there a way to resize this modal? For example, if the presented view controller is a log in form with an option to create an account instead, the two fields for email and password might expand to collect a first and last name. How do we increase the size of the modal to accommodate them?
I have updated the preferredContentSize but it seems to have no effect once the view controller is already presented.
After setting new preferredContentSize, call this:
if let superView = view.superview?.superview {
superView.setNeedsLayout()
}
How did I find it? I sent the app to the background and entered it again to find that view controller has been set to correct size, then used view hierarchy debugger to have a look at parent views, then some trail and error.
- Edit:
More solid implementation:
var brokenView = view
while brokenView != nil {
brokenView?.setNeedsLayout()
brokenView = brokenView?.superview
}
I have successfully used this trick to force the size update:
self.presentationController?.containerView?.setNeedsLayout()
Where (at least in my case) self.presentationController?.containerView is the same object (i.e. an instance of UITransitionView) as self.view.superview?.superview.
Note that in case you use the same method to update your preferredContentSize while the viewController is not yet being presented:
Both self.presentationController?.containerView and self.view.superview?.superview are nil until the viewController is actually being presented, so using optional chaining in the first case should be a safe noop with no unwanted side effects while the viewController is yet not being presented.
In the second case, you would likely want to use self.viewIfLoaded?.superview?.superview, so as not to provoke a too early load of the view.
I'm having the hardest time implementing a presentation of a drawer sliding partway up on the screen on iPhone.
EDIT: I've discovered that iOS is not respecting the .custom modalTransitionStyle I've set in the Segue. If I set that explicitly in prepareForSegue:, then it calls my delegate to get the UIPresentationController.
I have a custom Segue that is also a UIViewControllerTransitioningDelegate. In the perform() method, I set the destination transitioningDelegate to self:
self.destination.transitioningDelegate = self
and I either call super.perform() (if it’s a Present Modal or Present as Popover Segue), or self.source.present(self.destination, animated: true) (if it’s a Custom Segue, because calling super.perform() throws an exception).
The perform() and animationController(…) methods get called, but never presentationController(forPresented…).
Initially I tried making the Segue in the Storyboard "Present Modally" with my custom Segue class specified, but that kept removing the presenting view controller. I tried "Present as Popover," and I swear it worked once, in that it didn't remove the presenting view controller, but then on subsequent attempts it still did.
So I made it "Custom," and perform() is still being called with a _UIFullscreenPresentationController pre-set on the destination view controller, and my presentationController(forPresented…) method is never called.
Other solutions dealing with this issue always hinge on some mis-written signature for the method. This is mine, verbatim:
public func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController?
I've spent the last four days trying to figure out “proper” custom transitions, and it doesn't help that things don’t seem to behave as advertised. What am I missing?
Instead of using a custom presentation segue, you could use a Container View for your drawer. This way, you can use a UIViewController for your Drawer content, while avoiding the issue with the custom segue.
You achieve this in two steps:
First pull a Container View into your main view controller and layout it properly. The storyboard would look like this: (You can see you have two view controllers. One for the main view and one for the drawer)
Second, you create an action that animates the drawer in and out as needed. One simple example could look like this:
#IBAction func toggleDrawer(_ sender: Any) {
let newHeight: CGFloat
if drawerHeightConstraint.constant > 0 {
newHeight = 0
} else {
newHeight = 200
}
UIView.animate(withDuration: 1) {
self.drawerHeightConstraint.constant = newHeight
self.view.layoutIfNeeded()
}
}
Here, I simply change the height constraint of the drawer, to slide it in and out. Of course you could do something more fancy :)
You can find a demo project here.
I am trying to display a child view controller over the top of all elements on screen (including navigation bars), and the only way I've found that works is to add it as a child view controller to my window's rootViewController:
guard let window = UIApplication.shared.keyWindow,
let view = window.rootViewController?.view
else { return }
window.rootViewController?.addChildViewController(attachmentViewController)
view.addSubview(attachmentViewController.view)
attachmentViewController.view.snp.makeConstraints { make in
make.left.equalTo(view)
make.right.equalTo(view)
make.top.equalTo(view)
make.bottom.equalTo(view)
}
attachmentViewController.didMove(toParentViewController: window.rootViewController)
However, this doesn't call the viewDidAppear or viewWillDisappear methods... Why is that? I really need it to.
Instead of doing all that, simply present the view controller (don't push it as suggested).
let destination = SomeViewController.instantiateFromStoryboard(self.storyboard!)
present(destination, animated: true, completion: nil)
Focusing on the "why is that?" of your question.
When you call addChildViewController to a view you're not changing the "stack" of view controllers at all or the state of the host view controller; you're just adding a view controller as a child controller of the main view.
Usually when you work with child view controllers you orchestrate calls like willMove and didMove to trigger the view controller lifecycle behaviour.
In your case, you may be better off with a push or present. Present will give you the capability of overlaying a view controller.
As a note, I have used an approach similar to what you describe for managing sign in/out states adding either a signed in child view controller or a signed out view controller. In which case, when they change I usually call methods like:
// To add the child
addChildViewController(child)
view.addSubview(child.view)
child.didMove(toParentViewController: self)
// To remove the child.
child.willMove(toParentViewController: nil)
child.removeFromParentViewController()
child.view.removeFromSuperview()
I see the description of this method in Apple says
func addChildViewController(_ childController: UIViewController)
This method is only intended to be called by an implementation of a custom container view controller. If you override this method, you must call super in your implementation.
I see, so many examples that people use addChildViewController everywhere without containerViewController.
For example: I did not use containerView. I added like in the below? İt is correct?
// Create child VC
let childVC = UIViewController()
// Set child VC
self.addChildViewController(childVC)
// Add child VC's view to parent
self.view.addSubview(childVC.view)
// Register child VC
childVC.didMove(toParentViewController: self)
// Setup constraints for layout
childVC.view.translatesAutoresizingMaskIntoConstraints = false
childVC.view.topAnchor.constraint(equalTo: heroView.bottomAnchor).isActive = true
childVC.view.leftAnchor.constraint(equalTo: self.view.leftAnchor).isActive = true
childVC.view.widthAnchor.constraint(equalTo: self.view.widthAnchor).isActive = true
childVC.view.heightAnchor.constraint(equalToConstant: height).isActive = true
Like the documentation says, that method is intended to be used by view controllers that can contain another view controller. An example would be the navigation and tab bar controllers.
If you implemented a custom controller that, for example, put one controller on the top half on the screen and one on the bottom half, when you set the bottomHalfViewController property, you would call the addChildViewController method to let your controller know that it should handle that view controller as it's child.
This means that it will forward all view lifecycle calls like viewWillAppear:
I am using a UISplitViewController, with the master and the detail viewcontrollers, without UINavigationControllers.
In some cases (for example when clicking on a universal link), I would like to force the app to always show the master viewcontroller.
How can I do that?
Is there a way to switch back from detail to master programmatically?
The split view controller is a beast, and the documentation is confusing. It is best understood by considering it as operating in two different modes: collapsed or not. Collapsed mode applies when the split view is presented in a horizontally compact view (i.e. iPhone), otherwise it is not collapsed (i.e. iPad).
The property preferredDisplayMode only applies if the view is NOT collapsed (i.e. iPad), and you can use this to select the master or detail view.
In collapsed mode, unless you are using navigation controllers, the original master view may be discarded:
After it has been collapsed, the split view controller reports having
only one child view controller in its viewControllers property. The
other view controller is collapsed into the other view controller’s
content with the help of the delegate object or discarded temporarily
But it is much better to use navigation controllers, as the split view controller is designed to work in conjunction with them:
The split view controller knows how to adjust the interface in more
intuitive ways. It even works with other container view controllers
(like navigation controllers) to present view controllers.
If you are using navigation controllers then the original master view may be at the bottom of the navigation stack:
In a horizontally compact environment, the split view controller acts
more like a navigation controller, displaying the primary view
controller initially and pushing or popping the secondary view
controller as needed
So you can do something like this:
if split.isCollapsed,
let nav = split.viewControllers[0] as? UINavigationController
{
nav.popToRootViewController(animated:false)
} else {
split.preferredDisplayMode = .allVisible
}
(It can get even more complicated if your master view pushes views in master as well as showing detail views. This code will pop to the root of the master view navigation stack)
You can set the preferredDisplayMode
self.splitViewController?.preferredDisplayMode = UISplitViewControllerDisplayMode.allVisible
Or if you are looking for something like a toggle action:
extension UISplitViewController {
func toggleMasterView() {
let barButtonItem = self.displayModeButtonItem
UIApplication.shared.sendAction(barButtonItem.action!, to: barButtonItem.target, from: nil, for: nil)
}
}
Usage:
self.splitViewController?.toggleMasterView()
You can define a custom UISplitViewController and assign it to your split view in storyboard:
import UIKit
class GlobalSplitViewController: UISplitViewController, UISplitViewControllerDelegate {
override func viewDidLoad() {
super.viewDidLoad()
self.delegate = self
}
func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController: UIViewController, onto primaryViewController: UIViewController) -> Bool {
return true
}
}
My solution is to swap the position of your primary and secondary ViewControllers if user is using an iPad. Then set preferredDisplayMode = .primaryHidden. Example code below.
splitViewVieController = UISplitViewController()
let isIphone = UIDevice.current.userInterfaceIdiom == .phone
splitViewVieController.viewControllers = isIphone ? [primaryNavController, seconaryNavController] : [seconaryNavController, primaryNavController]
splitViewVieController.preferredDisplayMode = .primaryHidden
We can change the position or width of the primary ViewController if needed.
splitViewVieController.maximumPrimaryColumnWidth = splitViewVieController.view.bounds.width
splitViewVieController.preferredPrimaryColumnWidthFraction = 0.5
splitViewVieController.primaryEdge = .trailing