Injecting Views into properties with a View-Model-First approach

Dec 20, 2010 at 11:49 PM

While trying to refactor the current application I am working on, I had some problems with a third party control that is not so MVVM-firendly. In particular, the current RibbonWindow implementation I am using requires that a Ribbon control and a StatusBar control are directly set as depedency properties. Unfortunally, the standard Caliburn.Micro view-model-first approach (using View.Model to inject a view into a ContentControl) is not applicabile to this scenario, since such controls are not ContentControls and there is no way to define a template for them.

This issue led me to develop an alternative to the View.Model approach, that can be used in this kind of scenario or wherever a ContentControl cannot be used as the container of the injected view (e.g. with a ContextMenu).

A quick sample of this approach is shown below (taken from the attached sample project):

 

<Rectangle Fill="Blue"
           ContextMenu="{ext:Inject Model={Binding Path=ContextMenu}}"/>

 

with the above code the extension will inject the view associated to the view-model retrieved from the DataContext ContextMenu property.

As you can imagine, this extension can be even used in place of the standard View.Model attached property, since it is possible to target the Content property of a ContentControl:

 

<ContentControl Content="{ext:Inject Model={Binding Path=MyViewModel}}"/>

 

In other words, it is a generalization of the Caliburn.Micro implementation, that can be used to overcome the limitations of controls that were not designed to be MVVM-friendly.

 

The source code of the extension is shown below:

 

namespace Caliburn.Micro.Extensions
{
    #region Namespaces
    using System;
    using System.Collections.ObjectModel;
    using System.Globalization;
    using System.Linq;
    using System.Windows;
    using System.Windows.Data;
    using System.Windows.Markup;

    #endregion

    /// <summary>
    ///   Class used to define the injection of a view (retrieved using a view-model) into a property of another view.
    /// </summary>
    [ContentProperty("Model")]
    public class Inject : MultiBinding, IAddChild
    {
        #region Properties
        /// <summary>
        ///   Gets the converter used to extract the view to be injected, using the specified view-model.
        /// </summary>
        /// <value>The converter used to extract the view to be injected, using the specified view-model.</value>
        public new IMultiValueConverter Converter
        {
            get { return base.Converter; }
        }

        /// <summary>
        ///   Gets the optional parameter passed to the converter as additional information.
        /// </summary>
        /// <value>The optional parameter passed to the converter as additional information.</value>
        public new object ConverterParameter
        {
            get { return base.ConverterParameter; }
        }

        /// <summary>
        ///   Gets or sets a value that indicates the direction of the data flow of this binding.
        /// </summary>
        /// <value>The direction of the data flow of this binding.</value>
        public new BindingMode Mode
        {
            get { return base.Mode; }
            set
            {
                if (value != BindingMode.OneWay && value != BindingMode.OneTime)
                    throw new InvalidOperationException("An inject can only be defined as OneWay or OneTime");
            }
        }

        /// <summary>
        ///   Gets or sets the context.
        /// </summary>
        /// <value>The context.</value>
        public object Context
        {
            get { return ((ViewModelToViewMultiConverterParameter)base.ConverterParameter).Context; }
            set { ((ViewModelToViewMultiConverterParameter)base.ConverterParameter).Context = value; }
        }

        /// <summary>
        ///   Gets the collection of <see cref = "T:System.Windows.Data.Binding" /> objects within this <see cref = "Inject" /> instance.
        /// </summary>
        /// <value></value>
        /// <returns>A collection of <see cref = "T:System.Windows.Data.Binding" /> objects.</returns>
        public new ReadOnlyCollection<BindingBase> Bindings
        {
            get { return new ReadOnlyCollection<BindingBase>(base.Bindings); }
        }

        /// <summary>
        ///   Gets or sets the model used to inject the view.
        /// </summary>
        /// <value>The model used to inject the view.</value>
        public BindingBase Model
        {
            get { return base.Bindings.FirstOrDefault(); }
            set
            {
                Collection<BindingBase> collection = base.Bindings;
                if (collection.Count > 0)
                    collection[0] = value;
                else
                    collection.Add(value);
            }
        }
        #endregion

        /// <summary>
        ///   Initializes a new instance of the <see cref = "Inject" /> class.
        /// </summary>
        public Inject()
        {
            base.Mode = BindingMode.OneWay;
            base.Converter = ViewModelToViewMultiConverter.Default;
            base.ConverterParameter = new ViewModelToViewMultiConverterParameter();
        }

        #region IAddChild Members
        /// <summary>
        ///   Adds a child object.
        /// </summary>
        /// <param name = "value">The child object to add.</param>
        void IAddChild.AddChild(object value)
        {
            throw new NotSupportedException("Direct content is not supported");
        }

        /// <summary>
        ///   Adds the text content of a node to the object.
        /// </summary>
        /// <param name = "text">The text to add to the object.</param>
        void IAddChild.AddText(string text)
        {
            throw new NotSupportedException("Direct content is not supported");
        }
        #endregion

        #region Nested type: ViewModelToViewMultiConverter
        /// <summary>
        ///   Class used to define the multi-converter used to retrieve a view using a view-model.
        /// </summary>
        private class ViewModelToViewMultiConverter : IMultiValueConverter
        {
            #region Static Properties
            /// <summary>
            ///   Gets the default instance of the class.
            /// </summary>
            /// <value>The the default instance of the class.</value>
            public static ViewModelToViewMultiConverter Default
            {
                get { return Instancer._Instance; }
            }
            #endregion

            #region IMultiValueConverter Members
            /// <summary>
            ///   Converts source values to a value for the binding target. The data binding engine calls this method when it propagates the values from source bindings to the binding target.
            /// </summary>
            /// <param name = "values">The array of values that the source bindings in the <see cref = "T:System.Windows.Data.MultiBinding" /> produces. The value <see cref = "F:System.Windows.DependencyProperty.UnsetValue" /> indicates that the source binding has no value to provide for conversion.</param>
            /// <param name = "targetType">The type of the binding target property.</param>
            /// <param name = "parameter">The converter parameter to use.</param>
            /// <param name = "culture">The culture to use in the converter.</param>
            /// <returns>
            ///   A converted value.If the method returns null, the valid null value is used.A return value of <see cref = "T:System.Windows.DependencyProperty" />.<see cref = "F:System.Windows.DependencyProperty.UnsetValue" /> indicates that the converter did not produce a value, and that the binding will use the <see cref = "P:System.Windows.Data.BindingBase.FallbackValue" /> if it is available, or else will use the default value.A return value of <see cref = "T:System.Windows.Data.Binding" />.<see cref = "F:System.Windows.Data.Binding.DoNothing" /> indicates that the binding does not transfer the value or use the <see cref = "P:System.Windows.Data.BindingBase.FallbackValue" /> or the default value.
            /// </returns>
            public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
            {
                var param = (ViewModelToViewMultiConverterParameter)parameter;

                UIElement view = null;
                object viewModel = values.FirstOrDefault();
                if (viewModel != null)
                {
                    view = ViewLocator.LocateForModel(viewModel, null, param.Context);
                    ViewModelBinder.Bind(viewModel, view, param.Context);
                }
                return view;
            }

            /// <summary>
            ///   Converts a binding target value to the source binding values.
            /// </summary>
            /// <param name = "value">The value that the binding target produces.</param>
            /// <param name = "targetTypes">The array of types to convert to. The array length indicates the number and types of values that are suggested for the method to return.</param>
            /// <param name = "parameter">The converter parameter to use.</param>
            /// <param name = "culture">The culture to use in the converter.</param>
            /// <returns>
            ///   An array of values that have been converted from the target value back to the source values.
            /// </returns>
            public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
            {
                throw new NotSupportedException();
            }
            #endregion

            #region Nested type: Instancer
            private static class Instancer
            {
                #region Static Fields
                internal static readonly ViewModelToViewMultiConverter _Instance = new ViewModelToViewMultiConverter();
                #endregion
            }
            #endregion
        }
        #endregion

        #region Nested type: ViewModelToViewMultiConverterParameter
        /// <summary>
        ///   Class used to define the parameters used by the converter that extract a view from a view-model.
        /// </summary>
        private class ViewModelToViewMultiConverterParameter
        {
            #region Properties
            /// <summary>
            ///   Gets or sets the context.
            /// </summary>
            /// <value>The context.</value>
            public object Context { get; set; }
            #endregion
        }
        #endregion
    }
}

 

 

As you can see, the underlying implementation of the extension is a MultiBinding; this implementation is necessary to overcome some limitations in the .NET framework extensibility, such as:

  • the fact that the only ways to inherit the databinding context is either inherit from Freezable or from BindingBase
  • even if inherited, BindingBase does not allow for customization, due to the fact that the ProvideValue function is sealed
  • Freezable objects are not MarkupExtensions and cannot be used to produce a value to inject
  • the Binding class has many specific properties and risks to make the extension confusing for the user

Moreover, using a MultiBinding allows for a better extensibility, allowing for multiple values to be passed as bindings (e.g. a fallback view-model if the required one is not found).

The only issue with the Inject extension is that, since it is a BindingBase, it cannot be used on plain CLR properties, but just DependencyProperties; the issue is pretty much non-existent, since WPF/Silverlight controls mostrly avoid to declare and use plain CLR properties, nevertheless I implemented another solution that has not this limitation and avoids to use reflection. In short, I created an attached property that can be used to target individual properties of a view (defined using a PropertyPath), and inject a sub-view retrieved using a specified view-model... if you are interested in this solution, you can check the code available in the link below, and I'll be willing to answer your questions (if any)... an example of the usage of such approach is the following one:

 

<Grid Background="Green">
     <ext:View.SubViews>
          <ext:SubViewCollection>
               <ext:SubView Model="{Binding Path=ContextMenu}"
                            Property="ContextMenu"/>
          </ext:SubViewCollection>
     </ext:View.SubViews>
</Grid>

As you can see, the syntax is far less elegant than the Inject...

 

I hope this solution can help people that are struggling with the same problem I was facing.

A very simple test solution (containing the source code for both solutions) is available here.

 

P.S.

Thanks a lot to Marco for the code review and the nice ideas he gave me while I was implementing this stuff! :)