Creating a Xamarin.Android Navigation Service for MVVM Light to use with any Activity

The MVVM Light framework for Xamarin.Android provides a NavigationService allowing for navigation to views to be invoked from code in a View Model command handler for example.

The NavigationService has the limitation that an Activity class must derive from an ActivityBase class provided by MVVM Light. This itself derives from Activity so the type of views which can be used are limited.

I recently ran into this problem whereby I needed to use a view with a TabLayout which requires the Activity to derive from FragmentActivity. The purpose of this post is to provide an example which is based upon the MVVM Light NavigationService but will work with any Activity type and doesn't require deriving from ActivityBase.

The source code can be found on GitHub.

Overview

The code presented is the NavigationService with very minimal changes - The class works great apart from the ActivityBase inheritance limitation.

I found a great post by Jim Bennett MVVMLight navigation and AppCompatActivity which presents a solution to the problem in the context of using AppCompatActivity. If you are in this scenario take a read because Jim provides code on GitHub and a Nuget package so this would be your quickest solution.

At first I tried to just use the Activity deriving from FragmentActivity with the NavigationService and it worked to navigate to the view, but blew up with an exception when navigating back so be aware of this.

I have used the Plugin.CurrentActivty Nuget package written by James Montemagno. See his post Access the Current Android Activty from Anywhere! for details of how to use this elsewhere in your Xamarin.Android app. James also provides a Nuget package Plugin.currentActivty.

In additon the DialogService and DispatcherHelper classes depend upon ActivityBase so I have provided examples using the Plugin.CurrentActivty rather than the ActivityBase.

The example app provides a Home View and a Child View. There are buttons on each page to navigate to the other page. The Home View has an EditText into which an integer parameter can be entered. This simulates passing an Id as a navigation parameter to the Child View, the value is displayed on the Child View.

The Child View has a second button titled Show Message which demonstrates the AlternateDispatcherHelper and AlternateDialogService to show a Message Box.

The images below show the example app:-

The Home View

The Child View

The Message Box

Resources

Key Points

  • The Nuget package Plugin.CurrentActivty should be added to the Xamarin.Android app project.

  • Use the AlternateNavigationService implementation of the INavigationService interface in conjunction with the NavigationHelper. Your view can derive from any Xamarin.Android Activity class but must implement the interface INavigationView.

  • Instantiate an instance of NavigationHelper and provide a get property to satisfy the INavigationService interface.

  • Override OnResume and call the OnResume method on the NavigationHelper and the base implementation.

  • Register the AlternateNavigationService and AlternateDialogService with the IOC container (SimpleIOC in this example)

  • An example view excerpt is shown below showing the required implementation of these points:-

[Activity(Label = "Child View")]
public class ChildView : Activity, INavigationView  
{
    private readonly NavigationHelper navigationHelper = new NavigationHelper();

    public NavigationHelper Helper
    {
        get
        {
            return navigationHelper;
        }
    }

    protected override void OnResume()
    {
        Helper.OnResume(this);

        base.OnResume();
    }
}

Adding the Nuget Packages

To start with we need to add the Nuget package Plugin.CurrentActivty:-

When installed this will add a MainApplication.cs file and instructions will be displayed in a ReadMe.txt file:-

Example Navigation Service

The AlternateNavigationService is basically the MVVM Light NavigationService class copied and modifed to use the Plugin.CurrentActivity and the NavigationHelper via the INavigationView interface.

Essentially the properties and methods provided by the ActivityBase class are instead provided by the NavigationHelper class. I have removed the XML documentation comments from the code listings below but they are present in the source code on GitHub.

The INavigationView interface (in the Navigation folder) can be seen below:-

namespace NavigationExample.Navigation  
{
    public interface INavigationView
    {
        NavigationHelper Helper { get; }
    }
}

The NavigationHelper class (in the Navigation folder) can be seen below:-

public class NavigationHelper  
{
    public Activity CurrentActivity { get; set; }

    public string ActivityKey { get; set; }

    public string NextPageKey { get; set; }

    public void GoBack()
    {
        if (CurrentActivity != null)
        {
            CurrentActivity.OnBackPressed();
        }
    }

    public void OnResume(Activity view)
    {
        CurrentActivity = view;

        if (string.IsNullOrEmpty(ActivityKey))
        {
            ActivityKey = NextPageKey;
            NextPageKey = null;
        }
    }
}

The AlternateNavigationService class (in the Navigation folder) is modifed to call the methods on the NavigationHelper class rather than the ActivityBase and the CurrentView property is provided by the Plugin.CurrentActivity :-

public INavigationView CurrentView  
{
    get { return CrossCurrentActivity.Current.Activity as INavigationView; }
}

The class listing can be seen below:-

public class AlternateNavigationService : INavigationService  
{
    public INavigationView CurrentView
    {
        get { return CrossCurrentActivity.Current.Activity as INavigationView; }
    }

    public const string RootPageKey = "-- ROOT --";

    private const string ParameterKeyName = "ParameterKey";

    private readonly Dictionary<string, Type> _pagesByKey = new Dictionary<string, Type>();
    private readonly Dictionary<string, object> _parametersByKey = new Dictionary<string, object>();

    public string CurrentPageKey
    {
        get
        {
            return CurrentView.Helper.ActivityKey ?? RootPageKey;
        }
    }

    public void Configure(string key, Type activityType)
    {
        lock (_pagesByKey)
        {
            if (_pagesByKey.ContainsKey(key))
            {
                _pagesByKey[key] = activityType;
            }
            else
            {
                _pagesByKey.Add(key, activityType);
            }
        }
    }

    public object GetAndRemoveParameter(Intent intent)
    {
        if (intent == null)
        {
            throw new ArgumentNullException("intent",
                "This method must be called with a valid Activity intent");
        }

        var key = intent.GetStringExtra(ParameterKeyName);
        intent.RemoveExtra(ParameterKeyName);

        if (string.IsNullOrEmpty(key))
        {
            return null;
        }

        lock (_parametersByKey)
        {
            if (_parametersByKey.ContainsKey(key))
            {
                var param = _parametersByKey[key];
                _parametersByKey.Remove(key);
                return param;
            }

            return null;
        }
    }

    public T GetAndRemoveParameter<T>(Intent intent)
    {
        return (T)GetAndRemoveParameter(intent);
    }

    public void GoBack()
    {
        CurrentView.Helper.GoBack();
    }

    public void NavigateTo(string pageKey)
    {
        NavigateTo(pageKey, null);
    }

    public void NavigateTo(string pageKey, object parameter)
    {
        if (CurrentView.Helper.CurrentActivity == null)
        {
            throw new InvalidOperationException("No CurrentActivity found");
        }

        lock (_pagesByKey)
        {
            if (!_pagesByKey.ContainsKey(pageKey))
            {
                throw new ArgumentException(
                    string.Format(
                        "No such page: {0}. Did you forget to call NavigationService.Configure?",
                        pageKey),
                        "pageKey");
            }

            var intent = new Intent(CurrentView.Helper.CurrentActivity, _pagesByKey[pageKey]);

            if (parameter != null)
            {
                lock (_parametersByKey)
                {
                    var guid = Guid.NewGuid().ToString();
                    _parametersByKey.Add(guid, parameter);
                    intent.PutExtra(ParameterKeyName, guid);
                }
            }

            CurrentView.Helper.CurrentActivity.StartActivity(intent);
            CurrentView.Helper.NextPageKey = pageKey;
        }
    }
}

The AlternateDialogService class (in the Navigation folder) is changed by a single line only in the CreateDialog method:-

from:-
var builder = new AlertDialog.Builder(ActivityBase.CurrentActivity);

to:-
var builder = new AlertDialog.Builder(CrossCurrentActivity.Current.Activity);

The listing for the CreateDialog method can be seen below:-

public class AlternateDialogService : IDialogService  
{
     ....

    private static AlertDialogInfo CreateDialog(
        string content,
        string title,
        string okText = null,
        string cancelText = null,
        Action<bool> afterHideCallbackWithResponse = null)
    {
        var tcs = new TaskCompletionSource<bool>();

        var builder = new AlertDialog.Builder(CrossCurrentActivity.Current.Activity);

        builder.SetMessage(content);
        builder.SetTitle(title);

        AlertDialog dialog = null;

        builder.SetPositiveButton(
            okText ?? "OK",
            (d, index) =>
            {
                tcs.TrySetResult(true);

                // ReSharper disable AccessToModifiedClosure
                if (dialog != null)
                {
                    dialog.Dismiss();
                    dialog.Dispose();
                }

                if (afterHideCallbackWithResponse != null)
                {
                    afterHideCallbackWithResponse(true);
                }
                // ReSharper restore AccessToModifiedClosure
            });

        if (cancelText != null)
        {
            builder.SetNegativeButton(
                cancelText,
                (d, index) =>
                {
                    tcs.TrySetResult(false);

                    // ReSharper disable AccessToModifiedClosure
                    if (dialog != null)
                    {
                        dialog.Dismiss();
                        dialog.Dispose();
                    }

                    if (afterHideCallbackWithResponse != null)
                    {
                        afterHideCallbackWithResponse(false);
                    }
                    // ReSharper restore AccessToModifiedClosure
                });
        }

        builder.SetOnDismissListener(
            new OnDismissListener(
                () =>
                {
                    tcs.TrySetResult(false);

                    if (afterHideCallbackWithResponse != null)
                    {
                        afterHideCallbackWithResponse(false);
                    }
                }));

        dialog = builder.Create();

        return new AlertDialogInfo
        {
            Dialog = dialog,
            Tcs = tcs
        };
    }

    ....
}

The AlternateDispatcherHelper class (in the Threading folder) is modified by a single line change in the CheckBeginInvokeOnUI method:-

from:-
ActivityBase.CurrentActivity.RunOnUiThread(action); to:-
CrossCurrentActivity.Current.Activity.RunOnUiThread(action);

In the CheckDispatcher method the if statement is changed
from:-
if (ActivityBase.CurrentActivity == null)

to:-
if (CrossCurrentActivity.Current.Activity == null)

The error message is also changed to remove the comment about ActivityBase.

The class listing can be seen below:-

public static class AlternateDispatcherHelper  
{
    public static void CheckBeginInvokeOnUI(Action action)
    {
        if (action == null)
        {
            return;
        }

        CheckDispatcher();
        CrossCurrentActivity.Current.Activity.RunOnUiThread(action);
    }

    public static void Initialize()
    {
    }

    public static void Reset()
    {
    }

    private static void CheckDispatcher()
    {
        if (CrossCurrentActivity.Current.Activity == null)
        {
            var error = new StringBuilder("The AlternateDispatcherHelper cannot be called.");

            throw new InvalidOperationException(error.ToString());
        }
    }
}

Using the Alternate classes

To use the AlternateNavigationService and AlternateDialogService classes they must be registered with the IOC container. In the example this is done in the Bootstrapper class, which provides the static property to access the ViewModelLocator and also configures the pages with the AlternateNavigationService:-

public static class Bootstrapper  
{
    private static ViewModelLocator locator;

    public static ViewModelLocator Locator
    {
        get
        {
            if (locator == null)
            {
                DispatcherHelper.Initialize();

                var nav = new AlternateNavigationService();
                SimpleIoc.Default.Register<INavigationService>(() => nav);

                //register the pages with the navigation service
                nav.Configure(ViewModelLocator.MainPageKey, typeof(MainView));
                nav.Configure(ViewModelLocator.ChildPageKey, typeof(ChildView));

                SimpleIoc.Default.Register<IDialogService, AlternateDialogService>();

                locator = new ViewModelLocator();
            }

            return locator;
        }
    }
}

The ViewModelLocator class registers the View Models with the IOC container and provides properties so the View classes (Xamarin.Android Activity classes) can obtain the ViewModels. It also provides the string definitions for the page keys used with the AlternateNavigationService. The class listing is:-

public class ViewModelLocator  
{
    public const string MainPageKey = "MainPage";
    public const string ChildPageKey = "ChildPage";

    public ViewModelLocator()
    {
        ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);

        SimpleIoc.Default.Register<MainViewModel>();
        SimpleIoc.Default.Register<ChildViewModel>();
    }

    public MainViewModel Main
    {
        get
        {
            return ServiceLocator.Current.GetInstance<MainViewModel>();
        }
    }

    public ChildViewModel Child
    {
        get
        {
            return ServiceLocator.Current.GetInstance<ChildViewModel>();
        }
    }

    public static void Cleanup()
    {
        // TODO Clear the ViewModels
    }
}

The View's (Xamarin.Android Activity classes) must implement the INavigationView interface and in doing so provide a property on type NavigationHelper and an override on OnResume which calls the NavigationHelpers OnResume. Make sure to call the OnResume method on both the NavigationHelper and the base class.

The Home View screen is provided by the MainView class:-

[Activity(Label = "Home View", MainLauncher = true, Icon = "@mipmap/icon")]
public class MainView : Activity, INavigationView  
{
    private readonly List<Binding> bindings = new List<Binding>();

    private readonly NavigationHelper navigationHelper = new NavigationHelper();

    public NavigationHelper Helper
    {
        get
        {
            return navigationHelper;
        }
    }

    protected override void OnResume()
    {
        Helper.OnResume(this);

        base.OnResume();
    }

    private MainViewModel Vm
    {
        get
        {
            return Bootstrapper.Locator.Main;
        }
    }

    private Button navigateChildButton;

    public Button NavigateChildButton
    {
        get
        {
            return navigateChildButton ??
                       (navigateChildButton = FindViewById<Button>(
                       Resource.Id.navChildButton));
        }
    }

    private EditText numberEditText;
    public EditText NumberEditText
    {
        get
        {
            return numberEditText ??
                  (numberEditText = FindViewById<EditText>(
                   Resource.Id.paramEditText));
        }
    }

    protected override void OnCreate(Bundle savedInstanceState)
    {
        base.OnCreate(savedInstanceState);

        // Set our view from the "main" layout resource
        SetContentView(Resource.Layout.Main);

        NavigateChildButton.SetCommand("Click", Vm.NavigateChildCommand);

        //prevent agressive linker removing 'Click' event
        NavigateChildButton.Click += (sender, e) => { };

        bindings.Add(this.SetBinding(
                () => Vm.Parameter,
                () => NumberEditText.Text,
                BindingMode.TwoWay));
    }
}

The MainViewModel uses the AlternateNavigationService to show the Child View:-

public class MainViewModel : ViewModelBase  
{
    protected readonly INavigationService navigationService;

    private int parameter;

    private RelayCommand navigateChildCommand;

    public INavigationService NavigationService
    {
        get { return navigationService; }
    }

    public int Parameter
    {
        get
        {
            return parameter;
        }
        set
        {
            Set(ref parameter, value);}
        }
    }
    public RelayCommand NavigateChildCommand
    {
        get
        {
            return navigateChildCommand ?? 
                       (navigateChildCommand = new RelayCommand(
                       () =>
                       {
                           navigationService.NavigateTo(ViewModelLocator.ChildPageKey,
                                                        new ChildNavigationParameter()
                                                       { Id = Parameter });
                       }));
            }
        }

        public MainViewModel(INavigationService navigationService)
        {
            this.navigationService = navigationService;
        }
}

The ChildViewModel demonstrates the use of the AlternateDialogService and AlternateDispatcherHelper when the Show Message button is pressed:-

public RelayCommand ShowMessageCommand  
{
    get
    {
        return showMessageCommand ?? 
                   (showMessageCommand = new RelayCommand(
                   () =>
                   {
                       Task.Run(() => 
                       {
                           AlternateDispatcherHelper.CheckBeginInvokeOnUI(() =>
                               dialogService.ShowMessageBox(
                                   "Test the Alternate Dialog Service works.",
                                   "Test AlternateDialogService"));    
                       });

                   }));
    }
}

I have used the AlternateNavigationService with Activity and FragmentActivity derived views so far and it seems to work fine.

Richard Woollcott

Read more posts by this author.

Taunton, United Kingdom