[Xamarin] Implement your own binding engine (based on XML attributes) for your Xamarin.Android applications!

March 31st, 2015 | Posted by Tom in .NET | Article | Xamarin | Read: 2,271

First of all, let me be clear: I love MVVM Light Framework, specially in Windows applications. When I started to look at Xamarin, I was very enthusiast to see that Laurent ported its Framework on that platform.

But I have to say that I was a bit disappointed when I saw it was limited (who said “light” ;)) in terms of features. One of the feature found in others MVVM Frameworks (for example in MVVM Cross) that I particularly like is the XML attributes to apply data binding.

So I’ve decided to create a small proof of concept of how you can have, in a Xamarin.Android application, a data binding engine that supports the following points:

  • Looks like the same data binding that the one we can found in XAML apps
  • Support OneWay/TwoWay bindings
  • Support commands
  • Support converters
  • Support method calls when events are raised
  • and, the last (but not the least), support data binding through XML attributes

Please, remind that this is, for now, most a prototype than something useable in your applications. In fact, all the code here works fine but I’ve not checked for memory performances, I should have used weak events, I’ve not implemented cache, etc.

All the code will be attached to the post so I’ll just provide you some pointers to the interesting parts.

At first, we need a base class for all our ViewModels:

public class BindableObject : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        var handler = PropertyChanged;
        if (handler != null)
        {
            handler(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

Of course, this version is pretty simple and you can easily improve it. Once we have that base class, we’ll define a base activity for all the views of our application:

public abstract class BindingActivity<TViewModel> : Activity where TViewModel : BindableObject
{
    public TViewModel DataContext { get; set; }

    public int ViewLayoutResourceId { get; set; }

    protected BindingActivity(int viewLayoutResourceId)
    {
        this.ViewLayoutResourceId = viewLayoutResourceId;

        this.DataContext = ViewModelFactory.Create<TViewModel>();
    }

    protected override void OnCreate(Bundle savedInstanceState)
    {
        base.OnCreate(savedInstanceState);

        this.SetContentView(this.ViewLayoutResourceId);

        BindingEngine.Initialize(this);
    }
}

As you can see, the BindingActivity expose a DataContext property (as we found in XAML) and initialize the binding engine. To use it, you just have to inherit from that class and pass the type of ViewMode:

[Activity(Label = "SampleBindingEngine", MainLauncher = true, Icon = "@drawable/icon")]
public class MainActivity : BindingActivity<MainViewModel>
{
    int _count = 1;

    public MainActivity()
        : base(Resource.Layout.Main)
    {
    }
}

The ViewModel is composed by the following elements:

public class MainViewModel : BindableObject
{
    private bool _isBusy;

    public bool IsBusy
    {
        get { return _isBusy; }
        set
        {
            _isBusy = value;

            this.OnPropertyChanged();
        }
    }

    private string _sampleText;
    public string SampleText
    {
        get { return _sampleText; }
        set
        {
            _sampleText = value;

            this.OnPropertyChanged();

            if (this.ButtonClickCommand != null)
            {
                this.ButtonClickCommand.RaiseCanExecuteChanged();
            }
        }
    }

    public RelayCommand ButtonClickCommand { get; set; }

    public MainViewModel()
    {
        this.SampleText = "Hey, Xamarin World!";

        this.ButtonClickCommand = new RelayCommand(o =>
        {
            this.SampleText = "Text Changed from First Command!";
        },
        o => !string.IsNullOrWhiteSpace(this.SampleText));
    }

    private void ClickOnButton()
    {
        this.SampleText = "Text Changed from Method!";
    }
}

This is on that class, BindingEngine, that most of the job is done:

private const string ViewLayoutResourceIdPropertyName = "ViewLayoutResourceId";
public static void Initialize<TViewModel>(BindingActivity<TViewModel> bindingActivity) where TViewModel : BindableObject
{
    // TODO
}

First, we need to get the XML elements that composed the view;

List<XElement> xmlElements = null;

// Find the value of the ViewLayoutResourceId property
var viewLayoutResourceIdProperty = bindingActivity.GetType().GetProperty(ViewLayoutResourceIdPropertyName);
var viewLayoutResourceId = (int)viewLayoutResourceIdProperty.GetValue(bindingActivity);

if (viewLayoutResourceId > -1)
{
    // Load the XML elements of the view
    xmlElements = GetViewAsXmlElements(bindingActivity, viewLayoutResourceId);
}

/// <summary>
/// Returns the current view (activity) as a list of XML element.
/// </summary>
/// <typeparam name="TViewModel">The type of the ViewModel associated to the activity.</typeparam>
/// <param name="bindingActivity">The current activity we want to get as a list of XML elements.</param>
/// <param name="viewLayoutResourceId">The id corresponding to the layout.</param>
/// <returns>A list of XML elements which represent the XML layout of the view.</returns>
private static List<XElement> GetViewAsXmlElements<TViewModel>(BindingActivity<TViewModel> bindingActivity, int viewLayoutResourceId) where TViewModel : BindableObject
{
    List<XElement> xmlElements;

    using (var viewAsXmlReader = bindingActivity.Resources.GetLayout(viewLayoutResourceId))
    {
        using (var sb = new StringBuilder())
        {
            while (viewAsXmlReader.Read())
            {
                sb.Append(viewAsXmlReader.ReadOuterXml());
            }

            var viewAsXDocument = XDocument.Parse(sb.ToString());
            xmlElements = viewAsXDocument.Descendants().ToList();
        }
    }

    return xmlElements;
}

Then, we’ll check if there are some XML elements that contains our “Binding” attribute and, if that’s true, we’ll get the current view a a list of .NET objects:

private static readonly XName BindingOperationXmlNamespace = XNamespace.Get("http://schemas.android.com/apk/res-auto") + "Binding";

// If there is at least one 'Binding' attribute set in the XML file, get the view as objects
if (xmlElements != null && xmlElements.Any(xe => xe.Attribute(BindingOperationXmlNamespace) != null))
{
    viewElements = GetViewAsObjects(bindingActivity);
}

/// <summary>
/// Returns the current view (activity) as a list of .NET objects.
/// </summary>
/// <typeparam name="TViewModel">The type of the ViewModel associated to the activity.</typeparam>
/// <param name="bindingActivity">The current activity we want to get as a list of XML elements.</param>
/// <returns>A list of .NET objects which composed the view.</returns>
private static List<View> GetViewAsObjects<TViewModel>(BindingActivity<TViewModel> bindingActivity) where TViewModel : BindableObject
{
    // Get the objects on the view
    var rootView = bindingActivity.Window.DecorView.FindViewById(Resource.Id.Content);

    return GetAllChildrenInView(rootView, true);
}

/// <summary>
/// Recursive method which returns the list of children contains in a view.
/// </summary>
/// <param name="rootView">The root/start view from which the analysis is performed.</param>
/// <param name="isTopRootView">True is the current root element is, in fact, the top view.</param>
/// <returns>A list containing all the views with their childrens.</returns>
private static List<View> GetAllChildrenInView(View rootView, bool isTopRootView = false)
{
    if (!(rootView is ViewGroup))
    {
        return new List<View> { rootView };
    }

    var childrens = new List<View>();

    var viewGroup = (ViewGroup)rootView;

    for (int i = 0; i < viewGroup.ChildCount; i++)
    {
        var child = viewGroup.GetChildAt(i);

        var childList = new List<View>();
        if (isTopRootView)
        {
            childList.Add(child);
        }

        childList.AddRange(GetAllChildrenInView(child));

        childrens.AddRange(childList);
    }

    return childrens;
}

Now, we’ll extract all the binding operations implemented in the XML file:

if (xmlElements != null && xmlElements.Any() && viewElements != null && viewElements.Any())
{
    // Get all the binding operations inside the XML file.
    var bindingOperations = ExtractBindingOperationsFromLayoutFile(xmlElements, viewElements);
    if (bindingOperations != null && bindingOperations.Any())
    {
        // Find the value of the DataContext property (which is, in fact, our ViewModel)
        var viewModel = bindingActivity.DataContext as BindableObject;
        if (viewModel != null)
        {
            // TODO
        }
    }
}

/// <summary>
/// Extract the Binding operations (represent by the Binding="" attribute in the XML file).
/// </summary>
/// <param name="xmlElements">The list of XML elements from which we want to extract the Binding operations.</param>
/// <param name="viewElements">The list of .NET objects corresponding to the elements of the view.</param>
/// <returns>A list containing all the binding operations (matching between the Source property, the Target property, the Control bound to the .NET property and the Mode of the binding).</returns>
private static List<BindingOperation> ExtractBindingOperationsFromLayoutFile(List<XElement> xmlElements, List<View> viewElements)
{
    var bindingOperations = new List<BindingOperation>();

    for (int i = 0; i < xmlElements.Count; i++)
    {
        var currentXmlElement = xmlElements.ElementAt(i);

        if (currentXmlElement.Attributes(BindingOperationXmlNamespace).Any())
        {
            var xmlBindings = currentXmlElement.Attributes(BindingOperationXmlNamespace);

            foreach (var xmlBindingAttribute in xmlBindings)
            {

                var xmlBindingValue = xmlBindingAttribute.Value;

                if (!xmlBindingValue.StartsWith("{") || !xmlBindingValue.EndsWith("}"))
                {
                    throw new InvalidOperationException(string.Format("The following XML binding operation is not well formatted, it should start with '{{' and end with '}}:'{0}{1}", Environment.NewLine, xmlBindingValue));
                }

                var xmlBindingOperations = xmlBindingValue.Split(';');

                foreach (var bindingOperation in xmlBindingOperations)
                {
                    if (!bindingOperation.Contains(","))
                    {
                        throw new InvalidOperationException(string.Format("The following XML binding operation is not well formatted, it should contains at least one ',' between Source and Target:{0}{1}", Environment.NewLine, xmlBindingValue));
                    }

                    var bindingSourceValueRegex = new Regex(@"Source=(\w+)");
                    var bindingSourceValue = bindingSourceValueRegex.Match(bindingOperation).Groups[1].Value;

                    var bindingTargetValueRegex = new Regex(@"Target=(\w+)");
                    var bindingTargetValue = bindingTargetValueRegex.Match(bindingOperation).Groups[1].Value;

                    var bindingConverterValueRegex = new Regex(@"Converter=(\w+)");
                    var bindingConverterValue = bindingConverterValueRegex.Match(bindingOperation).Groups[1].Value;

                    // Converter parameter support using more than just a word.
                    var bindingConverterParameterValueRegex = new Regex(@"ConverterParameter='(\w+\s(.\w+)+)");
                    var bindingConverterParameterValue = bindingConverterParameterValueRegex.Match(bindingOperation).Groups[1].Value;

                    var bindingModeValue = BindingMode.OneWay;

                    var bindingModeValueRegex = new Regex(@"Mode=(\w+)");
                    var bindingModeValueRegexMatch = bindingModeValueRegex.Match(bindingOperation);

                    if (bindingModeValueRegexMatch.Success)
                    {
                        if (!System.Enum.TryParse(bindingModeValueRegexMatch.Groups[1].Value, true, out bindingModeValue))
                        {
                            throw new InvalidOperationException(string.Format("The Mode property of the following XML binding operation is not well formatted, it should be 'OneWay' or 'TwoWay':{0}{1}", Environment.NewLine, xmlBindingValue));
                        }
                    }

                    bindingOperations.Add(new BindingOperation { Control = viewElements.ElementAt(i), Source = bindingSourceValue, Target = bindingTargetValue, Converter = bindingConverterValue, ConverterParameter = bindingConverterParameterValue, Mode = bindingModeValue });
                }

            }
        }
    }

    return bindingOperations;
}

And then, we just need to analyse each binding operation to get the Mode, Source and Target propertied so we can setup the binding. For example, here is how we can setup a binding between a command (or a method) and an event:

var sourceProperty = typeof(TViewModel).GetProperty(bindingOperation.Source);

var bindingEvent = bindingOperation.Control.GetType().GetEvent(bindingOperation.Target);
if (bindingEvent != null)
{
    // The target is an event of the control

    if (sourceProperty != null)
    {
        // We need to ensure that the bound property implements the interface ICommand so we can call the "Execute" method
        var command = sourceProperty.GetValue(viewModel) as ICommand;
        if (command == null)
        {
            throw new InvalidOperationException(string.Format("The source property {0}, bound to the event {1}, needs to implement the interface ICommand.", bindingOperation.Source, bindingEvent.Name));
        }

        // Add an event handler to the specified event to execute the command when event is raised
        var executeMethodInfo = typeof(ICommand).GetMethod("Execute", new[] { typeof(object) });

        AddHandler(bindingOperation.Control, bindingOperation.Target, () =>
        {
            if (!_preventUpdateForSourceProperty)
            {
                executeMethodInfo.Invoke(command, new object[] { null });
            }
        });

        // Add an event handler to manage the CanExecuteChanged event of the command (so we can disable/enable the control attached to the command)
        var currentControl = bindingOperation.Control;

        var enabledProperty = currentControl.GetType().GetProperty("Enabled");
        if (enabledProperty != null)
        {
            enabledProperty.SetValue(currentControl, command.CanExecute(null));

            AddHandler(command, "CanExecuteChanged", () => enabledProperty.SetValue(currentControl, command.CanExecute(null)));
        }
    }
    else
    {
        // If the Source property of the ViewModel is not a 'real' property, check if it's a method
        var sourceMethod = typeof(TViewModel).GetMethod(bindingOperation.Source, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static);
        if (sourceMethod != null)
        {
            if (sourceMethod.GetParameters().Length > 0)
            {
                // We only support calls to methods without parameters
                throw new InvalidOperationException(string.Format("Method {0} should not have any parameters to be called when event {1} is raised.", sourceMethod.Name, bindingEvent.Name));
            }

            // If it's a method, add a event handler to the specified event to execute the method when event is raised
            AddHandler(bindingOperation.Control, bindingOperation.Target, () =>
            {
                if (!_preventUpdateForSourceProperty)
                {
                    sourceMethod.Invoke(viewModel, null);
                }
            });
        }
        else
        {
            throw new InvalidOperationException(string.Format("No property or event named {0} found to bint it to the event {1}.", bindingOperation.Source, bindingEvent.Name));
        }
    }
}

Using the binding feature from an AXML file is as simple as adding a new XML attribute matching the source property to the target property. Here are a few example of what is supported for now:

<!-- Simple OneWay binding -->
    <EditText
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:text="@string/Hello"
        local:Binding="{Source=SampleText, Target=Text}" />
<!-- Simple TwoWay binding -->
    <EditText
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:text="@string/Hello"
        local:Binding="{Source=SampleText, Target=Text, Mode=TwoWay}" />
<!-- Binding an event to a command -->
    <Button
        android:id="@+id/MyButton"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:text="Change EditText From Command"
        local:Binding="{Source=ButtonClickCommand, Target=Click}" />
    <EditText
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:text="@string/Hello"
        local:Binding="{Source=SampleText2, Target=Text, Mode=TwoWay;Source=TextCommand, Target=TextChanged}" />
<!-- Binding an event to a method -->
    <Button
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:text="Change EditText From Method"
        local:Binding="{Source=ClickOnButton, Target=Click}" />
<!-- Binding with a converter -->
    <EditText
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:text="@string/Hello"
        local:Binding="{Source=SampleBool, Target=Text, Mode=TwoWay, Converter=BooleanToStringConverter}" />
<!-- Binding with a converter & converter parameter -->
    <EditText
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:text="@string/Hello"
        local:Binding="{Source=SampleBool, Target=Text, Mode=TwoWay, Converter=BooleanToStringConverter, ConverterParameter='You can put any string here'}" />

I highly recommend you to take a look at the source code to see what can be done, how to setup TwoWay binding, etc. Also, for now, the engine does not support nested source properties (MyObject.MyFirstProperty.MySecondProperty) but this is something that I plan to implement as soon as possible (and if anybody think it’s a good idea/engine)!

 

The source code is available here for now but, based on feedbacks I have, it’s possible that I put it on Github to continue the development (and to allow pull requests).

So guys, what do you think about it ? Should I continue developing this binding engine ? Feel free to keep in touch with me by email, Twitter or in the comments to discuss about it!

 

Happy coding!

You can follow any responses to this entry through the RSS 2.0 You can leave a response, or trackback.

Add Comment Register



Leave a Reply