Presenters in MvvmCross: Nested Modal Navigation in iOS

When navigating to a new view controller in iOS there are two primary ways to do so: pushing it to the navigation stack (the standard way), or "presenting" it which results in a modal-like experience where the new view comes up from the bottom of the screen. This interaction can be useful to convey context to the user, since it feels like a modal interaction that will return them to the current screen afterwards, rather than a navigation away from what they're doing.

MvvmCross ships with a variety of presenters in the box that you can make use of, two of which support modals - MvxModalSupportTouchViewPresenter and MvxModalNavSupportTouchViewPresenter. If you're using one of these presenters you can simply add the IMvxModalTouchView marker interface to a view class and they will automatically be presented as a modal. This is great, but these presenters have one main problem in their current implementations: if you're in a modal view, you can't present another modal view. If you try you'll be greeted with an exception saying "Only one modal view controller at a time supported". Let's take a look at how you can extend the built-in presenters to add support for this, allowing views at any nesting level to either pop a new modal, or navigate to a non-modal view.

Views and View Models

First we need some view models:

public class NonModalViewModel : MvxViewModel  
{
    public IMvxCommand PopModalCommand
    {
        get { return new MvxCommand(() => ShowViewModel<ModalViewModel>()); }
    }

    public IMvxCommand CloseCommand
    {
        get { return new MvxCommand(() => Close(this)); }
    }
}

public class ModalViewModel : MvxViewModel  
{
    public IMvxCommand PopModalCommand
    {
        get { return new MvxCommand(() => ShowViewModel<ModalViewModel>()); }
    }

    public IMvxCommand GoToNonModalCommand
    {
        get { return new MvxCommand(() => ShowViewModel<NonModalViewModel>()); }
    }

    public IMvxCommand CloseCommand
    {
        get { return new MvxCommand(() => Close(this)); }
    }
}

Then we'll need some simple views to trigger those commands:

public class NonModalView : MvxViewController  
{
    public override void ViewDidLoad()
    {
            base.ViewDidLoad();

      View = new UIView { BackgroundColor = UIColor.White };

        var popModal = UIButton.FromType(UIButtonType.System);
        popModal.Frame = new CGRect(10, 100, 300, 50);
        popModal.SetTitle("Pop modal", UIControlState.Normal);
        Add(popModal);

        var close = UIButton.FromType(UIButtonType.System);
        close.Frame = new CGRect(10, 300, 300, 50);
        close.SetTitle("Close", UIControlState.Normal);
        Add(close);

            var set = this.CreateBindingSet<NonModalView, NonModalViewModel>();
            set.Bind(popModal).To(vm => vm.PopModalCommand);
            set.Bind(close).To(vm => vm.CloseCommand);
            set.Bind(close).For("Clicked").To(vm => vm.CloseCommand);
            set.Apply();
    }
}

public class ModalView : MvxViewController, IMvxModalTouchView  
{
    public override void ViewDidLoad()
    {
        base.ViewDidLoad();

        View = new UIView { BackgroundColor = UIColor.White };

        var popModal = UIButton.FromType(UIButtonType.System);
        popModal.Frame = new CGRect(10, 100, 300, 50);
        popModal.SetTitle("Pop modal", UIControlState.Normal);
        Add(popModal);

        var goToNonModal = UIButton.FromType(UIButtonType.System);
        goToNonModal.Frame = new CGRect(10, 300, 300, 50);
        goToNonModal.SetTitle("Go to non-modal", UIControlState.Normal);
        Add(goToNonModal);

        var close = new UIBarButtonItem(UIBarButtonSystemItem.Cancel);
        NavigationItem.RightBarButtonItem = close;

        var set = this.CreateBindingSet<ModalView, ModalViewModel>();
        set.Bind(popModal).To(vm => vm.PopModalCommand);
        set.Bind(goToNonModal).To(vm => vm.GoToNonModalCommand);
        set.Bind(close).For("Clicked").To(vm => vm.CloseCommand);
        set.Apply();
    }
}

Presenter Setup

Now we can start building out our new presenter. One option would be to inherit from one of the existing modal presenters and extend it that way, but for this example I'll just inherit from MvxTouchViewPresenter to show how little code is actually required to make this work:

using System.Collections.Generic;  
using System.Linq;  
using Cirrious.MvvmCross.Touch.Views;  
using Cirrious.MvvmCross.Touch.Views.Presenters;  
using Cirrious.MvvmCross.ViewModels;  
using UIKit;

namespace NestedModalPresenterDemo  
{
    public class NestedModalPresenter : MvxTouchViewPresenter
    {
        public NestedModalPresenter(UIApplicationDelegate applicationDelegate, UIWindow window) 
            : base(applicationDelegate, window)
        {
        }
    }
}

Next, we need to maintain a stack of modal views:

private readonly Stack<UIViewController> _modalViewControllers = new Stack<UIViewController>();

protected override UIViewController CurrentTopViewController  
{
    get 
    {
        return _modalViewControllers.FirstOrDefault() ?? MasterNavigationController.TopViewController;
    }
}

private UINavigationController CurrentNavigationController  
{
    get { return (CurrentTopViewController as UINavigationController) ?? MasterNavigationController; }
}

It's important here to override the base implementation of CurrentTopViewController so that any operations that should apply to the top view, like navigating, are always happening to the correct one. Since each modal we pop is going to be wrapped in its own UINavigationController to allow it to do non-modal navigation as well, we also add a property here for grabbing the current navigation controller.

Showing Views

With that in place, we can add the logic needed to show both modal and non-modal views:

public override void Show(IMvxTouchView view)  
{
    var viewControllerToShow = (UIViewController)view;

    if (view is IMvxModalTouchView)
    {
        var newNav = new UINavigationController(viewControllerToShow);

        PresentModalViewController(newNav, true);

        return;
    }

    if (MasterNavigationController == null)
        ShowFirstView(viewControllerToShow);
    else
        CurrentNavigationController.PushViewController(viewControllerToShow, true);
}

public override bool PresentModalViewController(UIViewController viewController, bool animated)  
{
    CurrentNavigationController.PresentViewController(viewController, animated, null);

    _modalViewControllers.Push(viewController);

    return true;
}

If you've done any other presenter work before this should look pretty familiar, since there's not much going on here. For this example we'll stick with looking for IMvxModalTouchView to signal the modal, but if you're like me and not a fan of marker interfaces you could easily swap this out to look for something else like a custom attribute. When a modal view is shown it gets wrapped in a UINavigationController and then added to the modal stack to keep track of the state.

Closing Views

Now that we can show the modals, we just need to add the ability to properly close them:

public override void CloseModalViewController()  
{
    var currentNav = _modalViewControllers.Pop();

    currentNav.DismissViewController(true, null);
}

public override void Close(IMvxViewModel toClose)  
{
    if (_modalViewControllers.Any() && CurrentNavigationController.ViewControllers.Count() == 1)
    {
        CloseModalViewController();

        return;
    }

    CurrentNavigationController.PopViewController(true);
}

When a close request comes in when we're in some level of modal nesting and the current navigation controller's backstack is empty we close the modal and remove it from the modal stack. If the backstack is not empty we just pop the view normally.

I've omitted it here for simplicity, but it's also often a good idea to do some view model type checks to make sure you're operating on the correct views for the request.

comments powered by Disqus
Navigation