Monday, March 1, 2010

MEF instead of PRISM for Silverlight 3 Part 2 of 2: Region Management

In my last post, I showed you how to dynamically load modules on demand using the latest MEF release in Silverlight 3. This post, I will take you through managing regions with MEF. This will enable us to have a 100% MEF-based solution in Silverlight 3 if the only pieces of PRISM we were using were the dynamic module loading and region management.

Download the Source Code for this Post

Quick note: technically, the Composite Application Guidance (PRISM) is more of a guidance than an actual library. The library that is often used is the reference library for the guidance. I only mention this because in using the concept of the region manager and views, technically we are implementing the PRISM concept here and using CAG, we're just using MEF as the engine instead of the included reference library.

OK, let's get going ...

The first thing I did this time around was to rip out all of the IModuleInitialization pieces. They aren't needed because MEF will initialize what we need, as it's loaded. I destroyed the classes in the modules (it was good to see them load, anyway) and updated the navigation class to look like this:

[Export(typeof(INavigation))]
public class ViewNavigator : INavigation 
{        
    [Import]
    public ICatalogService CatalogService { get; set; }
    
    private readonly Dictionary<ViewType, string> _viewMap =
        new Dictionary<ViewType, string>
            {
                { ViewType.MainView, "DynamicModule.xap" },
                { ViewType.SecondView, "SecondDynamicModule.cs.xap" }
            };   

    private readonly List<string> _downloadedModules = new List<string>();
                                                                   
    public void NavigateToView(ViewType view)
    {
        if (!_downloadedModules.Contains(_viewMap[view]))
        {
            var catalog = new DeploymentCatalog(_viewMap[view]);
            CatalogService.Add(catalog);
            catalog.DownloadAsync();
            _downloadedModules.Add(_viewMap[view]);
        }
    }           
}

Thanks to Glenn Block for pointing out the excellent sample that comes included with the MEF bits (the deployment sample) ... this shows a different way of abstracting even the XAP file from the action of loading the catalog. In this case, I'm assuming I have prior knowledge of the controls and want to make my application more official by loading them dynamically as the user selects them.

So, now we've re-factored. That's a lot cleaner! Next, I want strongly-typed regions. I created a new project just for the regions, and added this class to define the enumeration:

public enum ViewRegion
{
    MainRegion,
    SubRegion 
}

public static class ViewRegionExtensions
{
    private static readonly List<ViewRegion> _regions = new List<ViewRegion> { ViewRegion.MainRegion, ViewRegion.SubRegion };       

    public static ViewRegion AsViewRegion(this string regionName)
    {
        foreach(var region in _regions)
        {
            if (regionName.Equals(region.ToString(), StringComparison.InvariantCultureIgnoreCase))
            {
                return region;
            }
        }
        return ViewRegion.MainRegion; 
    }
}

Unfortunately, I don't believe the Silverlight version of the Enum class allows you to enumerate the values of an enumeration. Therefore, I'm simply providing a static class to hold those and putting it next to the enum so it's easy to remember for maintenance. I also added an extension method that allows me to take a string and do AsViewRegion() to convert it to the enum.

In order to make the enum appear in XAML, we need to give Silverlight a hint that tells it how to convert from the string we'll key in the XAML to an actual enum we use in code. Here is the type converter, it basically says it only knows how to convert from strings, and uses the extension method to cast the string when called:

public class ViewRegionConverter : TypeConverter 
{        
    public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
    {
        return sourceType.Equals(typeof (string));
    }

    public override object ConvertFrom(ITypeDescriptorContext context, System.Globalization.CultureInfo culture, object value)
    {
        return ((string) value).AsViewRegion();
    }
}

Now we can create our behavior. The behavior is an attached property we'll use to tag a container as a region. Eventually this can become complex as we support lists and content controls, but for the sake of this blog series I'm keeping it simple and limiting our containers to panels. Panels all have children, so it is easy to add an item to the children. The behavior will simply keep a static dictionary that maps the view region to the panel it was tagged to, and exposes a method that lets us request the panel for a view region. Here it is:

public static class RegionBehavior
{
    private static readonly Dictionary<ViewRegion, Panel> _regions = new Dictionary<ViewRegion, Panel>();
    
    public static readonly DependencyProperty RegionNameProperty = DependencyProperty.RegisterAttached(
        "RegionName",
        typeof (ViewRegion),
        typeof (RegionBehavior),
        new PropertyMetadata(ViewRegion.MainRegion, null));

    public static ViewRegion GetRegionName(DependencyObject obj)
    {
        return (ViewRegion) obj.GetValue(RegionNameProperty);
    }

    [TypeConverter(typeof(ViewRegionConverter))]
    public static void SetRegionName(DependencyObject obj, ViewRegion value)
    {
        obj.SetValue(RegionNameProperty, value);
        var panel = obj as Panel;
        if (panel != null)
        {
            _regions.Add(value, panel);
        }
    }
   
    public static Panel GetPanelForRegion(ViewRegion region)
    {
        return _regions[region];
    }

}

Notice the type converter hint I give the setter. That lets the XAML know how to convert. In fact, this will even give us intellisense in the XAML, so it knows what the valid enumerations are! Now I can go into my main shell and create my first region (the intellisense quickly gave me a list to choose from ... right now there are only two choices!)

<StackPanel Orientation="Vertical" Grid.Row="2" Regions:RegionBehavior.RegionName="MainRegion"/>

We can debug at this point and see that indeed the panel is registered with the dictionary with the behavior, so we're set on that front. Now we need to get our views into the region. We're still in the special "Regions" project and namespace. For now, I'm simply going to export type UserControl for my view, and specify the region with an attribute. In MEF, we can create a strongly typed attribute for metadata:

[MetadataAttribute]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class TargetRegionAttribute : ExportAttribute 
{        
    public TargetRegionAttribute() : base(typeof(UserControl))
    {
        
    }

    public ViewRegion Region { get; set; }
}

So the type is UserControl but we can specify a region in the meta data. Now I can add a view to my dynamic module. I'm also specifying a region here for a nested view.

<UserControl x:Class="DynamicModule.View"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 
    xmlns:Regions="clr-namespace:RegionsWithMEF.Regions;assembly=RegionsWithMEF.Regions" 
    >
    <Grid x:Name="LayoutRoot" Background="White">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="1*"/>
            <ColumnDefinition Width="1*"/>
        </Grid.ColumnDefinitions>
        <TextBlock Grid.Column="0" Text="Left Column of Outer View"/>
        <StackPanel Grid.Column="1" Orientation="Vertical" Regions:RegionBehavior.RegionName="SubRegion">
            <TextBlock Text="Right Column of Outer View"/>
        </StackPanel>
    </Grid>
</UserControl>

We then tag the view for export with our custom attribute, like this:

[TargetRegion(Region=ViewRegion.MainRegion)]
public partial class View
{
    public View()
    {
        InitializeComponent();
    }
}

Very easy and readable. I went ahead and added another control for the second view, and tagged it to the "sub region" view.

Now we've got our custom attributes and exports, but we still need to glue it together. When the catalog is loaded, the views will be tagged as exports. We just need something to import them and we can then inspect the meta data and put them in their proper places. For this, I created the RegionManager.

First, let me back up. We want to read our meta data, so I need an interface with read only properties to pull it in:

public interface ITargetRegionCapabilities
{
    ViewRegion Region { get;  }
}

Great! Now I can define region manager. We'll use the Lazy object so we can define the capabilities with the import and inspect the meta data. I put it into an observable collection and listen to the collection. When it fires, I inspect the data for new controls, and then route them to their region based on the meta data:

[Export]
public class RegionManager
{
    private readonly List<UserControl> _controls = new List<UserControl>();

    [ImportMany(AllowRecomposition = true)]
    public ObservableCollection<Lazy<UserControl, ITargetRegionCapabilities>> Controls { get; set; }

    public RegionManager()
    {
        Controls = new ObservableCollection<Lazy<UserControl, ITargetRegionCapabilities>>();
        Controls.CollectionChanged += Controls_CollectionChanged;
    }

    void Controls_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
    {
        if (e.NewItems != null)
        {
            foreach(var item in e.NewItems)
            {
                var controlInfo = item as Lazy<UserControl, ITargetRegionCapabilities>;

                if (controlInfo != null)
                {
                    if (!_controls.Contains(controlInfo.Value))
                    {
                        ViewRegion region = controlInfo.Metadata.Region;
                        Panel panel = RegionBehavior.GetPanelForRegion(region);
                        panel.Children.Add(controlInfo.Value);
                        _controls.Add(controlInfo.Value);
                    }
                }
            }
        }
    }
}

Note: if you are wondering why I use a list and an observable collection, it's because with recomposition, MEF is free to rebuild the entire list. I need to know specifically what changed and the "new items" might include older items that were recomposed. So the internal list allows me to keep track of what I've processed while I can parse out the newer items. With custom items you'll want to make sure you have a good implementation of Equals and GetHashCode, and if you have items that will go out of focus and disappear, you'll want to switch the internal list to a list of WeakReference instead.

That's it! The last step is to make sure that the region manager participates in the entire chain of imports and exports. I could wire it up in the container, but I know the shell view model is the top of the hierarchy, so to speak, so I'll just import the region manager directly. I haven't interfaced it because it doesn't expose any methods or properties: all of the work is internal and interfaces with the static dictionary we put into our behavior.

[Import]
public RegionManager Manager { get; set; }

OK, let's fire it up. I made a short video to show you the results.

Download the Source Code for this Post

Watch a great video tutorial covering dynamic modules with Glenn Block on Channel 9: Dynamically Loading XAPs with MEF.

Jeremy Likness