Adding a Bindable Map with the Map Behavior

IMG_1288_iphone6plus_gold_portraitMy weather app provides weather forecasts for all mountains in each mountain area in the UK. I display a list of mountain summits from which you can select to get a 5 day forecast. I have also added a search bar to make it easy to search for a specific mountain in the list. This is all fine but the list is a little dull. It would be great if I could see all these mountains on a map and use the search bar to filter and zoom into a specific mountain.

Xamarin Forms provides a cross platform map control out of the box, well almost. You need to add Xamarin.Forms.Maps via NuGet to your project and follow the setup in the guide here. This is a great control but one thing it doesn’t provide is the ability to bind to a list of locations. So I wrote this Map Behavior which provides an ItemsSource property so you can bind to a list of locations.

    public class MapBehavior : BindableBehavior<Map>
    {
        public static readonly BindableProperty ItemsSourceProperty = BindableProperty.Create<MapBehavior, IEnumerable<ILocationViewModel>>(
            p => p.ItemsSource, null, BindingMode.Default, null, ItemsSourceChanged);

        public IEnumerable<ILocationViewModel> ItemsSource
        {
            get { return (IEnumerable<ILocationViewModel>)GetValue(ItemsSourceProperty); }
            set { SetValue(ItemsSourceProperty, value); }
        }

        private static void ItemsSourceChanged(BindableObject bindable, IEnumerable oldValue, IEnumerable newValue)
        {
            var behavior = bindable as MapBehavior;
            if (behavior == null) return;
            behavior.AddPins();
        }

        private void AddPins()
        {
            var map = AssociatedObject;
            for (int i = map.Pins.Count-1; i >= 0; i--)
            {
                map.Pins[i].Clicked -= PinOnClicked;
                map.Pins.RemoveAt(i);
            }

            var pins = ItemsSource.Select(x =>
            {
                var pin = new Pin
                {
                    Type = PinType.SearchResult,
                    Position = new Position(x.Latitude, x.Longitude),
                    Label = x.Title,
                    Address = x.Description,

                };

                pin.Clicked += PinOnClicked;
                return pin;
            }).ToArray();

            foreach (var pin in pins)
                map.Pins.Add(pin);

            PositionMap();
        }

        private void PinOnClicked(object sender, EventArgs eventArgs)
        {
            var pin = sender as Pin;
            if (pin == null) return;
            var viewModel = ItemsSource.FirstOrDefault(x => x.Title == pin.Label);
            if (viewModel == null) return;
            viewModel.Command.Execute(null);
        }

        private void PositionMap()
        {
            if (ItemsSource == null || !ItemsSource.Any()) return;

            var centerPosition = new Position(ItemsSource.Average(x => x.Latitude), ItemsSource.Average(x => x.Longitude));

            var minLongitude = ItemsSource.Min(x => x.Longitude);
            var minLatitude = ItemsSource.Min(x => x.Latitude);

            var maxLongitude = ItemsSource.Max(x => x.Longitude);
            var maxLatitude = ItemsSource.Max(x => x.Latitude);

            var distance = MapHelper.CalculateDistance(minLatitude, minLongitude,
                maxLatitude, maxLongitude, 'M') / 2;

            AssociatedObject.MoveToRegion(MapSpan.FromCenterAndRadius(centerPosition, Distance.FromMiles(distance)));

            Device.StartTimer(TimeSpan.FromMilliseconds(500), () =>
            {
                AssociatedObject.MoveToRegion(MapSpan.FromCenterAndRadius(centerPosition, Distance.FromMiles(distance)));
                return false;
            });
        }
    }

The first thing to note here is that this behavior inherites from BindableBehavior<T>, which I created because the Behavior class does not set it’s BindingContext when attached to a visual element and therefore any BindableProperties do not get updated. I blogged about this previously here. BindableBehavior sets the BindingContext on the Behavior when it is attached to the visual element and also introduces a property called AssociatedOject which is the visual element the behavior is attached to, which is very similar to how behaviors work in WPF. Here’s the code for BindableBehavior.

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

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

            AssociatedObject = visualElement;

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

            visualElement.BindingContextChanged += OnBindingContextChanged;
        }

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

        protected override void OnDetachingFrom(T view)
        {
            view.BindingContextChanged -= OnBindingContextChanged;
        }

        protected override void OnBindingContextChanged()
        {
            base.OnBindingContextChanged();
            BindingContext = AssociatedObject.BindingContext;
        }
    }

The MapBehavior exposes an ItemsSource BindableProperty of type IEnumerable<ILocationViewModel>. ILocationViewModel looks like this.

    public interface ILocationViewModel
    {
        string Title { get; set; }
        string Description { get; }
        double Latitude { get; }
        double Longitude { get; }
        ICommand Command { get; }
    }

This is the basic information you need to support a location on a map. You need to provide a ViewModel that implements this and a ViewModel that exposes a list of LocationViewModels. The AddPins method is called whenever the ItemsSource property changes. The AddPins method first removes any pins which may already exist and unsubscribes the Click Event. Then it creates a Pin for each Location, hooks up the Click event and adds these to the map Pins. The Click event just executes the Command on the LocationViewModel. I then position the map so that it is correctly positioned to where the pins are located. I do this by finding the centre position using the average latitude and longitude and the radius by calculating the max and min latitude and longitude and using the following MapHelper, which I found here, to Calculate the current distance.

    public static class MapHelper
    {
        public static double CalculateDistance(double lat1, double lon1, double lat2, double lon2, char unit)
        {
            double theta = lon1 - lon2;
            double dist = Math.Sin(Deg2Rad(lat1)) * Math.Sin(Deg2Rad(lat2)) + Math.Cos(Deg2Rad(lat1)) * Math.Cos(Deg2Rad(lat2)) * Math.Cos(Deg2Rad(theta));
            dist = Math.Acos(dist);
            dist = Rad2Deg(dist);
            dist = dist * 60 * 1.1515;
            if (unit == 'K')
            {
                dist = dist * 1.609344;
            }
            else if (unit == 'N')
            {
                dist = dist * 0.8684;
            }
            return (dist);
        }

        private static double Deg2Rad(double deg)
        {
            return (deg * Math.PI / 180.0);
        }

        private static double Rad2Deg(double rad)
        {
            return (rad / Math.PI * 180.0);
        }
    }

I then call the Maps MoveToRegion method using MapSpan.FromCenterAndRadius. Also notice that I call this again using a Timer. This fixes a strange bug where the map didn’t set the position when changing the visibility of the map to true. I do this in my view to switch between the List and the Map. Let’s take a look at the Xaml for the Map.

      <maps:Map MapType="Street" VerticalOptions="FillAndExpand" IsVisible="{Binding ShowMap}">
        <maps:Map.Behaviors>
          <behaviors:MapBehavior ItemsSource="{Binding Items}" />
        </maps:Map.Behaviors>
      </maps:Map>

Notice I am binding the ItemsSource to the Items property of my ViewModel and I am also binding the Maps IsVisible property to a ShowMap property on my ViewModel. I have a ToolbarItem which toggles this property so that I can switch between the list and the map. Here’s the full Xaml for my page which allows switching between the list and the map.

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:coreBehaviors="clr-namespace:Silkweb.Mobile.Core.Behaviors;assembly=Silkweb.Mobile.Core"
             xmlns:views="clr-namespace:Silkweb.Mobile.Core.Views;assembly=Silkweb.Mobile.Core"
             x:Class="Silkweb.Mobile.MountainWeather.Views.SitesView"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:coreViewModels="clr-namespace:Silkweb.Mobile.Core.ViewModels;assembly=Silkweb.Mobile.Core"
             xmlns:maps="clr-namespace:Xamarin.Forms.Maps;assembly=Xamarin.Forms.Maps"
             xmlns:behaviors="clr-namespace:Silkweb.Mobile.MountainWeather.Behaviors;assembly=Silkweb.Mobile.MountainWeather"
             xmlns:viewModels="clr-namespace:Silkweb.Mobile.MountainWeather.ViewModels;assembly=Silkweb.Mobile.MountainWeather"
             xmlns:converters="clr-namespace:Silkweb.Mobile.Core.Converters;assembly=Silkweb.Mobile.Core"
             xmlns:generic="clr-namespace:System.Collections.Generic;assembly=System.Runtime"
             mc:Ignorable="d" Title="{Binding Title}"
             d:DataContext="{d:DesignInstance Type=viewModels:SitesViewModel, IsDesignTimeCreatable=False}">

  <ContentPage.Resources>
    <ResourceDictionary>
      <converters:BooleanToObjectConverter x:Key="booleanToObjectConverter">
        <converters:BooleanToObjectConverter.TrueValue>
          <FileImageSource File="List.png" />
        </converters:BooleanToObjectConverter.TrueValue>
        <converters:BooleanToObjectConverter.FalseValue>
          <FileImageSource File="Map.png" />
        </converters:BooleanToObjectConverter.FalseValue>
      </converters:BooleanToObjectConverter>
    </ResourceDictionary>
  </ContentPage.Resources>

  <ContentPage.ToolbarItems>
    <ToolbarItem Icon="{Binding ShowMap, Converter={ StaticResource booleanToObjectConverter}}" Command="{Binding ShowMapCommand}" />
  </ContentPage.ToolbarItems>

  <StackLayout>

    <SearchBar Text="{Binding SearchText}" SearchCommand="{Binding SearchCommand}" TextChanged="SearchBar_OnTextChanged" />

    <Grid VerticalOptions="FillAndExpand">

      <maps:Map MapType="Street" VerticalOptions="FillAndExpand" IsVisible="{Binding ShowMap}">
        <maps:Map.Behaviors>
          <behaviors:MapBehavior ItemsSource="{Binding Items}" />
        </maps:Map.Behaviors>
      </maps:Map>

      <TableView Intent="Menu" IsVisible="{Binding ShowMap, Converter={StaticResource negateConverter}}">
        <TableView.Behaviors>
          <coreBehaviors:TableViewItemsSourceBehavior ItemsSource="{Binding Groups}">
            <coreBehaviors:TableViewItemsSourceBehavior.ItemTemplate>
              <DataTemplate>
                <views:TextCellExtended Text="{Binding Title}" ShowDisclosure="True" Command="{Binding Command}" CommandParameter="{Binding Item}"
                                        d:DataContext="{d:DesignInstance Type=coreViewModels:ItemViewModel}"/>
              </DataTemplate>
            </coreBehaviors:TableViewItemsSourceBehavior.ItemTemplate>
          </coreBehaviors:TableViewItemsSourceBehavior>
        </TableView.Behaviors>
      </TableView>

    </Grid>

  </StackLayout>
</ContentPage>

The ToolbarItem uses a BooleanToObjectConverter to toggle the Icon between a list and a map depending on the ShowMap Boolean property. Here’s the converter.

    public class BooleanToObjectConverter : IValueConverter
    {
        public object TrueValue { get; set; }

        public object FalseValue { get; set; }

        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            if (!(value is bool)) return null;

            var boolValue = (bool) value;

            return boolValue ? TrueValue : FalseValue;
        }

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            var stringValue = value as string;
            if (stringValue == null) return false;

            return stringValue.Equals(TrueValue);
        }
    }

The ToolbarItem calls the ShowMapCommand which simply toggles the ShowMap property on the ViewModel. The list is a TableView which groups the locations alphabetically using the TableViewItemsSourceBehavior I discussed in a previous post here. The SearchBar Text property is bound to the view models SearchText property. When the Text changes it calls the SearchCommand on the ViewModel to filter the Items by the SearchText. I’ll discuss this in another post.

Let’s see how things look in action.

Looks great.

Maps really add a new dimension to your application when displaying locations like this. I hope you find this behavior useful for binding your location data to the Xamarin Forms Map control.

15 thoughts on “Adding a Bindable Map with the Map Behavior

  1. Hi, and thanks again for these amazing adventures 🙂

    One question:

    What do you think of, in AddPins(), adding the BindingContext for each Pin, like this:
    “pin.BindingContext = x;”

    And then in “PinOnClicked” Access it directly instead of doing:
    “var viewModel = ItemsSource.FirstOrDefault(x => x.Title == pin.Label);”

    This is regarding those occasions where you might have two Pins with the same Title/Label but are backed up by distinct ILocationViewModels.

    Thank you,

    Nuno

    Like

    1. Hey Nuno,

      Thanks for that. That’s a great idea. I didn’t realise Pins had BindingContext so it makes absolute sense to do what you are suggesting. Thanks, I’ll update my code with your suggestion.

      Like

  2. Hello Joathan,
    do you know any XAML control to enable a “checkbox like” experience in XF ?
    I mean a control similar to ActionSheet but that enable the user to choose multiple items before closing.

    Like

Leave a comment