In a typical cross-platform mobile app architecture, one would have to implement a platform-specific navigation service for each platform the app supports. In our case, Xamarin.Forms has already done this, so we're simply going to implement a single navigation service that extends the Xamarin.Forms navigation abstraction so we can perform ViewModel to ViewModel navigation.
The first thing we need to do is define an interface for our navigation service that will define its methods. We are starting with an interface so the service can be added to ViewModels via constructor injection, which we'll dive into in Chapter 4, Platform Specific Services and Dependency Injection. We are also starting with an interface so we can easily provide alternative implementations of the service without changing ViewModels that depend on it. A common scenario for this is creating a mock of the service that gets used when unit testing ViewModels.
In order to create the navigation service, perform the following steps:
Services
folder in the core library and within it create a new interface named INavService
:public interface INavService { bool CanGoBack { get; } Task GoBack (); Task NavigateTo<TVM> () where TVM : BaseViewModel; Task NavigateTo<TVM, TParameter> (TParameter parameter) where TVM : BaseViewModel; Task RemoveLastView(); Task ClearBackStack(); Task NavigateToUri(Uri uri); event PropertyChangedEventHandler CanGoBackChanged; }
This interface defines fairly standard navigation behavior – the ability to navigate to ViewModels, the ability to navigate back, the ability to clear the navigation stack, as well as the ability to navigate to a regular URI. The NavigateTo
method defines a generic type, and restricts its use to objects of the BaseViewModel
base class, which we created in the previous chapter. There is also an overloaded NavigateTo
method that enables a strongly typed parameter to be passed along with the navigation.
Before we create the actual implementation of the INavService
interface, we need to make a couple updates to our BaseViewModel
:
BaseViewModel
to include an abstract Init
method:public abstract Task Init();
This Init
method provides ViewModels with a way of initializing without using the constructor, which is useful when a ViewModel needs to be refreshed.
BaseViewModel
abstract base class with a generic-type that will be used to pass strongly typed parameters to the Init
method:public abstract class BaseViewModel<TParameter> : BaseViewModel { protected BaseViewModel () : base () { } public override async Task Init() { await Init (default(TParameter)); } public abstract Task Init (TParameter parameter); }
MainViewModel
and NewEntryViewModel
to override the Init
method; for now, the NewEntryViewModel
Init
will just be a blank implementation.The Init
method in MainViewModel
will be responsible for loading the log entries. We will move the log entry list population logic out of the constructor and into a new async
method called LoadEntries
, which will be called from the Init
override:
public MainViewModel () : base () { LogEntries = new ObservableCollection<TripLogEntry> (); } public override async Task Init () { await LoadEntries (); } async Task LoadEntries() { LogEntries.Clear (); await Task.Factory.StartNew (() => { LogEntries.Add (new TripLogEntry { Title = "Washington Monument", Notes = "Amazing!", Rating = 3, Date = new DateTime(2015, 2, 5), Latitude = 38.8895, Longitude = -77.0352 }); LogEntries.Add (new TripLogEntry { Title = "Statue of Liberty", Notes = "Inspiring!", Rating = 4, Date = new DateTime(2015, 4, 13), Latitude = 40.6892, Longitude = -74.0444 }); LogEntries.Add (new TripLogEntry { Title = "Golden Gate Bridge", Notes = "Foggy, but beautiful.", Rating = 5, Date = new DateTime(2015, 4, 26), Latitude = 37.8268, Longitude = -122.4798 }); }); }
DetailViewModel
to inherit from BaseViewModel<TripLogEntry>
and override the Init
method and set the Entry
property with the value of its TripLogEntry
parameter:public class DetailViewModel : BaseViewModel<TripLogEntry> { // ... public DetailViewModel () { } public override async Task Init (TripLogEntry logEntry) { Entry = logEntry; } }
Notice that because we are now setting the Entry
property within the Init
method, we can remove the TripLogEntry
parameter from the constructor.
TripLogEntry
parameter from the DetailPage
constructor as it will now be all handled between the navigation service and the ViewModel's Init
method:public class DetailPage : ContentPage { public DetailPage () // <- Remove parameter { // ... } // ... }
Now that BaseViewModel
has been updated, we can create our navigation service that implements INavService
.
Services
folder of the core library. Name the new class XamarinFormsNavService
and make it implement INavService
:public class XamarinFormsNavService : INavService { }
XamarinFormsNavService
to include a public INavigation
property named XamarinFormsNav
. This XamarinFormsNav
property provides a reference to the current Xamarin.Forms.INavigation
instance and will need to be set when the navigation service is first initialized, which we'll see later in this section when we update the TripLog app:public class XamarinFormsNavService : INavService
{
public INavigation XamarinFormsNav { get; set; }
// INavService implementation goes here.
}
As discussed in the previous section, we will implement the navigation service with a page-to-ViewModel mapping. We will do this with an IDictionary<Type, Type>
property and a method to register the mappings.
XamarinFormsNavService
with an IDictionary<Type, Type>
readonly
property and add a public method to populate it named RegisterViewMapping
:readonly IDictionary<Type, Type> _map = new Dictionary<Type, Type>(); public void RegisterViewMapping (Type viewModel, Type view) { _map.Add (viewModel, view); }
INavService
methods. Most of the INavService
methods will leverage the XamarinFormNav
property to make calls to the Xamarin.Forms navigation API in order to perform navigation and alter the navigation stack:public bool CanGoBack { get { return XamarinFormsNav.NavigationStack != null && XamarinFormsNav.NavigationStack.Count > 0; } } public async Task GoBack () { if (CanGoBack) { await XamarinFormsNav.PopAsync(true); } OnCanGoBackChanged (); } public async Task NavigateTo<TVM> () where TVM : BaseViewModel { await NavigateToView (typeof(TVM)); if (XamarinFormsNav.NavigationStack .Last().BindingContext is BaseViewModel) await ((BaseViewModel)(XamarinFormsNav .NavigationStack.Last ().BindingContext)).Init (); } public async Task NavigateTo<TVM, TParameter> (TParameter parameter) where TVM : BaseViewModel { await NavigateToView (typeof(TVM)); if (XamarinFormsNav.NavigationStack .Last().BindingContext is BaseViewModel<TParameter>) await ((BaseViewModel<TParameter>)(XamarinFormsNav .NavigationStack.Last ().BindingContext)) .Init (parameter); } async Task NavigateToView(Type viewModelType) { Type viewType; if (!_map.TryGetValue (viewModelType, out viewType)) throw new ArgumentException ("No view found in View Mapping for " + viewModelType.FullName + "."); var constructor = viewType.GetTypeInfo () .DeclaredConstructors .FirstOrDefault(dc => dc.GetParameters ().Count() <= 0); var view = constructor.Invoke (null) as Page; await XamarinFormsNav.PushAsync (view, true); } public async Task RemoveLastView () { if (XamarinFormsNav.NavigationStack.Any()) { var lastView = XamarinFormsNav.NavigationStack [XamarinFormsNav.NavigationStack.Count - 2]; XamarinFormsNav.RemovePage(lastView); } } public async Task ClearBackStack () { if (XamarinFormsNav.NavigationStack.Count <= 1) return; for (var i = 0; i < XamarinFormsNav.NavigationStack.Count - 1; i++) XamarinFormsNav.RemovePage (XamarinFormsNav.NavigationStack [i]); } public async Task NavigateToUri (Uri uri) { if (uri == null) throw new ArgumentException("Invalid URI"); Device.OpenUri(uri); } public event PropertyChangedEventHandler CanGoBackChanged; void OnCanGoBackChanged() { var handler = CanGoBackChanged; if (handler != null) handler(this, new PropertyChangedEventArgs("CanGoBack")); }
[assembly: Dependency(typeof(XamarinFormsNavService))]
namespace TripLog.Services
{
public class XamarinFormsNavService : INavService
{ ... }
}
In Chapter 4, Platform Specific Services and Dependency Injection, we will remove this as we replace the Xamarin.Forms DependencyService with a third-party IoC and dependency injection library.
With the navigation service complete, we can now update the rest of the TripLog app to leverage it. To start with, we will update the constructor in the main App
class to create a new instance of the navigation service and register the app's page-to-ViewModel mappings:
public App () { // The root page of your application var mainPage = new NavigationPage(new MainPage()); var navService = DependencyService.Get<INavService>() as XamarinFormsNavService; navService.XamarinFormsNav = mainPage.Navigation; navService.RegisterViewMapping (typeof(MainViewModel), typeof(MainPage)); navService.RegisterViewMapping (typeof(DetailViewModel), typeof(DetailPage)); navService.RegisterViewMapping (typeof(NewEntryViewModel), typeof(NewEntryPage)); MainPage = mainPage; }
Since most ViewModels in the TripLog app will need to use the navigation service, it makes sense to include it in the BaseViewModel
class:
public abstract class BaseViewModel : INotifyPropertyChanged { protected INavService NavService { get; private set; } protected BaseViewModel (INavService navService) { NavService = navService; } // ... }
Each of the ViewModels that inherit from BaseViewModel
will need to be updated to include an INavService
parameter in their constructors that is passed to the BaseViewModel
class. The BaseViewModel<TParameter>
base class needs to be updated to include an INavService
constructor parameter as well.
In addition, each ViewModel initialization needs to be updated to pass in an INavService
, which can be retrieved from the Xamarin.Forms DependencyService
:
MainViewModel
instantiation in the MainPage
constructor:public MainPage ()
{
BindingContext = new MainViewModel (
DependencyService.Get<INavService> ());
// ...
}
DetailViewModel
instantiation in the DetailPage
constructor:public DetailPage (TripLogEntry entry)
{
BindingContext = new DetailViewModel (
DependencyService.Get<INavService> ());
// ...
}
NewEntryViewModel
instantiation in the NewEntryPage
constructor:public NewEntryPage ()
{
BindingContext = new NewEntryViewModel (
DependencyService.Get<INavService> ());
// ...
}
In order to move navigation functionality from MainPage
to MainViewModel
, we need to add two new Command
properties—one for creating a new log entry and one for viewing the details of an existing log entry:
Command<TripLogEntry> _viewCommand; public Command<TripLogEntry> ViewCommand { get { return _viewCommand ?? (_viewCommand = new Command<TripLogEntry> (async (entry) => await ExecuteViewCommand(entry))); } } Command _newCommand; public Command NewCommand { get { return _newCommand ?? (_newCommand = new Command (async () => await ExecuteNewCommand ())); } } async Task ExecuteViewCommand(TripLogEntry entry) { await NavService.NavigateTo<DetailViewModel, TripLogEntry> (entry); } async Task ExecuteNewCommand() { await NavService.NavigateTo<NewEntryViewModel> (); }
With the Commands
in place on MainViewModel
, we can now update MainPage
to use these commands instead of using the Xamarin.Forms navigation service directly from the page:
MainViewModel
property named _vm
in the MainPage
class that simply provides access to the page's BindingContext
, but casted as a MainViewModel
type:public class MainPage : ContentPage { MainViewModel _vm { get { return BindingContext as MainViewModel; } } // ... }
ItemTapped
event on the entries ListView
to call the ViewCommand
:entries.ItemTapped += async (sender, e) => {
var item = (TripLogEntry)e.Item;
_vm.ViewCommand.Execute(item);
};
Clicked
event on the newButton ToolbarItem
with a Binding
to the NewCommand
:newButton.SetBinding (ToolbarItem.CommandProperty, "NewCommand");
The XamarinFormsNavService
custom navigation service we created handles initializing ViewModels automatically when they are navigated to by calling the Init
method in BaseViewModel
. However, because the main page is launched by default and not via navigation, we need to manually call the Init
method on the page's ViewModel when the page first appears.
Update the MainPage
by overriding its OnAppearing
method to call its ViewModel's Init
method:
protected override async void OnAppearing () { base.OnAppearing (); // Initialize MainViewModel if (_vm != null) await _vm.Init(); }
In Chapter 2, MVVM and DataBinding, we already added SaveCommand
to NewEntryViewModel
; however, once the SaveCommand
executed, nothing occurred. Once SaveCommand
performs its logic to save the new log entry, it should navigate the user back to the previous page. We can accomplish this by updating SaveCommand
's execute Action
to call the GoBack
method in the navigation service that we created in the last section:
async Task ExecuteSaveCommand() { var newItem = new TripLogEntry { Title = this.Title, Latitude = this.Latitude, Longitude = this.Longitude, Date = this.Date, Rating = this.Rating, Notes = this.Notes }; // TODO: Implement logic to persist Entry in a later chapter. await NavService.GoBack (); }
Notice that because the ExecuteSaveCommand
method now calls an asynchronous method, it needs to use async
and await
and its return type needs to be updated from void
to Task
.
Finally, we need to update how the map on DetailPage
is being bound to the data in the DetailViewModel
. Because the ViewModel is being initialized via the navigation service now, it happens after the page is constructed, and therefore the map doesn't have the data it needs. Normally, this would not be a problem thanks to data binding, but since the map control does not allow for data binding we need to handle its data differently. The best way for the page to check when its ViewModel has data for its map control is to handle the ViewModel's PropertyChanged
event. If the ViewModel's Entry
property changes, the map control should be updated accordingly, as shown in the following steps:
map
to a private class property named _map
:public class DetailPage : ContentPage { // ... readonly Map _map; public DetailPage () { // ... // var map = new Map (); _map = new Map (); // ... mainLayout.Children.Add (_map); mainLayout.Children.Add (detailsBg, 0, 1); mainLayout.Children.Add (details, 0, 1); Grid.SetRowSpan (_map, 3); Content = mainLayout; } }
DetailPage
class:void UpdateMap () { if (_vm.Entry == null) return; // Center the map around the log entry's location _map.MoveToRegion (MapSpan.FromCenterAndRadius ( new Position (_vm.Entry.Latitude, _vm.Entry.Longitude), Distance.FromMiles (.5) )); // Place a pin on the map for the log entry's location _map.Pins.Add (new Pin { Type = PinType.Place, Label = _vm.Entry.Title, Position = new Position (_vm.Entry.Latitude, _vm.Entry.Longitude) }); }
PropertyChanged
event to update the map when the ViewModel's Entry
property is changed:public DetailPage () { BindingContextChanged += (sender, args) => { if (_vm == null) return; _vm.PropertyChanged += (s, e) => { if (e.PropertyName == "Entry") UpdateMap (); }; }; // ... }
3.139.239.41