Sunday, March 27, 2011

Jounce Part 14: Using MEF for Non-Shared Views and View Models

Even if you don't use Jounce, this post will help you better understand how to create non-shared views and view models with the Managed Extensibility Framework (MEF). The architecture I prefer in Silverlight is to keep a shared view and view model where possible. If only one instance of the view is visible at a time, the view model can be manipulated to provide the correct bindings and information when different data is selected for that view. Keeping a single view avoids issues with waiting for multiple views in garbage collection or keeping track of multiple views that might not unhook from the visual tree correctly.

There are several cases where this is not plausible. In a master/detail scenario with dockable views, you may end up with multiple "detail" views on the screen. Each view requires a separate copy and possibly a separate view model as well.

Lazy Shared Views and View Models

In MEF, the Lazy<T,TMetadata> syntax is used to import multiple implementations of a contract and associated metadata. The pattern allows you to inspect the metadata before creating the object instances, so you have full control over what is happening. Jounce uses this feature to bind views and view models by inspecting the metadata and using that to examine "routes" created by the user (a route simply asserts that view "X" requires view model "Y").

The view model import looks like this:

[ImportMany(AllowRecomposition = true)]
public Lazy<IViewModel, IExportAsViewModelMetadata>[] ViewModels { get; set; }

The array can be queried with LINQ. In Jounce, the view model metadata simply provides a user-defined tag for the view model so it can be referenced without knowing the type (this is useful, for example, when the view model must be referenced before the XAP that contains it has been loaded). Here is the metadata contract:

public interface IExportAsViewModelMetadata
{
    string ViewModelType { get; }
}

Here is a simple query to get the associated information for the view model. What's important to note is that part of this information is a Lazy<T> property for the view model. When the value property is accessed, the view model is created, but any subsequent references will use the same copy, or a "shared" view model:

var vmInfo = (from vm in ViewModels
                where vm.Metadata.ViewModelType.Equals(viewModelType)
                select vm).FirstOrDefault();

Using the Export Factory

To get a fresh copy, instead of using the lifetime management attributes that MEF provides, I chose to go with the ExportFactory. Unlike a Lazy import, the export factory does not provide a copy of the target object. Instead, it provides the means of creating a new copy. In other words, you are provided with an actual factory to generate new copies. The convention for this is very similar to the lazy version:

[ImportMany(AllowRecomposition = true)]
public List<ExportFactory<IViewModel, IExportAsViewModelMetadata>> ViewModelFactory { get; set; }

Notice that again metadata exists, so you can inspect the factory to make sure it is the right factory before asking it to generate a new object. Now it is a simple matter to query for the right view model and then ask the export factory to generate a new copy:

public IViewModel GetNonSharedViewModel(string viewModelType)
{
    return (from factory in ViewModelFactory
            where factory.Metadata.ViewModelType.Equals(viewModelType)
            select factory.CreateExport().Value).FirstOrDefault();
}

The Ties that Bind

Now you are able to generate a view and a view model on the fly. Jounce adds helper methods to view models to help synchronize with the view. For example, InitializeViewModel is called the first time the view model is bound to the view, and ActivateView is called when a view is loaded that is bound to the view model. There is also a binding for the visual state manager so that the view model can transition states without being aware of the view.

In order to bind property, Jounce provides a simple mechanism for generating a new copy of a view that allows you to pass in the data context:

UserControl GetNonSharedView(string viewTag, object dataContext);

If you have simple views, this can take a model or other class that is not a view model and will still bind it to the data context for you. However, if the bound object is a Jounce view model, the binding will add some additional calls and hooks to ensure that visual states and other bindings are updated as needed. This is what the view creation method looks like:

public UserControl GetNonSharedView(string viewTag, object dataContext)
{
    var view = (from factory in ViewFactory
                where factory.Metadata.ExportedViewType.Equals(viewTag)
                select factory.CreateExport().Value).FirstOrDefault();

    if (view == null)
    {
        return null;
    }

    _BindViewModel(view, dataContext);
                
    var baseViewModel = dataContext as BaseViewModel;
    if (baseViewModel != null)
    {
        baseViewModel.RegisterVisualState(viewTag,
                (state, transitions) => JounceHelper.ExecuteOnUI(
                  () => VisualStateManager.GoToState(view, state, transitions)));
        baseViewModel.RegisteredViews.Add(viewTag);
        baseViewModel.Initialize();
        RoutedEventHandler loaded = null;
        loaded = (o, e) =>
                        {
                            ((UserControl) o).Loaded -= loaded;
                            baseViewModel.Activate(viewTag, new Dictionary<string, object>());
                        };
        view.Loaded += loaded;
    }
    return view;
}

Notice the use of the variable to allow unbinding the lambda expression after the first loaded event is fired (the load event is triggered every time the control is placed in the visual tree, but the bindings only need to be updated once).

DataTemplate Selectors

The final step for Jounce was to create a special value converter for spinning views. This is similar to the concept I wrote about in Data Template Selector using the Managed Extensibility Framework. Only in this case, the converter will do one of two things: if passed a Jounce view model, it will automatically find the view the view model is associated with, generate a new copy of the view, and bind it, or if passed a parameter, it will create a view with the tag provided in the parameter.

The Jounce quickstart (available by grabbing the latest Jounce source download) contains an example of this. The target model is a contact class:

public class Contact
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Address { get; set; }
    public string City { get; set; }
    public string State { get; set; }
}

The view model for the model simply passes through to the underlying properties. In a full implementation, it would save the orignal model for roll back and have other options such as commands to manipulate the data:

[ExportAsViewModel("ContactVM")]
public partial class ContactViewModel : BaseViewModel
{
    public ContactViewModel()
    {
        if (DesignerProperties.IsInDesignTool)
        {
            SetDesignerData();
        }
    }

    public Contact SourceContact { get; set; }
        
    public string FirstName
    {
        get { return SourceContact.FirstName; }
        set
        {
            SourceContact.FirstName = value;
            RaisePropertyChanged(()=>FirstName);
        }
    }

    public string LastName
    {
        get { return SourceContact.LastName; }
        set
        {
            SourceContact.LastName = value;
            RaisePropertyChanged(()=>LastName);
        }
    }

    public string Address
    {
        get { return SourceContact.Address; }
        set
        {
            SourceContact.Address = value;
            RaisePropertyChanged(()=>Address);
        }
    }

    public string City
    {
        get { return SourceContact.City; }
        set
        {
            SourceContact.City = value;
            RaisePropertyChanged(()=>City);
        }
    }

    public string State
    {
        get { return SourceContact.State; }
        set
        {
            SourceContact.State = value;
            RaisePropertyChanged(()=>State);
        }
    }
}

The view simply shows the last name, first name in bold and then the address on the second line:

<Grid x:Name="LayoutRoot" Background="White" d:DataContext="{d:DesignInstance vm:ContactViewModel, IsDesignTimeCreatable=True}">
    <Grid.RowDefinitions>
        <RowDefinition Height="Auto"/>
        <RowDefinition Height="Auto"/>
    </Grid.RowDefinitions>
    <StackPanel Orientation="Horizontal" Margin="5">
        <TextBlock FontWeight="Bold" Text="{Binding LastName}"/>
        <TextBlock FontWeight="Bold" Text=", "/>
        <TextBlock FontWeight="Bold" Text="{Binding FirstName}"/>
    </StackPanel>
    <StackPanel Orientation="Horizontal" Grid.Row="1" Margin="5">
        <TextBlock Text="{Binding Address}"/>
        <TextBlock Text=" "/>
        <TextBlock Text="{Binding City}"/>
        <TextBlock Text=", "/>
        <TextBlock Text="{Binding State}"/>
    </StackPanel>
</Grid>

Here is the view with sample data in the designer:

The view itself is exported in the code-behind, as well as a binding mapping it to the corresponding view model:

[ExportAsView("ContactView")]
public partial class ContactView
{
    public ContactView()
    {
        InitializeComponent();
    }

    [Export]
    public ViewModelRoute Binding
    {
        get { return ViewModelRoute.Create("ContactVM", "ContactView"); }
    }
}

The main view model starts out by providing a list of contacts (this one generates sample data for the sake of the demo):

[ExportAsViewModel("MainVM")]
public class MainViewModel : BaseViewModel
{
    private readonly List<Contact> _sampleData = new List<Contact>
                                            {
                                                new Contact
                                                    {
                                                        FirstName = "Jeremy",
                                                        LastName = "Likness",
                                                        Address = "1212 Hollywood Blvd",
                                                        City = "Hollywood",
                                                        State = "California"
                                                    },
                                                new Contact
                                                    {
                                                        FirstName = "John",
                                                        LastName = "Doe",
                                                        Address = "12 Driving Parkway",
                                                        City = "St. Petersburg",
                                                        State = "Florida"
                                                    },
                                                new Contact
                                                    {
                                                        FirstName = "Jane",
                                                        LastName = "Doe",
                                                        Address = "1414 Disk Drive",
                                                        City = "Lead",
                                                        State = "South Dakota"
                                                    },
                                                new Contact
                                                    {
                                                        FirstName = "Sam",
                                                        LastName = "Iam",
                                                        Address = "12 Many Terrace",
                                                        City = "Figment",
                                                        State = "Imagination"
                                                    },

                                            };

    public MainViewModel()
    {
        Contacts = new ObservableCollection<Contact>(_sampleData);
    }

    public ObservableCollection<Contact> Contacts { get; private set; }
}

The problem is that our view requires a view model (it might handle edits or deletes in the future, so the model itself isn't enough). No problem! First, create a set of extension methods: one to pass the contact into a view model, and another to handle a list of contacts that will return a list of contact view models:

public static ContactViewModel ToViewModel(this Contact contact, IViewModelRouter router)
{
    var vm = router.GetNonSharedViewModel("ContactVM") as ContactViewModel;

    if (vm == null)
    {
        throw new Exception("Couldn't create view model for contact.");
    }

    vm.SourceContact = contact;
    return vm;
}

public static IEnumerable<ContactViewModel> ToViewModels(this IEnumerable<Contact> contacts, IViewModelRouter router)
{
    return contacts.Select(contact => contact.ToViewModel(router)).ToList();
}

Notice the router is used to get a non-shared copy of the view model, then the contact is passed in. The list function simply applies the conversion to the entire list. Of course, the view model tag can be specified as a constant to avoid the "magic string" and make it easier to refactor down the road.

The main view model can now be tweaked to expose a list of view models:

public IEnumerable<ContactViewModel> ViewModels
{
    get { return Contacts.ToViewModels(Router); }
}

It's a very simple conversion using the helper extension methods. To ensure the enumerable is re-loaded anytime the underlying collection changes, simply add this after the observable collection is created:

Contacts.CollectionChanged += (o, e) => RaisePropertyChanged(() => ViewModels);

Next the XAML is updated to use the special converter:

<ListBox ItemsSource="{Binding ViewModels}">
    <ListBox.ItemTemplate>
        <DataTemplate>
            <ContentControl Content="{Binding Converter={StaticResource ViewConverter}}"/>
        </DataTemplate>
    </ListBox.ItemTemplate>
</ListBox>

In this example, the binding is a list of contact view models. The special converter will inspect the binding, figure out the view model and find the corresponding view (the contact view we showed earlier), then create a new copy of the view and bind the view model. This could also have been done directly with the contact model if a view model wasn't being used. More importantly, if the list is type IViewModel it can contain different types of view models, and the converter will automatically find the appropriate view - this is a "View Model First" approach to binding but serves as a data template selector.

When the application is run, the non-shared views and view models spin up and bind as expected:

The full source code for Jounce and the example shown here is available from the Jounce CodePlex site.

Jeremy Likness

6 comments:

  1. Hi Jeremy,
    First of all thanks for all your very interesting articles!!
    I am using jounce in a project and I was wondering one thing :
    I have a view named OrderDetailView that I open this way :

    ViewNavigationArgs v = new ViewNavigationArgs("OrderDetailView");

    v.ViewParameters.Add("orderDetail", orderDetail);

    EventAggregator.Publish(v);

    I'd like to use it on a master/detail scenario with multiple "OrderDetailView " but keeping this way of passing parameters and publishing
    Is it actually possible with jounce or maybe planned for a future release?

    ReplyDelete
  2. I'll integrate that into the RTM - basically when you spin up a new view, instead of just a data context I'll provide an overload to pass parameters in as well.

    ReplyDelete
  3. Hi Jeremy
    Please tag this post with Jounce :)

    ReplyDelete
  4. Hi Jeremy
    Thanks for your quick response on my last question
    I'd like to ask you if there is a way to get non-shared views handled by regions the same way as other view
    For example, If I have a shared view X, when I call EventAggregator.Publish(X) the view is opened in the region I have defined with ExportViewToRegion attribute,how can I get a non-shared view Z with the same ExportViewToRegion attribute published in this region?
    Hope my question is clear enough because I don't speak english very well and thanks again for all your works!!

    ReplyDelete
  5. The problem with routing non-shared views to regions is that you end up losing parity with which view is what. It's not an impossible problem to solve, it's just that the current view tag = view isn't sufficient to describe how I would, say, remove a view. I need more fidelity and it can't be a specific instance. I could generate a tag or ticket but for now that is why I am having the views generate from the view models in the "many" scenario. It is something I am aware of and just trying to find the right way to solve it and keep true to the goals behind the guidance and framework.

    ReplyDelete