Adding an Extensible Context Menu

3/30/2010 9:36 PM
You can subscribe to this wiki article using an RSS feed reader.


Create the ContextMenu and ContextMenuEnabled Properties

Create these properties on your ViewModel:

#region " ContextMenu "

public IEnumerable<IMenuItem> ContextMenu
{
    get
    {
        return m_ContextMenu;
    }
    protected set
    {
        if (m_ContextMenu != value)
        {
            m_ContextMenu = value;
            NotifyPropertyChanged(m_ContextMenuArgs);
        }
    }
}
private IEnumerable<IMenuItem> m_ContextMenu = null;
static readonly PropertyChangedEventArgs m_ContextMenuArgs =
    NotifyPropertyChangedHelper.CreateArgs<MyViewModelType>(o => o.ContextMenu);

#endregion

#region " ContextMenuEnabled "
/// <summary>
/// Allows control of whether or not the context menu is enabled.
/// True by default.
/// </summary>
public bool ContextMenuEnabled
{
    get
    {
        return m_ContextMenuEnabled;
    }
    protected set
    {
        if (m_ContextMenuEnabled != value)
        {
            m_ContextMenuEnabled = value;
            NotifyPropertyChanged(m_ContextMenuEnabledArgs);
        }
    }
}
private bool m_ContextMenuEnabled = true;
static readonly PropertyChangedEventArgs m_ContextMenuEnabledArgs =
    NotifyPropertyChangedHelper.CreateArgs<MyViewModelType>(o => o.ContextMenuEnabled);
static readonly string m_ContextMenuEnabledName =
    NotifyPropertyChangedHelper.GetPropertyName<MyViewModelType>(o => o.ContextMenuEnabled);

#endregion

Import the Context Menu Items

The ViewModel that abstracts the control that will have the context menu has to import the context menu items, just like the Workbench imports the main menu (make sure your ViewModel class is implementing IPartImportsSatisfiedNotification):

    [Import(SoapBox.Core.Services.Host.ExtensionService)]
    private IExtensionService extensionService { get; set; }

    [ImportMany(ExtensionPoints.Workbench.Pads.MyViewModelType.ContextMenu,
                      typeof(IMenuItem), AllowRecomposition = true)]
    private IEnumerable<IMenuItem> contextMenu { get; set; }

    public void OnImportsSatisfied()
    {
        ContextMenu = extensionService.Sort(contextMenu);
    }


Create a View Including a Context Menu

Put this in a new resource dictionary file:

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:Your.Namespace"
    xmlns:contracts="clr-namespace:SoapBox.Core;assembly=SoapBox.Core.Contracts"
    x:Class="Your.Namespace.ViewClassname">

    <DataTemplate DataType="{x:Type local:ViewModelClassname}">
      <DataTemplate.Resources>
        <BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter"/>
        <Style x:Key="contextMenuStyle">
          <Setter Property="MenuItem.Header"
                  Value="{Binding Path=(contracts:IMenuItem.Header)}"/>
          <Setter Property="MenuItem.ItemsSource"
                  Value="{Binding Path=(contracts:IMenuItem.Items)}"/>
          <Setter Property="MenuItem.Icon"
                  Value="{Binding Path=(contracts:IMenuItem.Icon)}"/>
          <Setter Property="MenuItem.IsCheckable"
                  Value="{Binding Path=(contracts:IMenuItem.IsCheckable)}"/>
          <Setter Property="MenuItem.IsChecked"
                  Value="{Binding Path=(contracts:IMenuItem.IsChecked)}"/>
          <Setter Property="MenuItem.Command"
                  Value="{Binding}"/>
          <Setter Property="MenuItem.Visibility"
                  Value="{Binding Path=(contracts:IControl.Visible),
                  Converter={StaticResource BooleanToVisibilityConverter}}"/>
          <Setter Property="MenuItem.ToolTip"
                  Value="{Binding Path=(contracts:IControl.ToolTip)}"/>
          <Style.Triggers>
              <DataTrigger Binding="{Binding Path=(contracts:IMenuItem.IsSeparator)}"
                           Value="true">
               <Setter Property="MenuItem.Template">
                 <Setter.Value>
                  <ControlTemplate TargetType="{x:Type MenuItem}">
                   <Separator
                     Style="{DynamicResource {x:Static MenuItem.SeparatorStyleKey}}"/>
                  </ControlTemplate>
                 </Setter.Value>
               </Setter>
              </DataTrigger>
           </Style.Triggers>
         </Style>
      </DataTemplate.Resources>
      <StackPanel Orientation="Horizontal"
                  ContextMenuOpening="stackPanel_ContextMenuOpening">
        <Whatever you want goes here.../>
        <StackPanel.ContextMenu>
          <ContextMenu x:Name="contextMenu"
           ItemsSource="{Binding Path=(local:YourViewModelClassname.ContextMenu)}"
           IsEnabled="{Binding Path=(local:
YourViewModelClassname.ContextMenuEnabled)}"
           ItemContainerStyle="{StaticResource contextMenuStyle}"
             />
        </StackPanel.ContextMenu>
      </StackPanel>
    </DataTemplate>
</ResourceDictionary>


Create the Resource Dictionary CodeBehind


We need a CodeBehind to export the resource dictionary into the Views collection of the host, but we also need to handle the ContextMenuOpening event on the StackPanel.  I've been trying to figure out a way to do this without using the CodeBehind, but it escapes me.  At any rate, the point of the event handler is to get a reference to the ViewModel of the control that was clicked on, and pass that to the menu before it opens, so it has a "context".  The context has to be the ViewModel, not the View or the control.  Here's what the CodeBehind for the View looks like:

namespace Your.Namespace
{
    [Export(SoapBox.Core.ExtensionPoints.Host.Views, typeof(ResourceDictionary))]
    public partial class ViewClassname: ResourceDictionary
    {
        public ViewClassname()
        {
            InitializeComponent();
        }

        /// <summary>
        /// Tell the context menu items about the ViewModel that is the "context"
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void stackPanel_ContextMenuOpening(
            object sender, ContextMenuEventArgs e)
        {
            StackPanel sp = sender as StackPanel;
            if (sp != null)
            {
                YourViewModelClassname vm =
                    sp.DataContext as YourViewModelClassname ;
                if (vm != null)
                {
                    IEnumerable<IMenuItem> items =
                        vm.ContextMenu as IEnumerable<IMenuItem>;
                    if (items != null)
                    {
                        foreach (IMenuItem item in items)
                        {
                            // will automatically set all
                            // child menu items' context as well
                            item.Context = vm;
                        }
                    }
                    else
                    {
                        e.Handled = true;
                    }
                }
                else
                {
                    e.Handled = true;
                }
            }
            else
            {
                e.Handled = true;
            }
        }
    }
}


That way, you can have lots of instances of your ViewModel floating around, and if the user right clicks on one to open a context menu, it's always the same context menu that gets opened, but the Context property on the menu items will always be set to the ViewModel of whatever the user clicked on.

Tags:
Home: SoapBox Core What's new: Recently changed articles