Monday, April 17, 2017

DataBinding for Custom Xamarin.Forms controls

In my last post we looked at obtaining a reference to controls from within our ViewModel. While this approach is a good workaround for non-MVVM friendly user controls a better long term strategy is to adapt these controls to support the MVVM features you need. Today's post will take a quick stab at adding binding support to the Map control.

If you follow Xamarin’s walk-through for the Map control it shows pins are added directly to the control from the code-behind. This post will use a more MVVM-friendly approach by adding a few bindable properties to the custom control so that we can manipulate the Map using data in the ViewModels only. We'll design our custom Map to dynamically add and remove pins, and to set focus and center on a specific pin.

Create a Custom Control

Because we’re not changing the behaviour or appearance of the control we simply need to derive from the Map control and Xamarin.Forms will use its default renderer to display the control. If we wanted to change the behavior or layout of the pins, things get a bit more complicated and we’d need to create a custom renderer for each platform. Xamarin has a good tutorial on how to create custom renderers.

Here’s the starting point for our custom map.

namespace XF.CaliburnMicro1.Controls
{
    using Xamarin.Forms;
    using Xamarin.Forms.Maps;
    
    public class CustomMap : Map
    {
    
    }
    
}

Add Bindable Properties

Although the Map control has several bindable properties already (HasScrollEnabled, HasZoomEnabled, IsShowingUser, MapType) it does not support properties for the Pins or currently selected pin. These are easy to add:

public static BindableProperty PinsItemsSourceProperty =
    BindableProperty.Create(
        "PinsItemsSource",
        typeof(IEnumerable<Pin>),
        typeof(CustomMap),
        default(IEnumerable<Pin>),
        propertyChanged: OnPinsItemsSourcePropertyChanged);

public static BindableProperty SelectedItemProperty =
    BindableProperty.Create(
        "SelectedItem",
        typeof(Pin),
        typeof(CustomMap),
        null,
        defaultBindingMode: BindingMode.TwoWay,
        propertyChanged: OnSelectedItemChanged
        );

public Pin SelectedItem
{
    get { return (Pin)GetValue(SelectedItemProperty); }
    set { SetValue(SelectedItemProperty, value); }
}

public IEnumerable<Pin> PinsItemsSource
{
    get { return (IEnumerable<Pin>)GetValue(PinsItemsSourceProperty); }
    set { SetValue(PinsItemsSourceProperty, value); }
}

Add Property Changed Callbacks

Similar to WPF’s DependencyProperties, BindableProperties have the ability to invoke a callback when new values are assigned through data bindings. The callback function for the data-bound pin collection is pretty straight forward — it assigns the new value to a property on the control. The real magic for the collection is that it uses Caliburn.Micro’s BindableCollection<T> – a thread-safe implementation of ObservableCollection<T> – which we use to listen for changes to the collection. Whenever the collection changes we copy the Pin to the Control’s default Pins collection.

private static void OnPinsItemsSourcePropertyChanged(BindableObject bindable, object oldValue, object newValue)
{
    var control = (CustomMap)bindable;

    control.PinsCollection = newValue as IObservableCollection<Pin>;
}

private IObservableCollection<Pin> _collection;

protected IObservableCollection<Pin> PinsCollection
{
    get { return _collection; }
    set
    {
        if (_collection != null)
        {
            _collection.CollectionChanged -= OnObservableCollectionChanged;
        }

        _collection = value;

        if (_collection != null)
        {
            _collection.CollectionChanged += OnObservableCollectionChanged;
        }
    }
}

private void OnObservableCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
    if (e.Action == NotifyCollectionChangedAction.Add)
    {
        foreach (Pin pin in e.NewItems)
        {
            pin.Clicked += OnPinClicked;
            Pins.Add(pin);
        }
    }

    if (e.Action == NotifyCollectionChangedAction.Remove)
    {
        foreach (Pin pin in e.NewItems)
        {
            pin.Clicked -= OnPinClicked;
            Pins.Remove(pin);
        }
    }
}

We’ll also take this time to associate the Pin’s Click event to our SelectedItem property.

private void OnPinClicked(object sender, EventArgs e)
{
    SelectedItem = (Pin)sender;
}

And since we’re setting the SelectedItem, let’s center the view on the selected pin. Because the binding mode of the property is set to TwoWay this callback is invoked from both changes in the control or from the ViewModel.

private static void OnSelectedItemChanged(BindableObject bindable, object oldValue, object newValue)
{
    var map = (CustomMap)bindable;
    var pin = newValue as Pin;
    if (pin != null)
    {
        Distance distance = map.VisibleRegion.Radius;
        MapSpan region = MapSpan.FromCenterAndRadius(pin.Position, distance);
        map.MoveToRegion(region);
    }
}

Putting it all together

Now to see this in action, let’s demonstrate a View with our custom map and a few buttons on it. We’ll wire the buttons to a command that sets the SelectedItem.

<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:maps="clr-namespace:Xamarin.Forms.Maps;assembly=Xamarin.Forms.Maps"
             xmlns:controls="clr-namespace:XF.CaliburnMicro1.Controls"
             x:Class="XF.CaliburnMicro1.Views.Tab3View"            
             >

    <StackLayout VerticalOptions="FillAndExpand">

        <Grid HeightRequest="150">
            <Grid.RowDefinitions>
                <RowDefinition />
                <RowDefinition />
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
                <ColumnDefinition />
                <ColumnDefinition />
                <ColumnDefinition />
            </Grid.ColumnDefinitions>

            <Button Grid.Row="0" Grid.Column="0" Command="{Binding SelectPinCommand}" CommandParameter="1" Text="1" />
            <Button Grid.Row="0" Grid.Column="1" Command="{Binding SelectPinCommand}" CommandParameter="2" Text="2" />
            <Button Grid.Row="1" Grid.Column="0" Command="{Binding SelectPinCommand}" CommandParameter="3" Text="3" />
            <Button Grid.Row="1" Grid.Column="1" Command="{Binding SelectPinCommand}" CommandParameter="4" Text="4" />

            <Button Grid.Row="1" Grid.Column="2" Command="{Binding NewPinCommand}" Text="Add Pin" />
        </Grid>

        <controls:CustomMap 
            IsShowingUser="true"
            MapType="Street"
            PinsItemsSource="{Binding Pins}"
            SelectedItem="{Binding SelectedPin}" 
            VerticalOptions="FillAndExpand"
          />
        
    </StackLayout>    
    
</ContentView>

The ViewModel is really quite simple now. We simply populate a list of Pins and change the SelectedPin to trigger changes in the View.

namespace XF.CaliburnMicro1.ViewModels
{
    using Caliburn.Micro;
    using Xamarin.Forms.Maps;
    using XF.CaliburnMicro1.Views;

    public class Tab3ViewModel : BaseScreen
    {
        private Pin _selectedPin;

        public Tab3ViewModel()
        {
            Pins = new BindableCollection<Pin>();

            SelectPinCommand = new DelegateCommand(o =>
            {
                var index = int.Parse(o.ToString());

                SelectedPin = Pins[index - 1];
            });

            NewPinCommand = new DelegateCommand((o) =>
            {
                var position = MapControl.VisibleRegion.Center;
                var pin = Create("New!", position.Latitude, position.Longitude);
                Pins.Add(pin);
                
            });

        }

        public BindableCollection<Pin> Pins { get; protected set; }

        public Pin SelectedPin
        {
            get { return _selectedPin; }
            set { SetField(ref _selectedPin, value); }
        }

        public DelegateCommand NewPinCommand { get; set; }

        public DelegateCommand SelectPinCommand { get; set; }

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

            Pins.Clear();

            // top 4 largest cities in north america
            Pins.Add(Create("Mexico City", 19.4326, -99.1332));
            Pins.Add(Create("New York", 40.7128, -74.0059));
            Pins.Add(Create("Los Angeles", 34.0522, -118.2437));
            Pins.Add(Create("Toronto", 43.6532, -79.3832));
        }

        private Pin Create(string label, double lat, double longitude)
        {
            return
                  new Pin
                  {
                      Label = label,
                      Position = new Position(lat, longitude),
                      Type = PinType.Generic
                  };
        }
    }
}

And Voila! Our Map is now populated and driven using data in the ViewModel!

map-android2map-ios

Happy coding.

submit to reddit

0 comments: