View-model-first with ItemsControls

Jan 17, 2011 at 3:14 PM
Edited Jan 18, 2011 at 9:50 PM

I am dealing with the following scenario:

  • I have to display a dynamic collection of view-models in an ItemsControl
  • Every view-model can a have a specific view
  • The view cannot be wrapped in a generic ContentControl

The last point means that I cannot use a simple DataTemplate like the following:

<DataTemplate>
   <ContentControl cm:Model="{Binding}"/>
</DataTemplate>

because I need to provide a specific view type. To understand the reason of this, consider a Menu, that uses MenuItems as ItemContainers: if the DataTemplate does not provide a MenuItem, you loose the ability to customize it, since the control generated by the DataTemplate is embedded in the MenuItem.Content.

This scenario is indeed quite common, given the fact that most of the ItemsControl do not use a simple ContentControl (see here for reference).

The only solution I could think of so far, is to create a specific DataTemplateSelector that exposes the functionalities of a the ViewLocator: such DataTemplateSelector would generate a DataTemplate on-the-fly, using the retrieved view type as the root of the DataTemplate.VisualTree, and perform the binding between the view and the view-model... something like this:

 

namespace Caliburn.Micro
{
    #region Namespaces
    using System;
    using System.Collections.Generic;
    using System.Windows;
    using System.Windows.Controls;
    using System.Windows.Data;
    using Caliburn.Micro;

    #endregion

    /// <summary>
    ///   Class used to create a template selector which is able to retrieve a data template using the <see cref = "ViewLocator" />.
    /// </summary>
    public class ViewLocatorTemplateSelector : DataTemplateSelector
    {
        #region Fields
        /// <summary>
        ///   The dictionary used to store cached templates.
        /// </summary>
        private readonly Dictionary<Type, DataTemplate> m_CachedTemplates;
        #endregion

        #region Properties
        /// <summary>
        ///   Gets the context used to retrieve the view.
        /// </summary>
        public object Context { get; private set; }
        #endregion

        /// <summary>
        ///   Initializes a new instance of the <see cref = "ViewLocatorTemplateSelector" /> class.
        /// </summary>
        public ViewLocatorTemplateSelector()
                : this(null)
        {
        }

        /// <summary>
        ///   Initializes a new instance of the <see cref = "ViewLocatorTemplateSelector" /> class.
        /// </summary>
        /// <param name = "context">The context.</param>
        public ViewLocatorTemplateSelector(object context)
        {
            m_CachedTemplates = new Dictionary<Type, DataTemplate>();
            Context = context;
        }

        /// <summary>
        ///   When overridden in a derived class, returns a <see cref = "T:System.Windows.DataTemplate" /> based on custom logic.
        /// </summary>
        /// <param name = "item">The data object for which to select the template.</param>
        /// <param name = "container">The data-bound object.</param>
        /// <returns>
        ///   Returns a <see cref = "T:System.Windows.DataTemplate" /> or null. The default value is null.
        /// </returns>
        public override DataTemplate SelectTemplate(object item, DependencyObject container)
        {
            DataTemplate dataTemplate;
            if (!m_CachedTemplates.TryGetValue(item.GetType(), out dataTemplate))
            {
                Type viewType = ViewLocator.LocateTypeForModelType(item.GetType(), container, Context);

                try
                {
                    var factory = new FrameworkElementFactory(viewType);
                    factory.SetBinding(Caliburn.Micro.View.ModelProperty, new Binding());
                    m_CachedTemplates[item.GetType()] = dataTemplate = new DataTemplate(item.GetType()) { VisualTree = factory };
                }
                catch (Exception exc)
                {
                    LogManager.GetLog(typeof(ViewLocatorTemplateSelector)).Error(exc);
                    dataTemplate = base.SelectTemplate(item, container);
                }
            }

            return dataTemplate;
        }
    }
}

 

Note that the ViewLocator.LocateTypeFromModelType is a custom method I think to use to avoid to generate an instance of a view when it is not needed (I just need the view type, not an actual instance), this is the proposed change to the ViewLocator class

 

/// <summary>
/// Locates the view for the specified model type.
/// </summary>
/// <returns>The view.</returns>
/// <remarks>Pass the model type, display location (or null) and the context instance (or null) as parameters and receive a view instance.</remarks>
public static Func<Type, DependencyObject, object, Type> LocateTypeForModelType = (modelType, displayLocation, context) =>{
    var viewTypeName = modelType.FullName.Replace("Model", string.Empty);
    if (context != null)
    {
        viewTypeName = viewTypeName.Remove(viewTypeName.Length - 4, 4);
        viewTypeName = viewTypeName + "." + context;
    }

    var viewType = (from assmebly in AssemblySource.Instance
                    from type in assmebly.GetExportedTypes()
                    where type.FullName == viewTypeName
                    select type).FirstOrDefault();

    return viewType;
};

/// <summary>
/// Locates the view for the specified model type.
/// </summary>
/// <returns>The view.</returns>
/// <remarks>Pass the model type, display location (or null) and the context instance (or null) as parameters and receive a view instance.</remarks>
public static Func<Type, DependencyObject, object, UIElement> LocateForModelType = (modelType, displayLocation, context) =>{
    var viewType = LocateTypeForModelType(modelType, displayLocation, context);

    return (viewType == null)
        ? new TextBlock { Text = string.Format("View for {0} not found.", modelType) }
        : GetOrCreateViewType(viewType);
};

Note that the change is trivial and does not involve breaking changes.

 

What do you think about this approach? Is it the correct way to go or there are alternatives?

Coordinator
Jan 17, 2011 at 4:06 PM

I haven't tried this myself, but it seams like it should work. Is it working for you? I can easily add the new method and perform the refactoring. If Silverlight supported DataTemplateSelector, this might even work as a default convention behavior. You could certainly alter the code to that end for your own purposes in WPF.

Jan 18, 2011 at 3:02 PM

I am testing this approach, and it seems to work... except for what seems to be a problem with the current implementation of View.SetContentPropertyCore

        private static void SetContentPropertyCore(object targetLocation, object view)
        {
            var type = targetLocation.GetType();
            var contentProperty = type.GetAttributes<ContentPropertyAttribute>(true)
                .FirstOrDefault() ?? new ContentPropertyAttribute("Content");

            type.GetProperty(contentProperty.Name)
                .SetValue(targetLocation, view, null);
        }

Such method is invoked on View.Model changed (View.OnModelChanged) and causes an exception to be thrown if the view is an ItemsControl.

In such a case, infact, the ContentAttribute will target the Items collection, which has no setter, thus the exception.

Probably the issue did not come out until now because setting View.Model on an ItemsControl is quite uncommon (unless you are using the above code).

This is the code I used to fix the issue (just a preliminary check to see if the Content can be set)

        private static void SetContentPropertyCore(object targetLocation, object view)
        {
            var type = targetLocation.GetType();
            var contentProperty = type.GetAttributes<ContentPropertyAttribute>(true)
                .FirstOrDefault() ?? new ContentPropertyAttribute("Content");

            var property = type.GetProperty(contentProperty.Name);
            if (property.CanWrite)
                property.SetValue(targetLocation, view, null);
        }

I am going to submit this issue and the patch right now (and a repro project, since I'm at it).

By the way, I am going to propose the change to the ViewLocator, together with a patch, it makes sense to me to have a point to retrieve just the view type.

Regarding the DataTemplateSelector, I suppose that it could fit in the WPF version of the framework, or a separate receipe, but I really don't know about the default convention behaviour... I am currently using an attached property to enable this feature on ItemsControls, and I suppose it is better to let it be optional, since setting the selector unknowingly can be potentially dangerous.

Jan 18, 2011 at 3:27 PM
Edited Jan 18, 2011 at 9:47 PM

On a second tought, it seems that the current implementation is consistent with the meaning of View.Model and the issue is generated by the fact that I am using the API improperly.

What I really need is just a way to call ViewModelBinder.Bind on the created view, once the DataTemplate is applied. It seems that Bind.Model would do the trick, but it has some 'extra bits' (View.IsScopeRoot and View.IsLoaded) that are used in a view-first scenario... I'll try to overcome the issue and provide some feedback before posting issues and such.

Jan 18, 2011 at 4:56 PM
Edited Jan 18, 2011 at 9:48 PM

I solved the issue with a couple of private attached dependency properties, used to trigger the bind operation with the proper context. This approach is quite nice and I think could be useful when dealing with collections of view-models.

The selector can work without any change to the existing API, yet I would prefer that ViewLocator class was changed to allow to retrieve a view type, as exposed before (I'll write a work-item for this).

I created a sample project using the DataTemplateSelector approach (using the current ViewLocator implementation) which includes a handy attached behaviour (i.e. ItemsControl.LocateViews) used to easily set the template selector on an ItemsControl.

You can download the sample here.

Mar 30, 2011 at 8:57 AM

Hi,
what's the current situation with the suggestions here?
Is the sample current to CM RC?
Thanks
John

Mar 30, 2011 at 10:04 AM

Rob implemented all required code to make this suggestion work. I currently use this approach in my project.

You just need the following classes to make it work (note that this code is an update of the previous example).

  • ViewLocatorTemplateSelector

namespace Framework
{
    #region Namespaces
    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Runtime.CompilerServices;
    using System.Windows;
    using System.Windows.Controls;
    using System.Windows.Data;
    using System.Windows.Threading;
    using Caliburn.Micro;
    using Action = System.Action;

    #endregion

    /// <summary>
    ///   Class used to create a template selector which is able to retrieve a data template using the <see cref = "ViewLocator" />.
    /// </summary>
    public class ViewLocatorTemplateSelector : DataTemplateSelector
    {
        #region Static Fields
        /// <summary>
        ///   The default key.
        /// </summary>
        private static readonly object m_DefaultContextKey = new object();

        /// <summary>
        ///   The Context attached dependency property.
        /// </summary>
        private static readonly DependencyProperty ContextProperty = DependencyProperty.RegisterAttached("Context", typeof(object), typeof(ViewLocatorTemplateSelector), new FrameworkPropertyMetadata((object)null));

        /// <summary>
        ///   The Model attached dependency property.
        /// </summary>
        private static readonly DependencyProperty ModelProperty = DependencyProperty.RegisterAttached("Model", typeof(object), typeof(ViewLocatorTemplateSelector), new FrameworkPropertyMetadata(null, new PropertyChangedCallback(OnModelChanged)));

        /// <summary>
        ///   The dictionary used to store cached templates.
        /// </summary>
        private static readonly Dictionary<object, Dictionary<Type, Dictionary<int, WeakReference>>> m_Cache = new Dictionary<object, Dictionary<Type, Dictionary<int, WeakReference>>>();

        /// <summary>
        ///   Flag used to determine if a clean-up has already been scheduled.
        /// </summary>
        private static bool m_IsCleanUpScheduled;
        #endregion

        #region Static Members
        /// <summary>
        ///   Gets the context associated to view.
        /// </summary>
        /// <param name = "obj">The dependency object.</param>
        /// <returns>The context associated to view</returns>
        [AttachedPropertyBrowsableForType(typeof(DependencyObject))]
        private static object GetContext(DependencyObject obj)
        {
            return obj.GetValue(ContextProperty);
        }

        /// <summary>
        ///   Called when the Model property has changed.
        /// </summary>
        /// <param name = "obj">The dependency object.</param>
        /// <param name = "e">The <see cref = "System.Windows.DependencyPropertyChangedEventArgs" /> instance containing the event data.</param>
        private static void OnModelChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
        {
            ViewModelBinder.Bind(e.NewValue, obj, GetContext(obj));
        }

        /// <summary>
        ///   Performs the internal cache clean-up.
        /// </summary>
        private static void CleanUp()
        {
            foreach (var cachePair in m_Cache.ToArray())
            {
                foreach (var viewModelCachePair in cachePair.Value.ToArray())
                {
                    foreach (var displayLocationCachePair in viewModelCachePair.Value.ToArray().Where(p => !p.Value.IsAlive))
                        viewModelCachePair.Value.Remove(displayLocationCachePair.Key);

                    if (viewModelCachePair.Value.Count == 0)
                        cachePair.Value.Remove(viewModelCachePair.Key);
                }

                if (cachePair.Value.Count == 0)
                    m_Cache.Remove(cachePair.Key);
            }

            m_IsCleanUpScheduled = false;
        }

        /// <summary>
        ///   Schedules a clean-up of the internal cache.
        /// </summary>
        private static void ScheduleCleanUp()
        {
            if (m_IsCleanUpScheduled)
                return;

            m_IsCleanUpScheduled = true;
            Dispatcher.CurrentDispatcher.BeginInvoke((Action)CleanUp, DispatcherPriority.Background);
        }

        /// <summary>
        ///   Enables view model binding over the element created by the specified factory.
        /// </summary>
        /// <param name = "factory">The factory used to generate the view.</param>
        /// <param name = "context">The context.</param>
        protected static void EnableViewModelBinding(FrameworkElementFactory factory, object context)
        {
            factory.SetValue(ContextProperty, context); //Prepare the context to be used  during the Bind operation...
            factory.SetBinding(ModelProperty, new Binding());
            //Prepare the binding used to trigger the Bind operation...
        }
        #endregion

        #region Properties
        /// <summary>
        ///   Gets the context used to retrieve the view.
        /// </summary>
        public object Context { get; private set; }
        #endregion

        /// <summary>
        ///   Initializes a new instance of the <see cref = "ViewLocatorTemplateSelector" /> class.
        /// </summary>
        public ViewLocatorTemplateSelector()
                : this(null)
        {
        }

        /// <summary>
        ///   Initializes a new instance of the <see cref = "ViewLocatorTemplateSelector" /> class.
        /// </summary>
        /// <param name = "context">The context.</param>
        public ViewLocatorTemplateSelector(object context)
        {
            Context = context;
        }

        /// <summary>
        ///   When overridden in a derived class, returns a <see cref = "T:System.Windows.DataTemplate" /> based on custom logic.
        /// </summary>
        /// <param name = "item">The data object for which to select the template.</param>
        /// <param name = "container">The data-bound object.</param>
        /// <returns>
        ///   Returns a <see cref = "T:System.Windows.DataTemplate" /> or null. The default value is null.
        /// </returns>
        public override DataTemplate SelectTemplate(object item, DependencyObject container)
        {
            Dictionary<Type, Dictionary<int, WeakReference>> viewModelTypeCache;
            var contextKey = Context ?? m_DefaultContextKey;
            if (!m_Cache.TryGetValue(contextKey, out viewModelTypeCache))
                m_Cache[contextKey] = viewModelTypeCache = new Dictionary<Type, Dictionary<int, WeakReference>>();

            Dictionary<int, WeakReference> displayLocationCache;
            if (!viewModelTypeCache.TryGetValue(item.GetType(), out displayLocationCache))
                viewModelTypeCache[item.GetType()] = displayLocationCache = new Dictionary<int, WeakReference>();

            WeakReference dataTemplateReference;
            DataTemplate dataTemplate;
            var key = RuntimeHelpers.GetHashCode(container);

            if (!displayLocationCache.TryGetValue(key, out dataTemplateReference) || (dataTemplate = dataTemplateReference.Target as DataTemplate) == null)
            {
                dataTemplate = CreateDataTemplate(item, container, Context) ?? base.SelectTemplate(item, container);
                if (dataTemplate != null)
                    displayLocationCache[key] = new WeakReference(dataTemplate);
            }

            ScheduleCleanUp();
            return dataTemplate;
        }

        /// <summary>
        ///   Creates the data template.
        /// </summary>
        /// <param name = "viewModel">The view model.</param>
        /// <param name = "displayLocation">The display location.</param>
        /// <param name = "context">The context.</param>
        /// <returns>
        ///   The data template.
        /// </returns>
        protected virtual DataTemplate CreateDataTemplate(object viewModel, DependencyObject displayLocation, object context)
        {
            DataTemplate dataTemplate = null;
            //Locate the view (it is a view-model-first approach)...
            var viewType = ViewLocator.LocateTypeForModelType(viewModel.GetType(), displayLocation, context);
            if (viewType != null)
            {
                try
                {
                    var factory = new FrameworkElementFactory(viewType); //Create a factory able to generate the view...
                    EnableViewModelBinding(factory, context);

                    //Create the data template from scratch...
                    dataTemplate = new DataTemplate(viewModel.GetType()) {
                                                                                 VisualTree = factory
                                                                         };
                    dataTemplate.Seal();
                }
                catch (Exception exc)
                {
                    //Report the exception and execute the default beahviour...
                    LogManager.GetLog(typeof(ViewLocatorTemplateSelector)).Error(exc);
                }
            }
            return dataTemplate;
        }
    }
}

  • ItemsControl.LocateViews attachewd behaviour

namespace Framework
{
    #region Namespaces
    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Windows;
    using System.Windows.Threading;

    #endregion

    /// <summary>
    ///   Static class used to store <see cref = "ItemsControl" />-related attached behaviours.
    /// </summary>
    public static class ItemsControl
    {
        #region Static Fields
        /// <summary>
        ///   The default key.
        /// </summary>
        private static readonly object m_DefaultKey = new object();

        /// <summary>
        ///   The dictionary used to retrieve a cached template selector.
        /// </summary>
        private static readonly Dictionary<object, WeakReference> m_Cache = new Dictionary<object, WeakReference>();

        /// <summary>
        ///   The Context attached dependency property.
        /// </summary>
        public static readonly DependencyProperty ContextProperty = DependencyProperty.RegisterAttached("Context", typeof(object), typeof(ItemsControl), new FrameworkPropertyMetadata(null, new PropertyChangedCallback(OnContextChanged)));

        /// <summary>
        ///   The LocateViews attached dependency property.
        /// </summary>
        public static readonly DependencyProperty LocateViewsProperty = DependencyProperty.RegisterAttached("LocateViews", typeof(bool), typeof(ItemsControl), new FrameworkPropertyMetadata(false, new PropertyChangedCallback(OnLocateViewsChanged)));

        private static bool m_IsCleanUpScheduled;
        #endregion

        #region Static Members
        /// <summary>
        ///   Gets the context used to retrieve the items views.
        /// </summary>
        /// <param name = "obj">The dependency object.</param>
        /// <returns>The context used to retrieve the views</returns>
        [AttachedPropertyBrowsableForType(typeof(System.Windows.Controls.ItemsControl))]
        public static object GetContext(System.Windows.Controls.ItemsControl obj)
        {
            return obj.GetValue(ContextProperty);
        }

        /// <summary>
        ///   Sets the context used to retrieve the items views.
        /// </summary>
        /// <param name = "obj">The dependency object.</param>
        /// <param name = "value">The context used to retrieve the views.</param>
        public static void SetContext(System.Windows.Controls.ItemsControl obj, object value)
        {
            obj.SetValue(ContextProperty, value);
        }

        /// <summary>
        ///   Called when the Context property has changed.
        /// </summary>
        /// <param name = "obj">The dependency object.</param>
        /// <param name = "e">The <see cref = "System.Windows.DependencyPropertyChangedEventArgs" /> instance containing the event data.</param>
        private static void OnContextChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
        {
            var itemsControl = (System.Windows.Controls.ItemsControl)obj;
            if (GetLocateViews(itemsControl))
                SetItemTemplateSelector(itemsControl, e.NewValue);

            ScheduleCleanUp();
        }

        /// <summary>
        ///   Gets the flag used to determine if views should be located for the specified <see cref = "ItemsControl" />.
        /// </summary>
        /// <param name = "obj">The dependency object.</param>
        /// <returns>The flag used to determine if views should be located for the specified <see cref = "ItemsControl" /></returns>
        [AttachedPropertyBrowsableForType(typeof(System.Windows.Controls.ItemsControl))]
        public static bool GetLocateViews(System.Windows.Controls.ItemsControl obj)
        {
            return (bool)obj.GetValue(LocateViewsProperty);
        }

        /// <summary>
        ///   Sets the flag used to determine if views should be located for the specified <see cref = "ItemsControl" />.
        /// </summary>
        /// <param name = "obj">The dependency object.</param>
        /// <param name = "value">The flag used to determine if views should be located for the specified <see cref = "ItemsControl" />.</param>
        public static void SetLocateViews(System.Windows.Controls.ItemsControl obj, bool value)
        {
            obj.SetValue(LocateViewsProperty, value);
        }

        /// <summary>
        ///   Called when the LocateViews property has changed.
        /// </summary>
        /// <param name = "obj">The dependency object.</param>
        /// <param name = "e">The <see cref = "System.Windows.DependencyPropertyChangedEventArgs" /> instance containing the event data.</param>
        private static void OnLocateViewsChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
        {
            var itemsControl = (System.Windows.Controls.ItemsControl)obj;
            if ((bool)e.NewValue)
                SetItemTemplateSelector(itemsControl, GetContext(itemsControl));
            else
                ClearItemTemplateSelector(itemsControl);

            ScheduleCleanUp();
        }

        /// <summary>
        ///   Sets the item template selector.
        /// </summary>
        /// <param name = "itemsControl">The items contol.</param>
        /// <param name = "context">The context.</param>
        private static void SetItemTemplateSelector(System.Windows.Controls.ItemsControl itemsControl, object context)
        {
            WeakReference selectorReference;
            ViewLocatorTemplateSelector selector;
            var key = context ?? m_DefaultKey;
            if (!m_Cache.TryGetValue(key, out selectorReference) || (selector = selectorReference.Target as ViewLocatorTemplateSelector) == null)
                m_Cache[key] = new WeakReference(selector = new ViewLocatorTemplateSelector(context));

            itemsControl.SetValue(System.Windows.Controls.ItemsControl.ItemTemplateSelectorProperty, selector);
        }

        /// <summary>
        ///   Clears the item template selector.
        /// </summary>
        /// <param name = "itemsControl">The items control.</param>
        private static void ClearItemTemplateSelector(System.Windows.Controls.ItemsControl itemsControl)
        {
            itemsControl.ClearValue(System.Windows.Controls.ItemsControl.ItemTemplateSelectorProperty);
        }

        /// <summary>
        ///   Performs the internal cache clean-up.
        /// </summary>
        private static void CleanUp()
        {
            foreach (var pair in m_Cache.ToArray().Where(r => !r.Value.IsAlive))
                m_Cache.Remove(pair.Key);

            m_IsCleanUpScheduled = false;
        }

        /// <summary>
        ///   Schedules a clean-up of the internal cache.
        /// </summary>
        private static void ScheduleCleanUp()
        {
            if (m_IsCleanUpScheduled)
                return;

            m_IsCleanUpScheduled = true;
            Dispatcher.CurrentDispatcher.BeginInvoke((Action)CleanUp, DispatcherPriority.Background);
        }
        #endregion
    }
}

The first class is the DataTemplateSelector implementation, the second one is a simple attached behaviour to allow a better XAML experience:

<ItemsControl ItemsSource="{Binding Path=MyItems}"
              mvvm:ItemsControl.LocateView="True"
              mvvm:ItemsControl.Context="myContext"/>

Note that the above code uses caching to avoid to generate too many DataTemplate or DataTemplateSelector.

To be precise, just one template selector per context and one data template for each (context, view-model type, container) triplet.