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.
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 theMicrosoft.Maui.Controls.Maps
Nuget package, adding.UseMauiMaps()
method in MauiProgram.cs and adding theMap
control with theMicrosoft.Maui.Controls.Maps
namespace on aContentPage
. - It also includes a basic MVVM setup with a Model (
Place
class), ViewModel (BaseViewModel
andMapViewModel
, which gets the current location, which is added to thePlaces
collection), and a View (MapView
, which displays the map with the features mentioned in the VM, theItemsSource
that 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.
It asks for permission, then it shows the current location after pressing the button:
BUT you must manually navigate through the map to find the 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: OnAttachedTo
and 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;
}
}
}
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
, andMapDemo.Models
. Moreover, use an alias for aMap
object to avoid ambiguity (there's another Map class already included in the global usings). We call itMauiMap
. - 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 ofIsReady
public property. When it happens, theOnIsReadyChanged
method is invoked. -
IsReady
is a public boolean property that is bound toIsReadyProperty
for notifications when its value changes. -
OnIsReadyChanged
method handles the value change. We have access to the previous and new value, and theChangePosition
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;
}
}
}
** Step 4. ViewModel** Now let's proceed to modify MapViewModel:
- Two new observable properties are added:
isReady
(a boolean) andbindablePlaces
(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 existingPlaces
observable collection object. Then, insert it into an IEnumerable object that is used to create a new instance of theBindablePlaces
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
}
}
}
}
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 theBindablePlaces
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>
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).
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.