Mvvmcross: [iOS 13] Closing a non-fullscreen modal using gesture doesn't close ViewModel

Created on 13 Sep 2019  Â·  4Comments  Â·  Source: MvvmCross/MvvmCross

🐛 Bug Report

iOS 13 introduces new modal presentation styles and dismissal behaviour for non-fullscreen modals - a user can now swipe down to dismiss modals instead of needing a dedicated bar button that would traditionally call NavigationService.Close() on the VM (more info here).

When we attempt to close these modals via swiping, the view does close but the MvxNavigationService doesn't remove the closed view from the stack. When we then try to present another view, the system complains with a Warning: Attempt to present <MvvmCross_Platforms_Ios_Views_MvxNavigationController: 0x7fa961d32a00> on <MvvmCross_Platforms_Ios_Views_MvxNavigationController: 0x7fa963757400> whose view is not in the window hierarchy! output, and the navigation fails.

Expected behavior

Swiping down to dismiss an iOS 13 non-fullscreen modal closes the ViewModel and allows new views to be presented.

Reproduction steps

  1. Create a new MvxViewController with an MvxModalPresentationAttribute that has a ModalPresentationStyle that is not fullscreen (UIModalPresentationStyle.FormSheet for example).
  2. Ensure that the ModalInPresentation property on the MvxViewController is false, if set to true it will prevent dismissal by swiping down.
  3. Use MvxNavigationService to present your new view, then dismiss it by pulling down on the modal.
  4. Attempt to open that view again, or any other view in the app. Navigation will fail with the error Warning: Attempt to present <MvvmCross_Platforms_Ios_Views_MvxNavigationController: 0x7fa961d32a00> on <MvvmCross_Platforms_Ios_Views_MvxNavigationController: 0x7fa963757400> whose view is not in the window hierarchy!. If you look in the navigation stack, you will find that the ViewModel attached to the view you dismissed is still in the stack.

Workarounds

There are two main workarounds right now:

  1. In any view controller that uses this new modal presentation style, set ModalInPresentation to true. This will prevent the swipe down to dismiss with a bounce back animation while still using the shiny new presentation style.
  2. Set ModalPresentationStyle in the MvxModalPresentationAttribute to be UIModalPresentationStyle.Fullscreen. This will present the modal as it would have pre-iOS13.

Configuration

Version: 6.3.0

Platform:

  • [x] :iphone: iOS
  • [ ] :robot: Android
  • [ ] :checkered_flag: WPF
  • [ ] :earth_americas: UWP
  • [ ] :apple: MacOS
  • [ ] :tv: tvOS
  • [ ] :monkey: Xamarin.Forms
ios needs-investigation bug

Most helpful comment

This is my first ever answer/solution. I hope it can help...

1. We need a custom UIAdaptivePresentationControllerDelegate

    public class CustomPresentationControllerDelegate : UIAdaptivePresentationControllerDelegate
    {
        private Func<Task> dismissModal;

        public CustomPresentationControllerDelegate(Func<Task> disposeViewAsync)
        {
            dismissModal = disposeViewAsync;
        }

        [Export("presentationControllerDidDismiss:")]
        public override void DidDismiss(UIPresentationController presentationController)
        {
            dismissModal();
        }
    }

2. Attach the custom delegate to the parent ViewController (in the custom renderer class)

public override void WillMoveToParentViewController(UIViewController parent)
        {
            base.WillMoveToParentViewController(parent);

            parent.PresentationController.Delegate = new CustomPresentationStyleDelegate(DismissModalAsync);
        }

3. Call the necessary methods to PopModal the view

       public async Task DismissModalAsync()
        {
            await this.Element.Navigation.PopModalAsync();
        }

That's it!

All 4 comments

There was a good write up about this on Medium. Importantly

Detecting Dismissal

As noted earlier, some apps might need to execute some code when a modally presented view controller is dismissed using a Cancel, Done or Save button (other than just dismissing it). For example, you might need to restart a timer in a game, or act upon some information that the user changed in the presented view controller. That code won’t be executed if the user dismisses with a swipe. Your button isn’t pressed, so its action handler won’t be called. This could break the behaviour of your app.


The simplest way to avoid this problem is to prevent the interactive dismissal using isModalInPresentation. The user will have to tap a button to dismiss the view controller, just as they did before iOS 13. There is another way



iOS 13 adds some new UIAdaptivePresentationControllerDelegate methods. These allow another object (typically the presenting view controller) to control whether the interactive dismissal should be allowed (an alternative to using isModalInPresentation), and to be informed when the interactive dismissal begins or completes. These methods are well-documented and clearly explained in WWDC 2019 224: Modernizing Your UI for IOS 13 starting at 15 minutes. Note that presentationControllerWillDismiss can be called multiple times if the user starts swiping to dismiss, changes their mind and then swipes again. The presentationControllerDidDismiss method is where you need to execute the extra code that currently occurs when a Cancel, Done or Save button is pressed (of course, you don’t need to dismiss the presented view controller). These methods won’t be called if the view controller is dismissed programmatically. Therefore you will still need to execute your code in the button handler (or your own delegate) that triggers the dismissal, even when running on ios 13.


The presentationControllerDidAttemptToDismiss delegate method is interesting. It will be called if the user tries to swipe to dismiss but isModalInPresentation resulted in the dismissal being blocked. The WWDC video suggests showing an action sheet (which will be a popover on iPad) asking if the user wants to abandon or save their changes. This seems like a really good idea if the presented view controller has Cancel and Save/Done buttons: creating a new note, editing the properties for an object etc.


For a nested view controller on a navigation stack with Cancel and Save buttons (for example, the Timer Profiles screen inside Pommie’s Settings screen), I think it’s more complicated. The code for performing the save is probably in the view controller one level higher in the stack (the delegate of the top view controller), and not in the object that would be the UIAdaptivePresentationControllerDelegate. Trying to route the user’s choice to the object that can perform the save might be pretty messy. In my own apps, I think I will just block the dismissal in view controllers that require an explicit cancel/save action if they are not at the top of a navigation stack.

This is my first ever answer/solution. I hope it can help...

1. We need a custom UIAdaptivePresentationControllerDelegate

    public class CustomPresentationControllerDelegate : UIAdaptivePresentationControllerDelegate
    {
        private Func<Task> dismissModal;

        public CustomPresentationControllerDelegate(Func<Task> disposeViewAsync)
        {
            dismissModal = disposeViewAsync;
        }

        [Export("presentationControllerDidDismiss:")]
        public override void DidDismiss(UIPresentationController presentationController)
        {
            dismissModal();
        }
    }

2. Attach the custom delegate to the parent ViewController (in the custom renderer class)

public override void WillMoveToParentViewController(UIViewController parent)
        {
            base.WillMoveToParentViewController(parent);

            parent.PresentationController.Delegate = new CustomPresentationStyleDelegate(DismissModalAsync);
        }

3. Call the necessary methods to PopModal the view

       public async Task DismissModalAsync()
        {
            await this.Element.Navigation.PopModalAsync();
        }

That's it!

I have a slightly different problem.
In my case if I close the modal windows by the new swipe down gesture, the navigation bar plain button that opened the modal windows in the first place stays disabled...

I tried @numan98 's solution, but the Element property is missing. It seems to be a Xamarin Forms workaround, while I'm on native. Otherwise it seemed to be compatible, so replaced the 'PopModalAsync' part with 'MvxIosViewPresenter.CloseModalViewController' but to no avail.

This is what i did to solve this issue:

[MvxModalPresentation(ModalPresentationStyle = UIModalPresentationStyle.PageSheet)]
public partial class MyModalViewController : MvxViewController<MyModalViewModel>, IUIAdaptivePresentationControllerDelegate
{
    [Export("presentationControllerDidDismiss:")]
    public void DidDismiss(UIPresentationController presentationController)
    {
        ViewModel.NavigationService.Close(ViewModel);
    }
}

You have to inject IMvxNavigationService into your ViewModel for this to work.

Was this page helpful?
0 / 5 - 0 ratings