Creating a Custom Workspace

Although I suspect that restyling one of the standard workspaces will meet the demands of most users, at some point you might need something extra. For that reason I have tried to make the workspace API extensible. In this tutorial we will create a workspace which is equal to the DeckWorkspace, except that the views can be detached into floating windows. Since this is a tutorial I will try to keep to the bare bones, so the finished workspace will probably still need some polishing before it can be used in a real application.

Stage I: The class

We will start off by duplicating the basic behavior of the DeckWorkspace. First, create a new class that derives from MultiViewWorkspaceBase.

public class DetachableDeckWorkspace: MultiViewWorkspaceBase { protected override void OnAddView(ApplicationView view, int targetIndex)
{
throw new NotImplementedException();
}

protected override void OnActivateView(ApplicationView view) { throw new NotImplementedException(); } protected override void OnMoveView(ApplicationView view, int sourceIndex, int targetIndex) { // This method has no meaning in this workspace. } protected override void OnRemoveView(ApplicationView view) { throw new NotImplementedException(); } }

You will notice four required methods:
  • OnAddView
    This method is called whenever a new view has been added to the workspace, and should be attached. Workspaces handle their views by wrapping them within container items of the type WorkspaceItem, and this is where we need to create it. Then we show the view by setting the ActiveWorkspaceItem property. This will be explained further in stage II.
protected override void OnAddView(ApplicationView view, int targetIndex)
{
    // Create the view's workspace item
    var workspaceItem = this.CreateItem(view);

    // Set the view as the current view
    this.ActiveWorkspaceItem = workspaceItem;
}
  • OnActivateView
    This method is called when an already attached view wants to be activated. In our case this means that this view should replace the one currently being displayed. This is as simple as in OnAddView. The only difference is that we retrieve an existing container instead of creating a new one.
protected override void OnActivateView(ApplicationView view)
{
    // Find the view's workspace item
    var workspaceItem = this.GetItemForView(view);

    // Set the view as the current view
    this.ActiveWorkspaceItem = workspaceItem;
}
  • OnMoveView
    This method is called when an already attached view should be moved within the workspace. In our case this method has no meaning, so we simply do nothing.
  • OnRemoveView
    This method is called when a view has been closed and should be removed from the workspace.
protected override void OnRemoveView(ApplicationView view)
{
    // Find the view's workspace item
    var workspaceItem = this.GetItemForView(view);

    // If the removed view is the active workspace item, reset it to whatever is next on the activation stack.
    if (this.ActiveWorkspaceItem == workspaceItem)
    {
        // If there are other items in the workspace, pick the last one and activate it. Otherwise reset the 
// active workspace property.
if (this.Items.Count > 0) this.ActiveWorkspaceItem = this.Items.Last(); else this.ClearActiveWorkspaceItem(); } // Finally destroy the workspace item. this.DestroyItem(workspaceItem); }

Finally, for convenience, we add an attribute to the class which will tell WPF that this control supports direct content. This will enable us to put ApplicationViewSource objects directly into the control for simplicity.

using Creventive.Wpf.ShellFactory.Workspace.Primitives;

namespace CustomWorkspace.Infrastructure.Workspaces
{
    [ContentProperty("ViewSources")]
    public class DetachableDeckWorkspace: MultiViewWorkspaceBase
    {
        ...


Stage II: The looks

Before the workspace can be used in a view, it will have to be designated a default style. So at the top of the class, add the following. It will tell WPF where to look for the default style.

static DetachableDeckWorkspace()
{
    DefaultStyleKeyProperty.OverrideMetadata(typeof(DetachableDeckWorkspace), new FrameworkPropertyMetadata(typeof(DetachableDeckWorkspace)));
}

As for the actual style you will need to add a Themes folder in the assembly you are creating your workspace in, and then add a ResourceDictionary named Generic.xaml in that folder. In this file you must add the default style. Below is a bare minimum style.

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 
                    xmlns:wsp="clr-namespace:Creventive.Wpf.ShellFactory.Workspace.Primitives;assembly=Creventive.Wpf.ShellFactory"
                    xmlns:cw="clr-namespace:CustomWorkspaces">

    <Style TargetType="{x:Type cw:DetachableDeckWorkspace}">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type cw:DetachableDeckWorkspace}">
                    <Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" 
BorderThickness="{TemplateBinding BorderThickness}" > <wsp:WorkspaceItemPresenter WorkspaceItem="{TemplateBinding ActiveWorkspaceItem}"
Margin="{TemplateBinding Padding}" /> </Border> </ControlTemplate> </Setter.Value> </Setter> </Style> </ResourceDictionary>

The key component in the example above is the WorkspaceItemPresenter. In the OnAddView and OnActivateView methods we set the property ActiveWorkspaceItem to whatever view we wish to display. The WorkspaceItemPresenter is in our case connected directly to this property, which effectively will display only the view which is currently active.

Make sure the CustomWorkspaces namespace is replaced with whatever namespace you have put your own workspace in. Now you should have a working workspace which basically behaves like the DeckWorkspace. Try it!

Stage III: The detachment

So far we have created a copy of the DeckWorkspace. Now it is time to extend its functionality. The first thing we should do is define how we want to tell the workspace to detach the views. I have chosen to use a WPF commands so that we can easily connect a button. Add the following declaration to your workspace. Make sure you import the namespace System.Windows.Input., You can find more about commands here: Commanding Overview

public static readonly RoutedUICommand DetachCommand = new RoutedUICommand("Detach", "Detach", typeof(DetachableDeckWorkspace));

In addition it will be practical to add a readonly attached property, IsDetached, to be used in the workspace item styling. You can find more about dependency properties here: Dependency Properties Overview.

private static readonly DependencyPropertyKey IsDetachedPropertyKey = DependencyProperty.RegisterAttachedReadOnly("IsDetached", 
    typeof(bool), typeof(DetachableDeckWorkspace), new UIPropertyMetadata(null)); public static readonly DependencyProperty IsDetachedProperty = IsDetachedPropertyKey.DependencyProperty; /// <summary> /// Gets a value indicating whether this workspace item has been detached /// </summary> /// <param name="obj">The obj.</param> /// <returns></returns> public static bool GetIsDetached(WorkspaceItem obj) { return (bool)obj.GetValue(IsDetachedProperty); } /// <summary> /// Sets a value indicating whether this workspace item has been detached /// </summary> /// <param name="obj">The obj.</param> /// <param name="value">if set to <c>true</c> this view is detached.</param> private static void SetIsDetached(WorkspaceItem obj, bool value) { obj.SetValue(IsDetachedPropertyKey, value); }

Then we’ll need to make a default constructor to connect with our new command.

public DetachableDeckWorkspace()
{
    // Connect the detach command to the workspace
    this.CommandBindings.Add(new CommandBinding(DetachCommand, ExecuteDetach));
}

private void ExecuteDetach(object sender, ExecutedRoutedEventArgs e)
{
    throw new NotImplementedException();
}
The ExecuteDetach method is where all the magic will happen, but we’ll get to that later. First we need some way to keep track of the views we have detached. Add a dictionary to your workspace.
// This dictionary contains a list of all detached views
private Dictionary<WorkspaceItem, Window> detachedItems = new Dictionary<WorkspaceItem, Window>();
The code which selects a new view to display when the current view is closed must only consider attached views, and we need to be able to do the same work when we detach views. The easiest is to put the reset code into its own method.
private void ResetActiveItem()
{
    // Find the last item (if any) which has not been moved into a window.
    var replacementItem = this.Items.Where(i => !this.detachedItems.ContainsKey(i)).LastOrDefault();
    if (replacementItem != null)
    {
        // If an item was found, activate it.
        this.ActiveWorkspaceItem = replacementItem;
    }
    else
    {
        // Otherwise clear the active item.
        this.ClearActiveWorkspaceItem();
    }
}
Then change the OnRemoveView to use this reset method instead of resetting the active workspace item itself.
protected override void OnRemoveView(ApplicationView view)
{
    // Find the view's workspace item
    var workspaceItem = this.GetItemForView(view);

    // CHANGE THIS...
    // // If the removed view is the active workspace item, reset it to whatever is next on the activation stack.
    // if (this.ActiveWorkspaceItem == workspaceItem)
    // {
    //     // If there are other items in the workspace, pick the last one and activate it. Otherwise reset the 
// // active workspace property. // if (this.Items.Count > 0) // this.ActiveWorkspaceItem = this.Items.Last(); // else // this.ClearActiveWorkspaceItem(); // } // TO THIS // If the removed view is the active workspace item, reset it to whatever is next on the activation stack.
if (this.ActiveWorkspaceItem == workspaceItem) this.ResetActiveItem(); // END OF CHANGED CODE // If the removed view is a detached view, its window must be closed. Window window; if (this.detachedItems.TryGetValue(workspaceItem, out window)) window.Close(); // Finally destroy the workspace item. this.DestroyItem(workspaceItem); }

Now we will need to write the ExecutedDetach method. It will have to create a window and move the relevant workspace item to the new window.

private void ExecuteDetach(object sender, ExecutedRoutedEventArgs e)
{
    // Get the view that should be detached
    var view = e.Parameter as ApplicationView;
                
    // Get the workspace item for the view
    var item = this.GetItemForView(view);

    // If a view has already been attached, ignore it.
    if (GetIsDetached(item))
        return;
    
    // Create a new container window
    var window = new Window();

    // Register this view as a detached item
    this.detachedItems.Add(item, window);
    SetIsDetached(item, true);

    // Reset the active workspace item if necessary
    if (this.ActiveWorkspaceItem == item)
        ResetActiveItem();
    
    // Create a workspace item presenter which will present the view
    var presenter = new WorkspaceItemPresenter();
    // Add the item to the presenter
    presenter.WorkspaceItem = item;
    // Add the presenter to the window
    window.Content = presenter;

    // Add up some events to manage the view when stuff happens to the window
    window.Closed += this.window_Closed;
    window.Activated += this.window_Activated;
    window.Deactivated += this.window_Deactivated;
    
    // Store the view on the Tag property of the window for convenience
    window.Tag = view;
    
    // Bind up the window's Icon and Title properties to reflect the properties on the view
    window.SetBinding(Window.IconProperty, 
        new Binding() { Source = item, Path = new PropertyPath("Icon") }); window.SetBinding(Window.TitleProperty,
        new Binding() { Source = item, Path = new PropertyPath("Title") }); // We need the window to always use the same data context as the workspace in order
// to make a view stay in the same context even when detached.
window.SetBinding(FrameworkElement.DataContextProperty,
        new Binding() { Source = this, Path = new PropertyPath("DataContext") }); // Show the window window.Show(); // If we don't dispatch the Activate method of the window, it is likely that it will be placed
// beneath our main window. // It's no point activating the window until it has been loaded.
window.Dispatcher.BeginInvoke(new Func<bool>(window.Activate), DispatcherPriority.Loaded); }
The window’s Activated and Deactivated events should reflect onto the target view.
void window_Deactivated(object sender, EventArgs e)
{
    // Whenever a window is deactivated, tell the view
    var window = (Window)sender;
    var view = (ApplicationView)window.Tag;
    WorkspaceHelper.NotifyDeactivated(view);
}

void window_Activated(object sender, EventArgs e)
{
    // Whenever a window is activated, tell the view
    var window = (Window)sender;
    var view = (ApplicationView)window.Tag;
    WorkspaceHelper.NotifyActivated(view);
}

When a window closes, its view should be reattached to the workspace.

void window_Closed(object sender, EventArgs e)
{
    // When a window closes, we want to reattach the view.
    var window = (Window)sender;

    // Get the workspace item from the window
    var presenter = (WorkspaceItemPresenter)window.Content;
    var item = presenter.WorkspaceItem;
    // Remove the item from the presenter to allow it to be placed in another container
    presenter.WorkspaceItem = null;
    
    // Unregister the item as a detached window
    this.detachedItems.Remove(item);
    SetIsDetached(item, false);
    
    // Set the active item to the detached view
    this.ActiveWorkspaceItem = item;
}
That should be it!

Stage IV: Using the workspace

Using your new workspace is simple. Create a new view and add your workspace like any of the other workspaces which is provided by the framework. Then add a ContainerStyle to add the Detach button.

<iw:DetachableDeckWorkspace AllowClose="False">
    <iw:DetachableDeckWorkspace.ContainerStyle>
        <Style TargetType="{x:Type wsp:WorkspaceItem}">
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="{x:Type wsp:WorkspaceItem}">
                        <Grid>
                            <Grid.RowDefinitions>
                                <RowDefinition Height="Auto" />
                                <RowDefinition Height="*" />
                            </Grid.RowDefinitions>
                            <DockPanel Height="24" Background="DarkBlue">
                                <!-- Detachment Button -->
                                <Button Content="Detach" DockPanel.Dock="Right" 
Command="{x:Static iw:DetachableDeckWorkspace.DetachCommand}"
CommandParameter="{TemplateBinding ApplicationView}"
CommandTarget="{TemplateBinding Workspace}" /> <TextBlock Text="{TemplateBinding Title}" VerticalAlignment="Center" Foreground="White" /> </DockPanel> <Border Padding="8" Grid.Row="1"> <wsp:ApplicationViewPresenter SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" /> </Border> </Grid> </ControlTemplate> </Setter.Value> </Setter> </Style> </iw:DetachableDeckWorkspace.ContainerStyle> <ws:ApplicationViewSource Uri="/CustomWorkspace.Modules.Core;component/DemoView1/DemoView1.xaml" /> <ws:ApplicationViewSource Uri="/CustomWorkspace.Modules.Core;component/DemoView2/DemoView2.xaml" /> </iw:DetachableDeckWorkspace>

The key component here is the detachment button. Notice that I have connected the Command parameter to the command property in the DetachableDeckWorkspace and the CommandParameter to the view I want the button to detach. Finally I have explicitly set the CommandTarget – for two reasons. First to ensure that the command goes to the correct workspace even if my program has multiple workspaces, and second – and most importantly – to make the command go to the correct workspace even when the view has been detached. Granted, in our case it doesn’t really matter, because the command doesn’t have a function in the detached mode, but if you wanted the same command to be able to reattach the view, you would need to tell the right workspace. This is because the original workspace wouldn’t be a part of the visual tree of the view.

You might also want to leverage the attached property DetachableDeckWorkspace.IsDetached property to change the visual style when the view is detached. You might for instance use triggers for this (look a bit down the page). I’ll leave this as an exercise for you though.

You can find a complete sample, CustomWorkspace, in the source download.

Last edited Feb 21, 2011 at 7:05 PM by Mantaray, version 4

Comments

No comments yet.