Thursday, July 8, 2010

Region Manager - Good or Evil?

What are your ideas in designing ViewModels that realize something like above? Nearly all applications we have came across have a desire to drill down in details. Or shall I say we found a realistic abstract view that improve user experience?

What I am about to discuss is how Region Manager comes to play when such an requirement is made. Whether clicking on a row, it shall bring out a child window or injection a view in the lower region of the same content. Many may say the way to Region Manager has been tough. More will say there are not enough documentation to move things along.

Where is Region Manager in MVVM

MVVM is a popular pattern everyone talks about. I am a real fan when it comes to WPF and Silverlight. From here I guess the answer is certain, ViewModel will be the one who manages regions and view injection. To declare a region in XAML, starting from mapping RegionManager namespace by xmlns:region="clr-namespace:Microsoft.Practices.Composite.Presentation.Regions;. Then we create a ContentControl or ItemsControl followed by RegionName.

<UserControl x:Class="CKL.AnimalFarm.Application.Silverlight.HorseStableView"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:i="clr-namespace:CKL.Infrastructure.Silverlight;assembly=CKL.Infrastructure.Silverlight"
    xmlns:region="clr-namespace:Microsoft.Practices.Composite.Presentation.Regions;assembly=Microsoft.Practices.Composite.Presentation"
    xmlns:sdk="http://schemas.microsoft.com/winfx/2006/xaml/presentation/sdk"
    mc:Ignorable="d"
    d:DesignHeight="300" d:DesignWidth="400">


    <Grid x:Name="LayoutRoot">
        <ContentControl region:RegionManager.RegionName="{Binding DetailRegionName}" />
    </Grid>
</UserControl>

In the ViewModel we will provide the RegionName and inject corrensponding view when required. A region is often given an unique name, so as the solution grows, other ViewModels will not be mis-using the same region unintentionally.

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows.Input;
using CKL.Infrastructure.Silverlight;
using Microsoft.Practices.Composite.Presentation.Commands;
using Microsoft.Practices.Composite.Regions;

namespace CKL.AnimalFarm.Application.Silverlight
{
    public class HorseStableViewModel : ViewModelBase
    {
        #region Region
        private Guid DetailRegionId = Guid.NewGuid();
        public string DetailRegionName { get { return string.Format("HorseStableDetailRegion_{0}", DetailRegionId); } }
        public IRegion DetailRegion { get { return this.RegionManager.Regions[DetailRegionName]; } }
        #endregion Region

   /* ... */

        private LiveStockOverview _mSelectedHorseView;

        public LiveStockOverview mSelectedHorseView
        {
            get { return _mSelectedHorseView; }
            set
            {
                _mSelectedHorseView = value;

                if (!this.DetailRegion.Views.Contains(_mSelectedHorseView))
                    this.DetailRegion.Add(_mSelectedHorseView);
                this.DetailRegion.Activate(_mSelectedHorseView);


                NotifyPropertyChanged(() => this.mSelectedHorseView);
                NotifyPropertyChanged(() => this.SelectedHorseViewModel);
                NotifyPropertyChanged(() => this.IsCurrentViewVisible);
                NotifyPropertyChanged(() => this.ToggleView);
                NotifyPropertyChanged(() => this.ToggleViewName);
            }
        }

        #endregion Model


        /* ... */
    }
}

What are the differences between ContentControl and ItemsControl when declaring a Region?

A region is categorized into three types. ContentControl is a SingleActiveRegion which allows a maximum of one active view at a time. Whereas ItemsControl is an AllActiveRegion, it keeps all views in it as active. Therefore if an deactivate call is issued, it will throw an exception. Last region type is Region, this region allow multiple active views; TabControl is consider as Region since it dervies from class Selector.

When a view is active it is visible to users. Therefore, deactivating an ContentControl active view hides the region from user. It is very handy when the detail view requires to be minimized or expanded.

Personally I am not a big fan of Region on TabControls. Because there was no easy way to set tab header. I prefer to manage my tabs in my ViewModel, which we will discuss further towards the end.

The gotchas in Region Manager

For controls that do not belong to the same visual root to your main application, Region Manager will fail to work its way up. Common symptom is that it cannot find the region in your resolved RegionManager. It will happen when resolving a ChildWindow or any of the sorts that do not use the same visual tree or failed trace the parent that does not link back to the main application.

There are work arounds out and about. I simply prefer to create an generic ChildWindow that sets the resolved RegionManager to itself and only register one view with unique name. Later on when required, I will pass the view to the ChildWindow it will then inject it to it's tummy. It has been working quite well for me, and it makes sense to my "View Management" approach mentioned later in this article.

Despite the Region in TabControls, I had to come up with a way of managing tab items. I have created a generic TabItem exactly the same code as I did with ChildWindow (Unfortunately, TabItems has the same problem as ChildWindow). By managing an ObservableCollection<MyTabItem>, I can then simply bind it to XAML like this:

<UserControl x:Class="CKL.AnimalFarm.Application.Silverlight.NorthFarmView"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:sdk="http://schemas.microsoft.com/winfx/2006/xaml/presentation/sdk" 
    xmlns:i="clr-namespace:CKL.Infrastructure.Silverlight;assembly=CKL.Infrastructure.Silverlight"
    mc:Ignorable="d">

    <Grid x:Name="LayoutRoot" Margin="10">
        <sdk:TabControl ItemsSource="{Binding TabItems}" />
    </Grid>
</UserControl>

Back to where we started..

Going back to answer those questions at the very beginning of this post. It almost looked like we need an collection of ViewModels, each represents the column on the DataGrid. It turns out to be a huge mistake for me. As it was often required to drill down the selected ViewModel into a ChildWindow or inject the detail view in the region below. We often found ourselves tangled in region not found exceptions, and then it finally occurred to me...

It was always good and mighty when Unity hands over an ViewModel to a View by assigning it to the DataContext. It is always safe because Unity often chains down and resolves all the way to the leaf views. Since DataContext property changed event will be triggered when Unity assigns the ViewModel to it. It often has time for the Binding Engine to receive the event and update the binding expression tree. However, consider the 3 quick lines of code:

  PopupWindow popup = this.UnitContainer.Resolve<PopupWindow>();
  popup.ViewModel = this.SelectedRowViewModel;
  popup.Show();

and assume in the SelectedRowViewModel it tries to inject a view to a region on view loaded:

public void OnViewLoaded()
{
  /* ... */
  this.ContentRegion.Add(ChildView);
  /* ... */
}

It is often found that the region does not exists. Some caused by ChildWindow, some caused by a race condition where a new ViewModel is assigned to the UserControl. Binding Engine is suppose to do a get method on every binding in the expression tree. However, sometimes ViewLoaded event was executed before the RegionName is retrieved in the View, therefore it was not yet registered to RegionManager. Common symptom for this is sometimes a region exists before injection and sometime it doesn't. This is due to the result of race condition whether the Binding Engine beats the view injection code or not.

Therefore, I have learnt my lesson to always prepare my views early. Instead of managing a collection of ViewModels, I then chose to manage the resolved views instead. In that case, whenever a detail view is requested, I will simply provide or inject the view to where it belongs.

You might wonder how you can then bind its properties to the DataGrid? The code in the view module will look like this:

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows.Input;
using CKL.Infrastructure.Silverlight;
using Microsoft.Practices.Composite.Presentation.Commands;
using Microsoft.Practices.Composite.Regions;

namespace CKL.AnimalFarm.Application.Silverlight
{
    public class HorseStableViewModel : ViewModelBase
    {

        #region Region
        private Guid DetailRegionId = Guid.NewGuid();
        public string DetailRegionName { get { return string.Format("HorseStableDetailRegion_{0}", DetailRegionId); } }
        public IRegion DetailRegion { get { return this.RegionManager.Regions[DetailRegionName]; } }
        #endregion Region

        #region Model
        private List<LiveStockOverview> _mHorseViews;

        public List<LiveStockOverview> mHorseViews
        {
            get { return _mHorseViews; }
            set
            {
                _mHorseViews = value;
                NotifyPropertyChanged(() => this.mHorseViews);
                NotifyPropertyChanged(() => this.HorseViewModels);
            }
        }

        private LiveStockOverview _mSelectedHorseView;

        public LiveStockOverview mSelectedHorseView
        {
            get { return _mSelectedHorseView; }
            set
            {
                _mSelectedHorseView = value;

                if (!this.DetailRegion.Views.Contains(_mSelectedHorseView))
                    this.DetailRegion.Add(_mSelectedHorseView);
                this.DetailRegion.Activate(_mSelectedHorseView);


                NotifyPropertyChanged(() => this.mSelectedHorseView);
                NotifyPropertyChanged(() => this.SelectedHorseViewModel);
                NotifyPropertyChanged(() => this.IsCurrentViewVisible);
                NotifyPropertyChanged(() => this.ToggleView);
                NotifyPropertyChanged(() => this.ToggleViewName);
            }
        }

        #endregion Model

        #region Properties
        public bool IsCurrentViewVisible
        {
            get { return this.mSelectedHorseView != null && this.DetailRegion.ActiveViews.Contains(mSelectedHorseView); }
        }
        public ObservableCollection<ILiveStockViewModel> HorseViewModels
        {
            get
            {
                return this.mHorseViews != null ?
                    this.mHorseViews.Select(v => v.LiveStockOverviewViewModel.LiveStockView.ILiveStockViewModel).ToObservableCollection() : null;
            }
        }

        public ILiveStockViewModel SelectedHorseViewModel
        {
            get
            {
                return this.mSelectedHorseView != null ?
                    this.mSelectedHorseView.LiveStockOverviewViewModel.LiveStockView.ILiveStockViewModel : null;
            }
            set
            {
                if (value != null && this.mHorseViews != null)
                {
                    this.mSelectedHorseView = this.mHorseViews.Where(v => v.LiveStockOverviewViewModel.LiveStockView.ILiveStockViewModel == value).First();
                }
            }
        }

        public string ToggleViewName
        {
            get { return IsCurrentViewVisible ? " - " : " + "; }

        }

        public ICommand ToggleView
        {
            get
            {
                return new DelegateCommand<object>((param) =>
                {
                    if (IsCurrentViewVisible)
                    {
                        this.DetailRegion.Deactivate(mSelectedHorseView);
                    }
                    else
                    {
                        this.DetailRegion.Activate(mSelectedHorseView);
                    }
                    NotifyPropertyChanged(() => ToggleViewName);
                },
                (param) =>
                {
                    return this.mSelectedHorseView != null;
                });
            }
        }

        #endregion Properties

        internal void Initialize(int numberOfHorses)
        {


            #region Generate Data
            List<Horse> source = new List<Horse>();
            for (int i = 0; i < numberOfHorses; i++)
            {
                source.Add(new Horse());
            }
            #endregion Generate Data

            this.mHorseViews = source.Select(s => ResolveHorseView(s)).ToList();


        }

        private DelegateCommand<object> OnHorseViewSelected(ILiveStockViewModel vm)
        {
            return new DelegateCommand<object>((param) =>
                {
                    this.SelectedHorseViewModel = vm as HorseViewModel;
                },
                (param) =>
                { return true; });

        }
        private LiveStockOverview ResolveHorseView(Horse horse)
        {
            LiveStockOverview view = this.UnityContainer.Resolve<LiveStockOverview>();
            view.Initialize(horse);
            view.LiveStockOverviewViewModel.LiveStockView.ILiveStockViewModel.OnSelected = OnHorseViewSelected;

            return view;
        }
    }
}

And the binding is not much different from what it used to be -

<i:ViewBase x:Class="CKL.AnimalFarm.Application.Silverlight.HorseStableView"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:i="clr-namespace:CKL.Infrastructure.Silverlight;assembly=CKL.Infrastructure.Silverlight"
    xmlns:region="clr-namespace:Microsoft.Practices.Composite.Presentation.Regions;assembly=Microsoft.Practices.Composite.Presentation"
    xmlns:sdk="http://schemas.microsoft.com/winfx/2006/xaml/presentation/sdk"
    mc:Ignorable="d"
    d:DesignHeight="300" d:DesignWidth="400">


    <Grid x:Name="LayoutRoot">
     <Grid.RowDefinitions>
      <RowDefinition/>   
   <RowDefinition/>
   <RowDefinition/>
  </Grid.RowDefinitions>
        <sdk:DataGrid Grid.Row="0" HorizontalAlignment="Left" VerticalAlignment="Top" ItemsSource="{Binding HorseViewModels}" SelectedItem="{Binding SelectedHorseViewModel, Mode=TwoWay}" AutoGenerateColumns="False">
            <sdk:DataGrid.Columns>
             <sdk:DataGridTemplateColumn Width="*">
              <sdk:DataGridTemplateColumn.CellTemplate>
               <DataTemplate>
                <HyperlinkButton Content="{Binding LabelID}" Command="{Binding SelectedCommand}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
               </DataTemplate>
              </sdk:DataGridTemplateColumn.CellTemplate>
             </sdk:DataGridTemplateColumn>
             <sdk:DataGridTemplateColumn Width="*">
              <sdk:DataGridTemplateColumn.CellTemplate>
               <DataTemplate>
                <HyperlinkButton Content="{Binding HealthStatus}" Command="{Binding SelectedCommand}" HorizontalAlignment="Left" VerticalAlignment="Center"/>
               </DataTemplate>
              </sdk:DataGridTemplateColumn.CellTemplate>
             </sdk:DataGridTemplateColumn>
            </sdk:DataGrid.Columns>
        </sdk:DataGrid>
        <Button Grid.Row="1" Content="{Binding ToggleViewName}" Command="{Binding ToggleView}" Height="20"/>
        <ContentControl Grid.Row="2" region:RegionManager.RegionName="{Binding DetailRegionName}" />
        <Image Height="128" Margin="-14,11,0,-39" Source="1278556637_Adobe Premiere.png" Width="128" HorizontalAlignment="Left" d:LayoutOverrides="Width" Opacity="0.7"/>
    </Grid>
</i:ViewBase>

See things in action

If you much prefer to see things in action, feel free to download the demo project provided at the end of the article. And get a real feeling of how it's working for you.

I must say there is a lot of coding to be done when working with Prism. However, we believe we have started a pattern of how to put everything together.

I think this is enough blogging for one night. So I must say good bye.

Until next time ...

No comments:

Post a Comment