Quantcast
Channel: Infragistics Community
Viewing all articles
Browse latest Browse all 2374

XamDockManager–A Prism RegionAdapter

$
0
0

Writing a generic custom Prism RegionAdapter for a complex control is sometimes difficult, because custom RegionAdapters are custom.  They normally have some specific logic built into them that make them fit into a particular application just right.  You might need a tweak here or a tweak there, need a feature here or a feature there, all depending on the requirements of the application itself. 

I have been asked numerous times about creating a RegionAdapter for the XamDockManager control that can be found in the Infragistics NetAdvantage for WPF product.  I am always hesitant to write a RegionAdapter for a complex control because I never know how the control will be used and my implementation might not fit their application needs.  Then they will think that Prism doesn’t work or the control is a piece of crap, and that I don’t know what the hell I’m doing.  I always assume they will just modify the code to make it work, but I realize that sometimes they might not understand the technology well enough to make changes.  So this blog will be my attempt to write a “generic” custom RegionAdapter for the XamDockManager control.  Because this is a “generic” region adapter, I will outline the intended functionality of this RegionAdapter.  I will explain exactly what is happening within each class to make it easier to make modifications if necessary.  If you don’t care about the WHY, then just download the source and start using it.

Functional Specs:

  1. Only intended to support a TabGroupPane as a region.  I didn’t want to make the XamDockManager itself a region because that would be too limiting.  By using the TabGroupPane, I can define multiple regions within a complex xamDockManager layout.
  2. Will not support data binding to the TabGroupPane.ItemsSource.  As with the default ItemsSourceRegionAdapter, this just causes major issues.
  3. All views injected must be of type UserControl.  I simply want to create a UserControl that has my view  elements and then inject just the UserControl.  I don’t want to have to create controls that derive from ContentPane or create ContentPanes in code.
  4. Support declaring ContentPanes in XAML and injecting views into ContentPanes.  This is helpful for when you have default views or hard coded views that are not dynamic in nature, but still need the ability to inject other views into the region.
  5. Views must be removed when closed.  The XamDockManager gives you the ability to control whether ContentPanes are completely removed from the control or just hidden from view when closed (clicking the close button).  My requirement is that when a view is closed, it will be completely removed from the region and the XamDockManager control.  So I will not honor the IRegionMemberLifetime interface which allows you to keep a view alive.
  6. Support IActiveAware.  If my View or ViewModel implements the IActiveAware interface, I want my RegionAdapter to also support this behavior.  This lets me know which View/ViewModel is the active item in my region.  This needs to be the case even if the ContentPanes are docked or floating.
  7. Have the ability to control the ContentPane.Header property.  I want my View or ViewModel to have control of what the tab header displays.

The RegionAdapter

The RegionAdapter is made up of three parts:

  1. TabGroupPaneRegionAdapter
  2. TabGroupRegionBehavior
  3. IDockAware
TabGroupPaneRegionAdapter

The TabGroupPaneRegionAdapter is the actual RegionAdapter that gets registered in the Bootstrapper of your prism application.  It’s implementation is very simple and is as follows:

public class TabGroupPaneRegionAdapter : RegionAdapterBase<TabGroupPane>
{
    public TabGroupPaneRegionAdapter(IRegionBehaviorFactory regionBehaviorFactory)
        : base(regionBehaviorFactory)
    {
    }

    protected override void Adapt(IRegion region, TabGroupPane regionTarget)
    {            
    }

    protected override void AttachBehaviors(IRegion region, TabGroupPane regionTarget)
    {
        if (!region.Behaviors.ContainsKey(TabGroupPaneRegionBehavior.BehaviorKey))
            region.Behaviors.Add(TabGroupPaneRegionBehavior.BehaviorKey, new TabGroupPaneRegionBehavior { HostControl = regionTarget });

        base.AttachBehaviors(region, regionTarget);            
    }

    protected override IRegion CreateRegion()
    {
        return new Region();
    }
}

As you can see, we are going to rely on our TabGroupRegionBehavior to manage our Region views.

TabGroupRegionBehavior

The TabGroupRegionBehavior class is doing all the heavy lifting.  Let’s look at the code, and then talk abut some of the really important parts.

public class TabGroupPaneRegionBehavior : RegionBehavior, IHostAwareRegionBehavior
{
    public const string BehaviorKey = "TabGroupPaneRegionBehavior";

    /// <summary>
    /// Used to determine what views were injected and ContentPanes were generated for
    /// </summary>
    private static readonly DependencyProperty IsGeneratedProperty = DependencyProperty.RegisterAttached("IsGenerated", typeof(bool), typeof(TabGroupPaneRegionBehavior), null);

    TabGroupPane _hostControl;
    public System.Windows.DependencyObject HostControl
    {
        get { return _hostControl; }
        set { _hostControl = value as TabGroupPane; }
    }

    protected override void OnAttach()
    {
        //if it's databound we do not allow view injection
        if (_hostControl.ItemsSource != null)
            throw new InvalidOperationException("ItemsControl's ItemsSource property is not empty. This control is being associated with a region, but the control is already bound to something else. If you did not explicitly set the control's ItemSource property, this exception may be caused by a change in the value of the inherited RegionManager attached property.");

        SynchronizeItems();

        var dockManager = XamDockManager.GetDockManager(_hostControl);
        if (dockManager != null)
            dockManager.ActivePaneChanged += DockManager_ActivePaneChanged;

        Region.Views.CollectionChanged += Views_CollectionChanged;
    }

    void DockManager_ActivePaneChanged(object sender, RoutedPropertyChangedEventArgs<ContentPane> e)
    {
        if (e.OldValue != null)
        {
            var item = e.OldValue;

            //this first checks to see if we had any default views declared in XAML and that were not injected
            if (Region.Views.Contains(item) && Region.ActiveViews.Contains(item))
            {
                Region.Deactivate(item);
            }
            else
            {
                //now check to see if we have any views that were injected
                var contentControl = item as ContentControl;
                if (contentControl != null)
                {
                    var injectedView = contentControl.Content;
                    if (Region.Views.Contains(injectedView) && Region.ActiveViews.Contains(injectedView))
                        Region.Deactivate(injectedView);
                }
            }
        }

        if (e.NewValue != null)
        {
            var item = e.NewValue;

            //this first checks to see if we had any default views declared in XAML and that were not injected
            if (Region.Views.Contains(item) && !this.Region.ActiveViews.Contains(item))
            {
                Region.Activate(item);
            }
            else
            {
                //now check to see if we have ay views that were injected
                var contentControl = item as ContentControl;
                if (contentControl != null)
                {
                    var injectedView = contentControl.Content;
                    if (Region.Views.Contains(injectedView) && !this.Region.ActiveViews.Contains(injectedView))
                        Region.Activate(injectedView);
                }
            }
        }
    }

    /// <summary>
    /// Takes all the views that were declared in XAML manually and merges them with any views that were injected into the region.
    /// </summary>
    private void SynchronizeItems()
    {
        List<object> existingItems = new List<object>();
        if (_hostControl.Items.Count > 0)
        {
            foreach (object childItem in _hostControl.Items)
            {
                existingItems.Add(childItem);
            }
        }

        foreach (object view in Region.Views)
        {
            var contentPane = PrepareContainerForItem(view);
            _hostControl.Items.Add(contentPane);
        }

        foreach (object existingItem in existingItems)
        {
            PrepareContainerForItem(existingItem);
            Region.Add(existingItem);
        }
    }

    void Views_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
    {
        //new views are being injected, so lets add them to the region
        if (e.Action == NotifyCollectionChangedAction.Add)
        {
            //we want to add them behind any previous views that may have been manually declare in XAML or injected
            int startIndex = e.NewStartingIndex;
            foreach (object newItem in e.NewItems)
            {
                ContentPane contentPane = PrepareContainerForItem(newItem);
                _hostControl.Items.Insert(startIndex++, contentPane);
            }
        }

        //We do not need to implement the NotifyCollectionChangedAction.Remove functionality becase we are setting the ContentPane.CloseAction = PaneCloseAction.RemovePane.
        //This places the responsibility of removing the item from the XamDockManager control onto the ContentPane being removed which makes our life much easier.
    }

    /// <summary>
    /// Prepares a view being injected as a ContentPane
    /// </summary>
    /// <param name="item">the view</param>
    /// <returns>The injected view as a ContentPane</returns>
    protected virtual ContentPane PrepareContainerForItem(object item)
    {
        ContentPane container = item as ContentPane;

        if (container == null)
        {
            container = new ContentPane();
            container.Content = item; //the content is the view being injected
            container.DataContext = ResolveDataContext(item); //make sure the dataContext is the same as the view. Most likely a ViewModel
            container.SetValue(IsGeneratedProperty, true); //we generated this one
            CreateDockAwareBindings(container);
        }

        container.CloseAction = PaneCloseAction.RemovePane; //make it easy on ourselves and have the pane manage removing itself from the XamDockManager
        container.Closed += Container_Closed;

        return container;
    }

    void Container_Closed(object sender, Infragistics.Windows.DockManager.Events.PaneClosedEventArgs e)
    {
        ContentPane contentPane = sender as ContentPane;
        if (contentPane != null)
        {
            contentPane.Closed -= Container_Closed; //no memory leaks

            if (Region.Views.Contains(contentPane)) //this view was delcared in XAML and not injected
                Region.Remove(contentPane);

            var item = contentPane.Content; //this view was injected and set as the content of our ContentPane
            if (item != null && Region.Views.Contains(item))
                Region.Remove(item);

            ClearContainerForItem(contentPane); //reduce memory leaks
        }
    }

    /// <summary>
    /// Checks to see if the View or the View's DataContext (Most likely a ViewModel) implements the IDockAware interface and creates the necessary data bindings.
    /// </summary>
    /// <param name="container"></param>
    void CreateDockAwareBindings(ContentPane contentPane)
    {
        Binding binding = new Binding("Header");

        var dockAwareContainer = contentPane as IDockAware;
        if (dockAwareContainer != null)
        {
            binding.Source = dockAwareContainer;
        }

        var dockAwareDataContext = contentPane.DataContext as IDockAware;
        if (dockAwareDataContext != null)
        {
            binding.Source = dockAwareDataContext;
        }

        contentPane.SetBinding(ContentPane.HeaderProperty, binding);
    }

    /// <summary>
    /// Finds the DataContext of the view.
    /// </summary>
    /// <param name="item"></param>
    /// <returns></returns>
    private object ResolveDataContext(object item)
    {
        FrameworkElement frameworkElement = item as FrameworkElement;
        return frameworkElement == null ? item : frameworkElement.DataContext;
    }

    /// <summary>
    /// Sets the Content property of a generated ContentPane to null.
    /// </summary>
    /// <param name="contentPane">The ContentPane</param>
    protected virtual void ClearContainerForItem(ContentPane contentPane)
    {
        if ((bool)contentPane.GetValue(IsGeneratedProperty))
            contentPane.Content = null;
    }
}

When this behavior is invoked on the Region, the first thing that happens is that we synchronize any views that may have been declared in XAML with any views that we are injecting.  This makes sure that our Region will know about every view that has been placed inside of it.  Even those not injected. 

During this process, we take the views being injected and create ContentPanes for them.  Why do we do this?  The TabGroupPane can only contain ContentPanes as children.  So we must create a new ContentPane for every view being injected and use that view as the Content for the ContentPane.  This is what happens in the PrepareContainerForItem method.  Notice that not only are we creating a new ContentPane and settings it’s Content property to the injected view instance, but we are also getting the DataContext of the view, which is most likely a ViewModel, and setting the DataContext of the ContentPane to be the same.  This will ensure that all our data bindings will work as expected.  We are also keeping track of if we generated the ContentPane.  Remember how we support both ContentPanes declared in XAML and views that are dynamically injected?  Well, we need to know which ones we created ContentPanes for.  The IsGeneratedPropert allows us to do this.  Now we know who created what.  We also create some data bindings which we will get to later.  The next important thing to make note of in this method is that we are adding an event handler for the Closed event of the ContentPane.  This will let us know when we need to remove the view from the region.  If you notice we set the ContentPane.CloseAction = PaneCloseAction.RemovePane.  This will basically remove the view from the XamDockManager for us so we don’t have to worry about it.

The next thing that happens is we add an event handler for the XamDockManager.ActivePaneChanged event.  This lets us know which ContentPane had been activated no matter where is has been dragged to.  In this handler we first check if the ContentPane is being deactivated (e.OldValue) or activated (e.NewValue).  The logic is very similar for both operations.  First we check to see if the Region contains the view as is.  Meaning that we are dealing with a pre-defined ContentPane most likely declared in XAML.  If it is found, then we simply deactivate/activate it.  If the Region doesn’t contain the view, then that means we are dealing with an injected view that has been wrapped in a generated ContentPane.  So we simply grab the Content of the ContentPanel, because that will be the actual injected view, and then check to see if the region contains the view.  If we find it, then we deactivate/activate it.  Pretty simple stuff.  I do want to note, that this is where the support for IActiveAware comes in.  When we activate and deactivate the view, we are invoking the IActiveAware members through the base Prism behavior.

We also add an event handler for the Regions.Views.CollectionChanged event.  This event executes when new views are added or removed from a region.  So this is where we need to add and remove views to the TabGroupPane.Items collection.  In this case we are only concerned with when new views are added.  Not removed.  Why do you ask?  Remember when we set the ContentPane.CloseAction property to PaneCloseAction.RemovePane?  Well, this is what actually handles removing the view from the TabGroupPane.Items collection.  So we don’t have to mess with it.  We only care about adding views.  So when we get a new view to add, we simply call our PrepareContainerForItem method, and add the resulting ContentPane to the TabGroup.Items collection.  Pretty simple.

The last thing to note is when we close a ContentPane.  Remember we added the event handler for the ContentPane.Closed event?  Well this is where we simply remove the view from the Region.  First we make sure to unsubscribe to the ContentPane.Closed event (no memory leaks), and then we check to see if it was a predefined ContentPane declared in XAML or a view that was injected and wrapped in a generated ContentPane.  We remove the view from the region and then clear out the Content property from the ContentPane to help eliminate any memory leaks.

IDockAware

In order to have the ability to set the Header of the tab, we need to create a data binding between a property on the View or ViewModel and the Header property of the ContentPane.  To accomplish this I have introduced an interface called IDockAware.

public interface IDockAware
{
    string Header { get; set; }
}

It has a single property appropriately called Header.  If your View or ViewModel implement this interface, the value you have set for the property will be used as the Header of the ContentPanel.  How does this work?  In the TabGroupPaneRegionBehavior.PrepareContainerForItem method, I am calling another method called CreateDockAwareBindings.  This method checks to see if the View or ViewModel implements the IDockAware interface and if it does sets a data binding between the ContentPane.Header property and the IDockAware.Header property.  This way you have complete control of the tab header and can even update it if necessary.

RegionAdapter in Action

Let’s see this baby in action.  I already have a Prism application with a single module and a single view ready for testing.  The first thing we need to do is register our RegionAdapter in the Bootstrapper.

protected override Microsoft.Practices.Prism.Regions.RegionAdapterMappings ConfigureRegionAdapterMappings()
{
    RegionAdapterMappings mappings = base.ConfigureRegionAdapterMappings();
    mappings.RegisterMapping(typeof(TabGroupPane), Container.Resolve<TabGroupPaneRegionAdapter>());
    return mappings;
}

Next, I have a Shell with some regions defined on a couple of TabGroupPanes.

<Grid>
    <igWPF:XamDockManager>
        <igWPF:XamDockManager.Panes>
            <igWPF:SplitPane SplitterOrientation="Horizontal" igWPF:XamDockManager.InitialLocation="DockedLeft">
                <igWPF:TabGroupPane prism:RegionManager.RegionName="TabGroupPaneTwo" />
            </igWPF:SplitPane>
        </igWPF:XamDockManager.Panes>
        <igWPF:DocumentContentHost>
            <igWPF:SplitPane>
                <igWPF:TabGroupPane prism:RegionManager.RegionName="TabGroupPaneOne">
                    <igWPF:ContentPane TabHeader="Default 1">
                        <TextBlock Text="Content" />
                    </igWPF:ContentPane>
                    <igWPF:ContentPane TabHeader="Default 2">
                        <TextBlock Text="Content" />
                    </igWPF:ContentPane>
                </igWPF:TabGroupPane>
            </igWPF:SplitPane>
        </igWPF:DocumentContentHost>
    </igWPF:XamDockManager>
</Grid>

As you can see I have even added a couple of “default” ContentPanes declaratively in XAML.  In my Module I am simply injecting a few instances of my view into the two regions.  Nothing special here so I won’t take up space with a code snippet.  What I do want to share with you is the ViewModel that the view is using as it’s DataContext.

public class ViewAViewModel : INotifyPropertyChanged, IActiveAware, IDockAware
{
    public ViewAViewModel()
    {
        Number = DateTime.Now.Millisecond;
        Header = String.Format("Title: {0}", Number);
    }

    public int Number { get; set; }

    #region IActiveAware

    bool _isActive;
    public bool IsActive
    {
        get { return _isActive; }
        set
        {
            _isActive = value;
            NotifyPropertyChanged("IsActive");
        }
    }

    public event EventHandler IsActiveChanged;

    #endregion //IActiveAware

    #region IDockAware

    private string _header;
    public string Header
    {
        get { return _header; }
        set
        {
            _header = value;
            NotifyPropertyChanged("Header");
        }
    }

    #endregion //IDockAware

    #region INotifyPropertyChanged

    public event PropertyChangedEventHandler PropertyChanged;
    protected void NotifyPropertyChanged(string propertyName)
    {
        var handler = PropertyChanged;
        if (handler != null)
            handler(this, new PropertyChangedEventArgs(propertyName));
    }

    #endregion //INotifyPropertyChanged
}

As you can see, this ViewModel implements the standard INotifyPropertyChanged, but it also implements two more interfaces.  It implements Prism’s IActiveAware interface so I will always know which View/ViewModel is the active View/ViewModel.  It also implements our newly created IDockAware interface, so I can set the Header of the View’s ContentPane.  This is the result of the running application.

image

As you can see, I have multiple instances of my View injected into the various Regions.  The ViewModel is properly setting the header property of the tabs, and I know which View is the active view as I change tabs or start docking and undocking tabs.

image

That just about wraps it up.  Go ahead and download the source and start having some Prism fun.  If something doesn’t fit your needs, feel free to change it so that it does.  If you have any questions feel free to contact me through my blog, on twitter (@BrianLagunas), or leave a comment below.


Viewing all articles
Browse latest Browse all 2374

Trending Articles