BindableObject + Behaviors to enhance MVVM in .NET MAUI Maps

icebeam7

Luis Beltran

Posted on December 13, 2022

BindableObject + Behaviors to enhance MVVM in .NET MAUI Maps

This publication is part of the C# Advent Calendar 2022, an initiative led by Matthew D. Groves and Calvin A. Allen. Check it out for more interesting C# articles posted by community members.

With the release of .NET 7 and the corresponding update in .NET MAUI, one of the newest additions is the Map Control that you can use in order to include a control that supports annotation and display of native map controls across Android and iOS mobile applications.

.NET MAUI app with Map on iOS

While this control includes several powerful features, not all of them can be used directly with Data Binding. The following actions can't be the target of data bindings in a .NET MAUI Map

  • Displaying a specific location on a Map and/or Moving the map (for example, setting the initial position of the map to an specific location, otherwise the map is centered on Maui, Hawaii by default). For both operations, a MapSpan object is involved.

  • Pin interaction (click). You can't even add a TapGestureRecognizer, sadly.

  • Drawing polygons, polylines, and circles. Perhaps you want to highlight specific areas on a map with these shapes.

MVVM is a great pattern that is highly used in .NET development. We want to decouple every layer so our code is reusable, maintainable, etc. Behaviors is a class that help us to add functionality to UI controls in a separate class that is not a subclass of them, but attached to the control as if it was part of the control itself. The idea is to directly interact with the API of the control in such a way that it can be concisely attached to the control and packaged for reuse across more than one application.

And then we have BindableObjects, which allows us to propagate changes that are made to data in one object to another, by enabling validation, type coercion, and an event system. We can combine the potential advantages of BindableObjects and Behaviors into one class, a BindableBehavior, that can be reused in MVVM to extend capabilities of controls such as a .NET MAUI Map. Enough theory, show me the code!

Step 1. Set up Clone this GitHub repo (master branch)

This is a .NET MAUI app that:

  • Targets .NET 7
  • Has already been configured to display a map in a ContentPage. Check the official documentation for specifics on how to do it, such as: adding the Microsoft.Maui.Controls.Maps Nuget package, adding .UseMauiMaps() method in MauiProgram.cs and adding the Map control with the Microsoft.Maui.Controls.Maps namespace on a ContentPage.
  • It also includes a basic MVVM setup with a Model (Placeclass), ViewModel (BaseViewModel and MapViewModel, which gets the current location, which is added to the Places collection), and a View (MapView, which displays the map with the features mentioned in the VM, the ItemsSourcethat displays a pin uses Data Binding)

If you are testing the app on Android, there is an additional thing to do: Add a Google Maps API key in the AndroidManifest.xml.

This is how the app looks in Android when is executed for the first time.

Maps in .NET MAUI 1

It asks for permission, then it shows the current location after pressing the button:

Location permission

BUT you must manually navigate through the map to find the pin:

Map with a pin

Step 2. BindableBehavior class Create a folder (Behaviors) and a class BindableBehavior that extends from Behavior<T>. The class includes a generic AssociatedObject (a UI control) and overrides the two basic methods from the Behavior class: OnAttachedToand OnDetachingFrom, which are typically used to add and remove the behavior from a control. Moreover (and this is the key point of everything in this implementation), the BindingContext of the associated control is also referenced, so we can notify (and get notified) about changes in properties from the class and control. This is the code:



namespace MapDemo.Behaviors
{
    public class BindableBehavior<T> : Behavior<T> where T : BindableObject
    {
        public T AssociatedObject { get; private set; }

        protected override void OnAttachedTo(T bindable)
        {
            base.OnAttachedTo(bindable);

            AssociatedObject = bindable;

            if (bindable.BindingContext != null)
                BindingContext = bindable.BindingContext;

            bindable.BindingContextChanged += Bindable_BindingContextChanged;
        }

        private void Bindable_BindingContextChanged(object sender, EventArgs e)
        {
            OnBindingContextChanged();
        }

        protected override void OnDetachingFrom(T bindable)
        {
            base.OnDetachingFrom(bindable);

            bindable.BindingContextChanged -= Bindable_BindingContextChanged;
        }

        protected override void OnBindingContextChanged()
        {
            base.OnBindingContextChanged();

            BindingContext = AssociatedObject.BindingContext;
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

Step 3. MapBehavior class Now it is time to consume the above class. Create a new class (MapBehavior). Here:

  • Add namespaces: Microsoft.Maui.Controls.Maps, Microsoft.Maui.Maps, and MapDemo.Models. Moreover, use an alias for a Map object to avoid ambiguity (there's another Map class already included in the global usings). We call it MauiMap.
  • This class extends from the BindableBehavior class that we just created. The generic T member is a MauiMap.
  • Create a MauiMap local object.

And here we also have the most important part which includes 3 elements: A BindableProperty, a public property, and a method:

  • IsReadyProperty is a public static BindableProperty member that gets notified when there is a change in the value of IsReady public property. When it happens, the OnIsReadyChanged method is invoked.
  • IsReady is a public boolean property that is bound to IsReadyProperty for notifications when its value changes.
  • OnIsReadyChanged method handles the value change. We have access to the previous and new value, and the ChangePosition method is invoked.

The IsReady property will be the target for data binding in the View after the Behavior is attached, and its value will be affected/read from the ViewModel. More on that later :-).

Then we also have another BindableProperty element: PlacesProperty, which is bound to Places, an IEnumerable of Place. When there is a change in this collection value, the OnPlacesChanged method is invoked, which in turn executes ChangePosition (and DrawLocation, if it contains only one element).

You might wonder why Places is an IEnumerable rather than just one Place object. The answer is that in an upcoming post I'll use the Places collection to draw a route between the first point and another one (selected by the user).

The ChangePosition method uses MoveToRegion from the map reference to display the map in an specific location, while DrawLocation highlights the location by drawing a Circle on the map (it is drawn at the moment it is added to the Elements collection of the map).

Both OnAttachedTo and OnDetachingFrom overriden methods set and remove the map reference, respectively. The implementations from the base class are also invoked (if you remember, we set the BindingContext there).

The code goes as follows:



using Microsoft.Maui.Controls.Maps;
using Microsoft.Maui.Maps;

using MauiMap = Microsoft.Maui.Controls.Maps.Map;
using MapDemo.Models;

namespace MapDemo.Behaviors
{
    public class MapBehavior : BindableBehavior<MauiMap>
    {
        private MauiMap map;

        public static readonly BindableProperty IsReadyProperty = 
            BindableProperty.CreateAttached(nameof(IsReady),
                typeof(bool),
                typeof(MapBehavior),
                default(bool),
                BindingMode.Default,
                null,
                OnIsReadyChanged);

        public bool IsReady
        {
            get => (bool)GetValue(IsReadyProperty);
            set => SetValue(IsReadyProperty, value);
        }

        private static void OnIsReadyChanged(BindableObject view, object oldValue, object newValue)
        {
            var mapBehavior = view as MapBehavior;

            if (mapBehavior != null)
            {
                if (newValue is bool)
                    mapBehavior.ChangePosition();
            }
        }

        public static readonly BindableProperty PlacesProperty =
            BindableProperty.CreateAttached(nameof(Places),
                typeof(IEnumerable<Place>),
                typeof(MapBehavior),
                default(IEnumerable<Place>),
                BindingMode.Default,
                null,
                OnPlacesChanged);


        public IEnumerable<Place> Places
        {
            get => (IEnumerable<Place>)GetValue(PlacesProperty);
            set => SetValue(PlacesProperty, value);
        }

        private static void OnPlacesChanged(BindableObject view, object oldValue, object newValue)
        {
            var mapBehavior = view as MapBehavior;

            if (mapBehavior != null)
            {
                mapBehavior.ChangePosition();

                if (mapBehavior.Places.Count() == 1)
                    mapBehavior.DrawLocation();
            }
        }

        private void DrawLocation()
        {
            map.MapElements.Clear();

            if (Places == null || !Places.Any())
                return;

            var place = Places.First();
            var distance = Distance.FromMeters(50);

            Circle circle = new Circle()
            {
                Center = place.Location,
                Radius = distance,
                StrokeColor = Color.FromArgb("#88FF0000"),
                StrokeWidth = 8,
                FillColor = Color.FromArgb("#88FFC0CB")
            };

            map.MapElements.Add(circle);
        }

        private void ChangePosition()
        {
            if (!IsReady || Places == null || !Places.Any())
                return;

            var place = Places.First();
            var distance = Distance.FromKilometers(1);

            map.MoveToRegion(MapSpan.FromCenterAndRadius(place.Location, distance));
        }

        protected override void OnAttachedTo(MauiMap bindable)
        {
            base.OnAttachedTo(bindable);
            map = bindable;
        }

        protected override void OnDetachingFrom(MauiMap bindable)
        {
            base.OnDetachingFrom(bindable);
            map = null;
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

** Step 4. ViewModel** Now let's proceed to modify MapViewModel:

  • Two new observable properties are added: isReady (a boolean) and bindablePlaces (an observable collection of Place). They are a bridge between the View and the BindableBehavior.
  • In the GetCurrentLocationAsync method, set the location obtained from the sensor into a new object (place) that is added to the already existing Places observable collection object. Then, insert it into an IEnumerable object that is used to create a new instance of the BindablePlaces object. Moreover, IsReady is set to true.

This is the code:



...
using CommunityToolkit.Mvvm.ComponentModel;

namespace MapDemo.ViewModels
{
    public partial class MapViewModel : BaseViewModel
    {
        ...

        [ObservableProperty]
        bool isReady;

        [ObservableProperty]
        ObservableCollection<Place> bindablePlaces;

        ...

        [RelayCommand]
        private async Task GetCurrentLocationAsync()
        {
            try
            {
                ...

                var place = new Place()
                {
                    Location = location, 
                    Address = address,
                    Description = "Current Location"
                };

                Places.Add(place);

                var placeList = new List<Place>() { place };
                BindablePlaces = new ObservableCollection<Place>(placeList);
                IsReady = true;
            }
            catch (Exception ex)
            {
                // Unable to get location
            }
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

Step 5. View Finally, let's attach the behavior to the map. Go to MapView and add a reference to the Behaviors namespace; then, inside the Map definition access the Behaviors section where you'll include:

  • a MapBehavior instance
  • The IsReady property from the behavior is bound to the IsReady from the viewmodel.
  • The Places property from the behavior is bound to the BindablePlaces from the viewmodel.

Here we are connecting everything we prepared earlier! Check the code:



<?xml version="1.0" encoding="utf-8" ?>
<ContentPage ...
             xmlns:behaviors="clr-namespace:MapDemo.Behaviors"
             ...>
    <Grid ...>
        <maps:Map ...>
            <maps:Map.Behaviors>
                <behaviors:MapBehavior 
                    IsReady="{Binding IsReady}"
                    Places="{Binding BindablePlaces}"/>
            </maps:Map.Behaviors>
        </maps:Map>
    </Grid>
</ContentPage>


Enter fullscreen mode Exit fullscreen mode

Let's run the application! Once we click on the button, the map will immediately be centered around the current location where we will see the pin and red circle (before, we manually had to scroll through the map to the pin location).

Map with pin and red circle

In case you want the app to directly access the current location, simply get rid of the button and invoke the GetCurrentLocationCommand command in the OnAppearing method from the MapView ContentPage class.

The final code is here, which is the bindable-behavior branch from the MapDemoNetMaui project.

💖 💪 🙅 🚩
icebeam7
Luis Beltran

Posted on December 13, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related