How would I dismiss a modal View Controller and also its parent that was pushed?
self.presentingViewController?.dismiss(animated: true, completion: {
self.parent?.navigationController?.popViewController(animated: true)
})
This only dismisses the top modal.
You can go another way.
First, you have UINavigationController in your Home view. So you can write an extension that will allow you to go to the controller, which is in the navigation stack.
I tried making an implementation like this:
extension UINavigationController {
func routingPath(for controller: UIViewController) -> [UIViewController] {
guard viewControllers.contains(controller) else {
return []
}
var result: [UIViewController] = []
for previousController in viewControllers {
result.append(previousController)
if controller === previousController {
break
}
}
return result
}
func performNavigation(toPrevious controller: UIViewController,
shouldDismissModals: Bool = true) {
let previousViewControllers = routingPath(for: controller)
guard !previousViewControllers.isEmpty else { return }
viewControllers = previousViewControllers
if shouldDismissModals, let _ = controller.presentedViewController {
controller.dismiss(animated: true, completion: nil)
}
}
}
Then you can make a special method for UIViewController:
extension UIViewController {
func returnBackIfPossible(to controller: UIViewController? = nil,
shouldDismissModals: Bool = true) {
navigationController?.performNavigation(toPrevious: controller ?? self,
shouldDismissModals: shouldDismissModals)
}
}
Then you need to pass a reference for a Home controller to all of the next controllers (or store it somewhere). Next, when needed, you can call to homeViewController?.returnBackIfPossible() method, which will close all modals and reset navigation stack.
What is non-modal parent exectly?
Is it a view controller pushed by the navigation controller?
If then, you must pop that view controller from navigation controller.
I am struggling to hide the navigationBar, which would properly be hidden if the root controller wasn't a SwiftUI UIHostingController.
I tried the following:
Setting navigationController.isNavigationBarHidden = true after creating it, at viewDidLoad and viewWillAppear.
Adding both .navigationBarHidden(true) and .navigationBarBackButtonHidden(true) for the UIHostingController's rootView.
Could it be an Apple bug? I am using Xcode 11.6.
All my attempts together:
class LoginController: UINavigationController, ObservableObject
{
static var newAccount: LoginController
{
let controller = LoginController()
let view = LoginViewStep1()
.navigationBarHidden(true)
.navigationBarBackButtonHidden(true)
controller.viewControllers = [UIHostingController(rootView: view)]
controller.isNavigationBarHidden = true
return controller
}
override func viewWillAppear(_ animated: Bool)
{
super.viewWillAppear(animated)
self.isNavigationBarHidden = true
}
override func viewDidLoad()
{
super.viewDidLoad()
self.isNavigationBarHidden = true
}
}
struct LoginViewStep1: View
{
// ...
var body: some View
{
VStack {
// ...
}
.navigationBarHidden(true)
.navigationBarBackButtonHidden(true)
}
}
Here is a solution. Tested with Xcode 11.4 / iOS 13.4
Modified your code:
class LoginController: UINavigationController, ObservableObject
{
static var newAccount: LoginController
{
let controller = LoginController()
let view = LoginViewStep1()
controller.viewControllers = [UIHostingController(rootView: view)]
// make it delayed, so view hierarchy become constructed !!!
DispatchQueue.main.async {
controller.isNavigationBarHidden = true
}
return controller
}
}
struct LoginViewStep1: View
{
var body: some View
{
VStack {
Text("Hello World!")
}
}
}
tested part in SceneDelegate
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = LoginController.newAccount
self.window = window
window.makeKeyAndVisible()
}
Alternate solution is to use UINavigationControllerDelegate:
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
// Required because pushing UIHostingController makes the navigationBar appear again
navigationController.isNavigationBarHidden = true
}
Tested with iOS 14.5 - Xcode 12.5
Currently I have a view controller that when you tap on the tab at index 3 (Map Tab) it loads a view controller that contains a map (Map A). NOTE: it should always load Map A every time the Map Tab is pressed. There is a chiclet at index 0 on the main tab that when tapped allows you to switch from Map A to Map B and then takes you to that map (This switch is done manually so to keep the tab bar on screen using the tab bar's selectedIndex which means the viewWillAppear() doesnt seem to be called). NOTE: Map A and Map B share the same viewController, a bool is used to differentiate which one to load in viewWillAppear..The issue I'm having is after the chiclet is pressed to switch from Map A to Map B, once I hit the Map Tab again on the tab bar it automatically loads the current map I was just on (Map B), but as stated earlier, when pressed from tab bar, it should only load Map A.
This is what I was trying but it still won't show the proper map after the tab has been pressed:
class MainTabBarController: UITabBarController {
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
switch viewController {
case is MapViewController:
let nc = NotificationCenter.default
nc.post(name: Notification.Name("changeMapStatus"), object: nil)
}
}
class MapViewController: BaseViewController {
var mapBSelected: Bool = false
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
mapBSelected ? setupMapB() : setupMapA()
}
#objc func changeMapStatus() {
guard let mainTabController = tabBarController as? MainTabBarController else { return }
mainTabController.refreshMapTab()
self.MapBSelected = false
}
}
func refreshMapTab() {
let index = PrimaryFeatureTab.map.displayOrder// enum to determine tab order
DispatchQueue.main.async {
self.viewControllers?.remove(at: index)
self.viewControllers?.insert(PrimaryFeatureTab.map.rootViewController, at: index)
}
}
}
You can do the following
Create a closure in your FirstViewController like this:
enum MapType {
case mapA
case mapB
}
class MainViewController: UIViewController {
var mapSelectionClosure: ((MapType) -> Void)?
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
}
#IBAction func aTapped(_ sender: Any) {
mapSelectionClosure?(.mapA)
}
#IBAction func bTapped(_ sender: Any) {
mapSelectionClosure?(.mapB)
}
}
Then you can set this closure in your MainTabBarController like this
class MainTabBarController: UITabBarController {
override func viewDidLoad() {
super.viewDidLoad()
guard let mainViewController = viewControllers?.first(where: { $0 is MainViewController }) as? MainViewController else {
return
}
mainViewController.mapSelectionClosure = { mapType in
self.setMapType(mapType)
}
}
func setMapType(_ type: MapType) {
guard let mapViewController = viewControllers?.first(where: { $0 is MapViewController }) as? MapViewController else {
return
}
mapViewController.selectedMapType = type
}
}
In MapViewController...
class MapViewController: BaseViewController {
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
self.setupMapA()
}
}
Now, when you leave the Map Tab, it will reset itself to Map A.
No need for any code in a custom TabBarController.
I have a custom action sheet viewController. Which is presented modally on the current top view controller. Like this:
//MARK: Static Func
static func initViewController() -> CustomActionSheetViewController {
let customActionSheetViewController = CustomActionSheetViewController(nibName: "CustomActionSheetViewController", bundle: nil)
return customActionSheetViewController
}
func presentViewController<T: UIViewController>(viewController: T) {
DispatchQueue.main.async {
if let topViewController = UIApplication.getTopViewController() {
viewController.modalTransitionStyle = .crossDissolve
viewController.modalPresentationStyle = .overCurrentContext
topViewController.present(viewController, animated: true, completion: nil)
}
}
}
// MARK: UIApplication extensions
extension UIApplication {
class func getTopViewController(base: UIViewController? = UIApplication.shared.keyWindow?.rootViewController) -> UIViewController? {
if let nav = base as? UINavigationController {
return getTopViewController(base: nav.visibleViewController)
} else if let tab = base as? UITabBarController, let selected = tab.selectedViewController {
return getTopViewController(base: selected)
} else if let presented = base?.presentedViewController {
return getTopViewController(base: presented)
}
return base
}
}
And I am dismissing it like this:
#objc func dismissViewController() {
DispatchQueue.main.async {
if let topViewController = UIApplication.getTopViewController() {
topViewController.dismiss(animated: true, completion: nil)
}
NotificationCenter.default.removeObserver(self)
}
}
It's working perfectly fine. I have added the notification observer in my customTabbarController, to dismiss the action sheet if user tap on some another tabbar button like this:
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
// print("Selected view controller", viewController)
// print("index", tabBarController.selectedIndex )
let tabbarNotiKey = Notification.Name(rawValue: "TabbarNotiKey")
NotificationCenter.default.post(name: tabbarNotiKey, object: nil, userInfo: nil)
}
The action sheet is right now presenting on Home tab > Profile (by push) > Action sheet (by modal). So if I tap on Home tab again it will dismiss the action sheet viewController and come back to Home perfectly. But if I tap on some other tabbar button rather than home and come back home, it shows a black screen. What I am missing here? Any suggestions would be highly appreciable.
I guess your code calls dismissViewController() twice.
When you press other tab, it removes the action sheet
And then you click home tab and it calls dismissViewController again, and now it removes the homescreenVC
Before iOS 13, presented view controllers used to cover the entire screen. And, when dismissed, the parent view controller viewDidAppear function were executed.
Now iOS 13 will present view controllers as a sheet as default, which means the card will partially cover the underlying view controller, which means that viewDidAppear will not be called, because the parent view controller has never actually disappeared.
Is there a way to detect that the presented view controller sheet was dismissed? Some other function I can override in the parent view controller rather than using some sort of delegate?
Is there a way to detect that the presented view controller sheet was dismissed?
Yes.
Some other function I can override in the parent view controller rather than using some sort of delegate?
No. "Some sort of delegate" is how you do it. Make yourself the presentation controller's delegate and override presentationControllerDidDismiss(_:).
https://developer.apple.com/documentation/uikit/uiadaptivepresentationcontrollerdelegate/3229889-presentationcontrollerdiddismiss
The lack of a general runtime-generated event informing you that a presented view controller, whether fullscreen or not, has been dismissed, is indeed troublesome; but it's not a new issue, because there have always been non-fullscreen presented view controllers. It's just that now (in iOS 13) there are more of them! I devote a separate question-and-answer to this topic elsewhere: Unified UIViewController "became frontmost" detection?.
Here's a code example of a parent view-controller which is notified when the child view-controller it presents as a sheet (i.e., in the default iOS 13 manner) is dismissed:
public final class Parent: UIViewController, UIAdaptivePresentationControllerDelegate
{
// This is assuming that the segue is a storyboard segue;
// if you're manually presenting, just set the delegate there.
public override func prepare(for segue: UIStoryboardSegue, sender: Any?)
{
if segue.identifier == "mySegue" {
segue.destination.presentationController?.delegate = self;
}
}
public func presentationControllerDidDismiss(
_ presentationController: UIPresentationController)
{
// Only called when the sheet is dismissed by DRAGGING.
// You'll need something extra if you call .dismiss() on the child.
// (I found that overriding dismiss in the child and calling
// presentationController.delegate?.presentationControllerDidDismiss
// works well).
}
}
Jerland2's answer is confused, since (a) the original questioner wanted to get a function call when the sheet is dismissed (whereas he implemented presentationControllerDidAttemptToDismiss, which is called when the user tries and fails to dismiss the sheet), and (b) setting isModalInPresentation is entirely orthogonal and in fact will make the presented sheet undismissable (which is the opposite of what OP wants).
For future readers here is a more complete answer with implementation:
In the root view controllers prepare for segue add the following (Assuming your modal has a nav controller)
// Modal Dismiss iOS 13
modalNavController.presentationController?.delegate = modalVc
In the modal view controller add the following delegate + method
// MARK: - iOS 13 Modal (Swipe to Dismiss)
extension ModalViewController: UIAdaptivePresentationControllerDelegate {
func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
print("slide to dismiss stopped")
self.dismiss(animated: true, completion: nil)
}
}
Ensure in the modal View Controller that the following property is true in order for the delegate method to be called
self.isModalInPresentation = true
Profit
Another option to get back viewWillAppear and viewDidAppear is set
let vc = UIViewController()
vc.modalPresentationStyle = .fullScreen
this option cover full screen and after dismiss, calls above methods
Swift
General Solution to call viewWillAppear in iOS13
class ViewController: UIViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
print("viewWillAppear")
}
//Show new viewController
#IBAction func show(_ sender: Any) {
let newViewController = NewViewController()
//set delegate of UIAdaptivePresentationControllerDelegate to self
newViewController.presentationController?.delegate = self
present(newViewController, animated: true, completion: nil)
}
}
extension UIViewController: UIAdaptivePresentationControllerDelegate {
public func presentationControllerDidDismiss( _ presentationController: UIPresentationController) {
if #available(iOS 13, *) {
//Call viewWillAppear only in iOS 13
viewWillAppear(true)
}
}
}
If you want to do something when user closes the modal sheet from within that sheet.
Let's assume you already have some Close button with an #IBAction and a logic to show an alert before closing or do something else. You just want to detect the moment when user makes push down on such a controller.
Here's how:
class MyModalSheetViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
self.presentationController?.delegate = self
}
#IBAction func closeAction(_ sender: Any) {
// your logic to decide to close or not, when to close, etc.
}
}
extension MyModalSheetViewController: UIAdaptivePresentationControllerDelegate {
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
return false // <-prevents the modal sheet from being closed
}
func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
closeAction(self) // <- called after the modal sheet was prevented from being closed and leads to your own logic
}
}
Override viewWillDisappear on the UIViewController that's being dismissed. It will alert you to a dismissal via isBeingDismissed boolean flag.
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if isBeingDismissed {
print("user is dismissing the vc")
}
}
** If the user is halfway through the swipe down and swipes the card back up, it'll still register as being dismissed, even if the card is not dismissed. But that's an edge case you may not care about.
DRAG OR CALL DISMISS FUNC will work with below code.
1) In root view controller, you tell that which is its presentation view controller as below code
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "presenterID" {
let navigationController = segue.destination as! UINavigationController
if #available(iOS 13.0, *) {
let controller = navigationController.topViewController as! presentationviewcontroller
// Modal Dismiss iOS 13
controller.presentationController?.delegate = self
} else {
// Fallback on earlier versions
}
navigationController.presentationController?.delegate = self
}
}
2) Again in the root view controller, you tell what you will do when its presentation view controller is dissmised
public func presentationControllerDidDismiss(
_ presentationController: UIPresentationController)
{
print("presentationControllerDidDismiss")
}
1) In the presentation view controller, When you hit cancel or save button in this picture. Below code will be called.The
self.dismiss(animated: true) {
self.presentationController?.delegate?.presentationControllerDidDismiss?(self.presentationController!)
}
in SwiftUI you can use onDismiss closure
func sheet<Item, Content>(item: Binding<Item?>, onDismiss: (() -> Void)?, content: (Item) -> Content) -> some View
If someone doesn't have access to the presented view controller, they can just override the following method in presenting view controller and change the modalPresentationStyle to fullScreen or can add one of the strategies mentioned above with this approach
override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) {
if let _ = viewControllerToPresent as? TargetVC {
viewControllerToPresent.modalPresentationStyle = .fullScreen
}
super.present(viewControllerToPresent, animated: flag, completion: completion)
}
if presented view controller is navigation controller and you want to check the root controller, can change the above condition to be like
if let _ = (viewControllerToPresent as? UINavigationController)?.viewControllers.first as? TargetVC {
viewControllerToPresent.modalPresentationStyle = .fullScreen
}
If you used the ModalPresentationStyle in FullScreen, the behavior of the controller is back as usual.
ConsultarController controllerConsultar = this.Storyboard.InstantiateViewController("ConsultarController") as ConsultarController;
controllerConsultar.ModalPresentationStyle = UIModalPresentationStyle.FullScreen;
this.NavigationController.PushViewController(controllerConsultar, true);
From my point of view, Apple should not set pageSheet is the default modalPresentationStyle
I'd like to bring fullScreen style back to default by using swizzling
Like this:
private func _swizzling(forClass: AnyClass, originalSelector: Selector, swizzledSelector: Selector) {
if let originalMethod = class_getInstanceMethod(forClass, originalSelector),
let swizzledMethod = class_getInstanceMethod(forClass, swizzledSelector) {
method_exchangeImplementations(originalMethod, swizzledMethod)
}
}
extension UIViewController {
static func preventPageSheetPresentationStyle () {
UIViewController.preventPageSheetPresentation
}
static let preventPageSheetPresentation: Void = {
if #available(iOS 13, *) {
_swizzling(forClass: UIViewController.self,
originalSelector: #selector(present(_: animated: completion:)),
swizzledSelector: #selector(_swizzledPresent(_: animated: completion:)))
}
}()
#available(iOS 13.0, *)
private func _swizzledPresent(_ viewControllerToPresent: UIViewController,
animated flag: Bool,
completion: (() -> Void)? = nil) {
if viewControllerToPresent.modalPresentationStyle == .pageSheet
|| viewControllerToPresent.modalPresentationStyle == .automatic {
viewControllerToPresent.modalPresentationStyle = .fullScreen
}
_swizzledPresent(viewControllerToPresent, animated: flag, completion: completion)
}
}
And then put this line to your AppDelegate
UIViewController.preventPageSheetPresentationStyle()
wouldn't it be simple to call the presentingViewController.viewWillAppear?
befor dismissing?
self.presentingViewController?.viewWillAppear(false)
self.dismiss(animated: true, completion: nil)