A MenuKiller Control – Draft

by Christoph Menge in Software

This is just a draft on my upcoming article. I have asked a number of WPF Gurus to help me out on this…

Introduction

In an effort to take user experience to the next level, designers have come up with ideas on how to solve old problems in a new way. In the last few months, the term “Differentiated User Experience” or simply “Differentiated UX” has come up to describe these efforts.

One of the beforementioned UI designers is Dax Pandhi, who published an article “Rethinking the Button” on his blog which introduced a new control he calls MenuKiller. This control is certainly more than a replacement for the Menu and ContextMenu, as it can be used in ways that are quite different. However, it turns out that changing the way things are typically done is indeed not so easy.

A Screenshot of the MenuKiller

This article gives an overview of my implementation of the MenuKiller and presents a small sample application. It’s written in C# using .NET 3.5 and Visual Studio 2008. What is presented here is not a full-fledged control, since there are quite a number of open todos. Yet, I hope that this motivates the use of different controls and serves as a good starting point for other WPF control development.

Download

The current code can be downloaded here.

Scope & Prerequsities

Since this is a custom control which is meant to redefine significant parts of user experience, it is not trivial to implement. Hence, knowledge of WPF basics is helpful, but I attempted to present the basics while still giving some more advanced tips. Although this control is merely the result of my begginer’s journey into WPF, it took me quite some time to write and it involved a lot of reading, programming and experimenting.

Also, for a better explanation of the goal we try to achieve, I strongly recommend Dax’s article. While the screenshot should already give you an idea, the article provides significantly more insight.

This control uses a very different approach to control alignment. This is the most critical point of this article. Either I did this because I was completely lost and didn’t see a better solution or this approach is a necessity of the complicated arrange behaviour this control employs. I am quite happy with the last iteration of this, before I managed to cut down code size by almost 70% through refactoring. I also dropped some ‘heavy’ features.

Design Aims

  • Using the control in XAML should be very similar to using a TreeView.
  • It should allow for rich and dynamic content, as well as animation.
  • Databinding must be possible.
  • Templating is also a requirement.
  • The importance of the Visual Studio designer is low.

Our Options

When reading the MSDN on custom control authoring, Microsoft presents a lot of options on how to avoid authoring a custom control. These options include Rich Content, Styles, Data Templates, Control Templates and Triggers. Microsoft does not directly encourage you to write a control, probably in an effort to prevent people from writing controls contrary to the idea of WPF. However, I have come to the conclusion that it is best for this control to use a custom control approach, despite -or even because of- the possibilities of WPF. This control is implemented with the idea in mind that you might want to create a control which does not come with source, yet can be customized easily.

Next, it is important not to re-invent the wheel, primarily because it is error-prone and the result might not fit into the framework as it should.

So what does our control do? Essentially, it shows a hierarchically organized collection of command-bound items circularly arranged around a center object.

That does, I agree, sound a little overcomplicated, but it is the full statement. Let’s see what we can get from it:

  • Hierarchically organized: Since the control presents different ‘levels’, we need to model a hierarchy. This is reminiscent of a TreeView, obviously. TreeViews are derived from ItemsControl, so we might also want to take these into account. Note that TreeViews need container objects (TreeViewItems) to store the hierarchy information while still allowing rich content.
  • Circularly arranged: Since this involves solely the arrangement of items, we might want to separate this problem into a custom panel, commonly called a CircularPanel.
  • around a center object: While this might look like a simple statement, it will prove to be one of the hardest tasks in the creation of our MenuKiller. Normal controls can have one of four alignments horizontally and vertically. These alignments are relative to their respective parent. The canvas, on the other hand, allows items to be positioned arbitrarily within its bounds.

The TreeView

A normal TreeView is an ItemsControl, which typically has TreeViewItems. These are, again, ItemsControls (or more specifically HeaderedItemsControls), thereby representing the desired tree structure. For our control, however, the MenuKiller itself is not an ItemsControl: It can only have one root item. Therefore, it is a ContentControl and we will not derive from TreeView.

The MenuKillerItems, on the other hand, are in fact ItemsControls, thereby being a good candidate for the parent of our MenuKillerItems. Although we won’t derive from TreeView, a small comparison of the two might be enlightening, yet keeping in mind that the behaviour of the MenuKiller is, not suprisingly, very similar to that of a normal Menu or ContextMenu.

  • While the TreeView is typically used for selection purposes, a MenuKiller is used for activation of actions. This can be a button press or -toggle, depending on the CanExecute() on the Command
  • Unlike TreeViewItems, Items in the MenuKiller cannot be selected. More importantly, nodes that have children are fundamentally different from those that haven’t, because they are used only for grouping purposes. (Note: It’s possible to do grouping with TreeViews, too, but that is not their default behaviour.
  • Most TreeViews are embedded in a scroll container, a MenuKiller on the other hand must not live in a scroll container. It also has a much tighter constraint in terms of item count.

It is obvious that the difference between the two is rather large. I also implemented an approach using Menu and MenuItems, which have a very similar behaviour, but it turned out to be very ugly due to some render-related problems with the MenuItems.

Sizing & Positioning

The control works in a very visual manner, which requires us to have the root item in close proximity to the object it shall operate on. Let’s first concentrate on the case where the actual object does not move. Now we are still facing the problem that the control, when collapsing / expanding, changes its size. When the control grows to a certain direction -say, for example, to the top- it also needs to be repositioned, so the center of the main circle is still aligned with our object.

Even worse, since we have a hierarchical data structure, this problem must be solved for all items recursively. This, again, calls for a special panel. In an act of desperation, I called this ReferenceAlignPanel, thus qualifying for the worst-class-names-ever-thought-of-2008-award. While the name of this control is really awkward, it’s behaviour is at least unusual, too:
Given a set of controls that implement ICustomAlignedControl, it will ask the controls for their AlignReferencePoint and arrange the controls in a way such that all reference points coincide. This, of course, is not possible in every case. I stuck to a very basic implementation of this kind of panel. More on that later.

The standard ContextMenu will be placed in a Popup, and can draw on areas of the screen where the application window is not. That sounds tempting for the MenuKiller, too. However, the ContextMenu can do that only due to the fact that the ContextMenu is a very boring control with a solid background color. The MenuKiller, having a 100% transparent panel, should not cross window borders. The effect might be visually irritating.

Apart from the alignment of circles, it would be nice to see a different expanding behaviour if the control is located at a window border. It is also desireable to make a distinction between left-to-right and right-to-left systems. I call this feature “adaptive arrange”, but the current solution does not include it because some problems remain to this day. (The issue here is that we need arrangement information in the measure pass. That is not good, and the current simple MenuKiller already needs remeasures from time to time).

Summing up the possibilities on arrangement:

  • We can rearrange the items in the CircularPanel in a smart manner, thus eliminating the need to use alignment hacks. This is tricky and will only work to a certain degree.
  • We can put the whole MenuKiller or all MenuKillerItems into a Popup, which might result in more available space at the cost of visual coherence. This doesn’t eliminate the arrangement problem at screen borders, however. Putting each item into a separate popup is not a good idea from my experience.

For most scenarios, we can make some simplifying assumptions. The most important assumption is that there is enough space in one direction, at least. So we can choose the direction(s) with most space available as initial grow directions. We might also accept the case where the control ‘jumps’ because it grew too large in one direction, therefore requiring to be realigned, at least if there is little space available.

Custom Alignment: ICustomAlign

Let’s talk about the way our custom alignment should look for the MenuKiller: When we talk about the center of a control, we normally mean the point which lies in the middle of the actual control’s border. However, for the CircularPanel, it is desirable to know the center of the circle segment. We have to provide some public getter:

///

/// This name is too generic and imprecise.
/// 

interface ICustomAlignedControl
{
  Point AlignReferencePoint { get; }
}

Notice that this point may be well outside of the control. The screenshot shows a CircularPanel with its full circle, the circles center and the controls center:

Screenshot showing the difference between the center of the CircularPanels' desired size and the center of the corresponding circle segment.

Since we don’t want to mess around with this interface and its alignment-implications everywhere, it’s time for the first custom panel of the solution which takes care of that:

The ReferenceAlignPanel

Also known as the panel with the ugly name, the ReferenceAlignPanel constitutes the core concept that addresses the alignment issues we are getting into by arranging all its items such that their AlignReferencePoints or their centers coincide (see above). Let me give a short introduction to panels:

When writing a custom panel, one must override the MeasureOverride and ArrangeOverride methods, which constitute the 2-pass layout system of WPF. In the MeasureOverride() method, the control is asked for its DesiredSize. WPF will automatically assign the return value of the measure pass to the DesiredSize-property. Note that even if the amount of availableSize is infinite (as it is for example in a scroll-container), you must not return an infinite size from MeasureOverride. Also, we need to make sure we call Measure() on all children:

using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;

namespace MenuKiller
{
  public class ReferenceAlignPanel : Panel, ICustomAlignedControl
  {
    private Point _alignReferencePoint;

    public Point AlignReferencePoint
    {
      get { return _alignReferencePoint; }
      set { _alignReferencePoint = value; }
    }

    private Vector GetChildOffset(UIElement child)
    {
      Vector childDesiredOffset;

      if (child is ICustomAlignedControl)
      {
        childDesiredOffset = AlignReferencePoint -
                                    ((ICustomAlignedControl)child).AlignReferencePoint;
      }
      else
      {
        // TODO: Honor the children's align properties
        childDesiredOffset = new Vector();

        childDesiredOffset.X =
            _alignReferencePoint.X - child.DesiredSize.Width * 0.5;
        childDesiredOffset.Y =
            _alignReferencePoint.Y - child.DesiredSize.Height * 0.5;
      }

      if (AllowRealign)
      {
        // If this happens, we have to re-measure all items!
        if (childDesiredOffset.X < 0)
        {
          _alignReferencePoint.X -= childDesiredOffset.X;
          childDesiredOffset.X = 0;
          realignRequired = true;
        }

        if (childDesiredOffset.Y < 0)
        {
          _alignReferencePoint.Y -= childDesiredOffset.Y;
          childDesiredOffset.Y = 0;
          realignRequired = true;
        }
      }

      return childDesiredOffset;
    }

    bool realignRequired = false;

    protected override Size ArrangeOverride(Size finalSize)
    {
      int arrangeCount = 0;

      // realign should not be required, but you can't bet on it.
      // Protect from infinite iteration.
      do
      {
        foreach (UIElement child in Children)
        {
          if (child.IsVisible)
          {
            Vector childOffset = GetChildOffset(child);

            child.Arrange(
              new Rect(childOffset.X,
                       childOffset.Y,
                       child.DesiredSize.Width,
                       child.DesiredSize.Height));
          }
        }

        ++arrangeCount;
      } while (realignRequired && arrangeCount < 2);

      return finalSize;
    }

    protected override Size MeasureOverride(Size availableSize)
    {
      Size inifiniteSize =
            new Size(Double.PositiveInfinity, Double.PositiveInfinity);

      AdjustAlignReferencePoint(availableSize);

      bool bMeasureNecessary = true;
      int iMaxRemeasureCount = 4;

      Size neededSize = new Size();

      // ugly and convoluted
      // A high number of remeasures will not happen - except
      // for the vs designer, it seems. Removing
      // this check will crash VS
      // FIXME: Clean this up or use different beh. in design mode
      for (int i = 0; bMeasureNecessary && i < iMaxRemeasureCount; ++i )
      {
        neededSize.Width = neededSize.Height = 0;

        bMeasureNecessary = false; // Assume remeasure is not needed

        Vector MinimumChildOffset =
          new Vector(Double.MaxValue, Double.MaxValue);

        foreach (UIElement child in Children)
        {
          child.Measure(inifiniteSize);

          if (child.IsVisible == false)
          {
            continue;
          }

          Vector childDesiredOffset = GetChildOffset(child);

          MinimumChildOffset.X =
            Math.Min(MinimumChildOffset.X, childDesiredOffset.X);

          MinimumChildOffset.Y =
            Math.Min(MinimumChildOffset.Y, childDesiredOffset.Y);

          neededSize.Width = Math.Max(neededSize.Width,
                        childDesiredOffset.X + child.DesiredSize.Width);
          neededSize.Height = Math.Max(neededSize.Height,
                        childDesiredOffset.Y + child.DesiredSize.Height);
        }

        if (bMeasureNecessary)
          continue;

        if (AllowRealign)
        {
          if (MinimumChildOffset.X > 0)
            _alignReferencePoint.X -= MinimumChildOffset.X;

          if (MinimumChildOffset.Y > 0)
            _alignReferencePoint.Y -= MinimumChildOffset.Y;

          if (MinimumChildOffset.X > 0 || MinimumChildOffset.Y > 0)
          {
            bMeasureNecessary = true;
          }
        }
      }

      return neededSize;
    }

#if DEBUG
    protected override void OnRender(DrawingContext dc)
    {
      base.OnRender(dc);

      Rect r = new Rect(this.DesiredSize);

      dc.DrawRectangle(null, new Pen(Brushes.Tomato, 2.0), r);

      // show the visual center
      dc.DrawEllipse(Brushes.SeaGreen, null, _alignReferencePoint, 3, 3);
    }
#endif
  }
}

As you can see, there are some hacks in here: We sometimes need to repeat measurement. We will later dig into that deeper; there are a number of unusual effects here.

Implementing a CircularPanel

The CircularPanel will arrange all items (more specifically, their centers or their align reference points) on a circle segment. The segments StartAngle, EndAngle and Radius can be customized using DependencyProperties, which automatically enables data binding and animation:

#region Dependency Properties
#region StartAngle DP
[Category("Arrange")]
[Description(@"Sets the angle in degrees where the first item will
    be placed. 0 degrees points to the top.")]
public double StartAngle
{
    get { return (double)GetValue(StartAngleProperty); }
    set { SetValue(StartAngleProperty, value); }
}

public static readonly DependencyProperty StartAngleProperty =
    DependencyProperty.Register("StartAngle", typeof(double), typeof(CircularPanel),
    new FrameworkPropertyMetadata(45d,
        FrameworkPropertyMetadataOptions.AffectsArrange));
#endregion

#region EndAngle DP
[Category("Arrange")]
[Description(@"Sets the angle in degrees where the last item will be placed. 0 degrees
    points to the top. Note that this will be ignored if AngleSpacing is specified.")]
[TypeConverterAttribute(typeof(DoubleAutoConverter))]
public double EndAngle
{
    get { return (double)GetValue(EndAngleProperty); }
    set { SetValue(EndAngleProperty, value); }
}

public static readonly DependencyProperty EndAngleProperty =
    DependencyProperty.Register("EndAngle", typeof(double), typeof(CircularPanel),
    new FrameworkPropertyMetadata(Double.NaN,
        FrameworkPropertyMetadataOptions.AffectsArrange));
#endregion

#region Radius DP
[Category("Arrange")]
[Description(@"Sets the radius of the circle where children will be positioned on.
    Note that the resulting panel might be siginificantly larger.")]
public double Radius
{
    get { return (double)GetValue(RadiusProperty); }
    set { SetValue(RadiusProperty, value); }
}

public static readonly DependencyProperty RadiusProperty =
    DependencyProperty.Register("Radius", typeof(double), typeof(CircularPanel),
        new FrameworkPropertyMetadata(45d,
            FrameworkPropertyMetadataOptions.AffectsMeasure));
#endregion

#region AngleSpacing DP
[Category("Arrange")]
[Description(@"Sets the number of degress between two items. When
    set to 'auto', children will be arranged evenly distributed between
    StartAngle and EndAngle.")]
[TypeConverterAttribute(typeof(DoubleAutoConverter))]
public double AngleSpacing
{
    get { return (double)GetValue(AngleSpacingProperty); }
    set { SetValue(AngleSpacingProperty, value); }
}

public static readonly DependencyProperty AngleSpacingProperty =
    DependencyProperty.Register("AngleSpacing", typeof(double),
        typeof(CircularPanel),
        new FrameworkPropertyMetadata(45d,
            FrameworkPropertyMetadataOptions.AffectsArrange));
#endregion
#endregion

For the CircularPanel, the MeasureOverride() method looks like this:

protected override Size MeasureOverride(Size availableSize)
{
  Size size = new Size(Double.PositiveInfinity, Double.PositiveInfinity);
  Size resultSize = new Size();

  if (this.Children == null || this.Children.Count == 0)
  {
      return resultSize;
  }

  RecalcParams();

  int iCurrentChildIndex = 0;

  dXMax = Double.MinValue;
  dYMax = Double.MinValue;

  dXMin = Double.MaxValue;
  dYMin = Double.MaxValue;

  foreach (UIElement child in Children)
  {
    double dAngle = ((double)(iCurrentChildIndex)) * dRenderAngleSpacing
    		    + StartAngle;

    double dX =  dRenderRadius * Math.Sin(dAngle * dDegToRad);

    // We invert the Y coordinate, because the origin in controls
    // is the upper left corner, rather than the lower left
    double dY = -dRenderRadius * Math.Cos(dAngle * dDegToRad);

    child.Measure(size);

    Point visualCenter = new Point();

    if (child is ICustomAlignedControl)
    {
      ICustomAlignedControl mkchild = (ICustomAlignedControl)child;
      visualCenter.X = mkchild.AlignReferencePoint.X;
      visualCenter.Y = mkchild.AlignReferencePoint.Y;
    }
    else
    {
      visualCenter.X = child.DesiredSize.Width * 0.5;
      visualCenter.Y = child.DesiredSize.Height * 0.5;
    }

    dXMax = Math.Max(dXMax, dX + (child.DesiredSize.Width - visualCenter.X));
    dYMax = Math.Max(dYMax, dY + (child.DesiredSize.Height - visualCenter.Y));

    dXMin = Math.Min(dXMin, dX - (visualCenter.X));
    dYMin = Math.Min(dYMin, dY - (visualCenter.Y));
    iCurrentChildIndex++;
  }

  resultSize.Width = dXMax - dXMin;
  resultSize.Height = dYMax - dYMin;

  return resultSize;
}

private Vector GetChildOffset(UIElement child, double dAngle)
{
  Vector Offset = new Vector();

  if (child is ICustomAlignedControl)
  {
    ICustomAlignedControl mkchild = (ICustomAlignedControl)child;
    Offset.X =  dRenderRadius * Math.Sin(dAngle * dDegToRad)
    		- dXMin - mkchild.AlignReferencePoint.X;
    Offset.Y = -dRenderRadius * Math.Cos(dAngle * dDegToRad)
    		- dYMin - mkchild.AlignReferencePoint.Y;
  }
  else
  {
    Offset.X =  dRenderRadius * Math.Sin(dAngle * dDegToRad)
    		- dXMin - child.DesiredSize.Width * 0.5;
    Offset.Y = -dRenderRadius * Math.Cos(dAngle * dDegToRad)
    	 	- dYMin - child.DesiredSize.Height * 0.5;
  }

  return Offset;
}

First of all, note that the maximum and minimum X and Y coordinates will be used to determine the size of the resulting control. The idea here is that the CircularPanel does not need to be large enough for a full circle. You might even choose to use the CircularPanel in a different context with very large radius to achieve a completely different effect:

CircularPanel Screenshot

Note the border which is, as promised, only as large as necessary to hold the children. The circle segment which is visible is a little debugging helper. Let’s add these fancy lines and circles to the OnRender() method:

#if DEBUG
protected override void OnRender(DrawingContext dc)
{
    base.OnRender(dc);

    System.Windows.Media.Pen p =
        new System.Windows.Media.Pen(System.Windows.Media.Brushes.LightBlue, 2.0);

    dc.DrawRectangle(null, p, new Rect(0, 0,
        DesiredSize.Width - Margin.Left - Margin.Right,
        DesiredSize.Height - Margin.Top - Margin.Bottom));

    dc.DrawEllipse(null, p, AlignReferencePoint, dRenderRadius, dRenderRadius);
    dc.DrawEllipse(null, p, AlignReferencePoint, 1.0, 1.0);
}
#endif

A note on this one: Using the DesiredSize, we can determine and debug whether our measure method works as expected. However, when clipped, this value will not decrease, so the border will just be clipped partially just like the rest of the control. Sice DesiredSize includes Margin, we have to subtract the value if we want to see the minimum size in this case. You also may want to draw additional Rects for debugging purposes, namely:

  • Rect r = LayoutInformation.GetLayoutSlot(this); This is all available space (might be larger or smaller than DesiredSize) including Margin
  • RenderSize This is all available space (again larger or smaller than DesiredSize), not including margin

A simple CircularPanel in debug mode now looks like this:

Screenshot of a CircularPanel showing a full circle.

The CircularPanel provided here is still very simple, because it does not provide support realignment, re-scaling, different positioning modes, etc. However, adding that functionality is straightforward, and the panel is more elaborate than many others I found on the net. Furthermore, our CircularPanel makes some assumptions here, namely that it centers all children unless they implement the ICustomAlignedControl interface.

The MenuKillerItem

First, we have to decide whether we customize an existing control, create a UserControl or create a custom control. Since we need quite special behaviour and want the control to be re-templateable, we choose a custom control. When creating a custom control, there is no designer support (nonsense in this case, anyway) and the control will be derived from an existing control or a control base class, rather than from UserControl. A custom control will have a default template which is stored in Themes/Generic.xaml.

As previously mentioned, the MenuKillerItem will have to be an ItemsControl so it can hold a number of children. We also like it to have an Expanded property and a Header, so we derive this from TreeViewItem, where we get both of the last for free. With the components we have created before, we can start creating the core functionality of the future MenuKiller control within the MenuKillerItem. Since the whole MenuKiller has a recursive structure, most of the work is done in the MenuKillerItems, rather than in the MenuKiller class.

Additionally, we want the MenuKillerItem to report where its Header is positioned, so we have to implement ICustomAlignedControl, too.

Moreover, sticking to the approach which has a Button in its template, we need to assign a Command to the MenuKillerItem. In doing so, we expect the MenuKillerItem to be able to trigger that associated command. To do so, it needs to implement ICommandSource. Thus, our class defition reads:

public class MenuKillerItem : TreeViewItem, ICommandSource, ICustomAlignedControl
{
  // ...
}

Putting it together

The MenuKillerItem will consist of a Button which can be used to toggle expansion (a node), or to send a specific application command (a leaf), and a CircularPanel, which presents children if they exist and the MenuKillerItem is expanded. Since we want the Button to be in the center of the circle segment, we enclose both in a ReferenceAlignPanel, which does all the hard work for us.

Sadly, we need to know these components in the code (Footnote: This is not entirely true, at least the Button can easily be removed. For this article, I chose the simpler option, though). Therefore, we have to introduce some kind of contract between the default template defined in Generic.xaml and the code. We do this by giving those controls a special name, per convention prefixed with PART_. Let’s have a look at the MenuKillerItems default template:



  
    
    
  

Let’s take a close look at this. The first line is trivial: It creates a ControlTemplate for the MenuKillerItem. (Applying the template, however, can be a little tricky. See my blog post on this).

Everything in the control is then placed in a ReferenceAlignPanel, which will arrange the items in the desired fashion. Since we need to know this control in the code, we give it a PART_-prefix and call it PART_AlignPanel.

In the ReferenceAlignPanel, we place a Button, which we simply call PART_Button and apply a certain style to it. What the style looks like is not important right now. This merely makes sure the hover behaviour of the buttons is the same. As previously mentioned, the button does not need to be part of the generic template, but it simplifies using the control a little. Since we don’t want to restrict the contents of the button, we simply put a ContentPresenter in it which will put all content from the TreeViewItem.Header into it.

The CircularPanel is now added, again with PART_-prefix. Since we want all TreeViewItem.Items to appear in it, we set IsItemsHost to true. The Visibility, of course, shall depend on the TreeView.IsExpanded state. We’ll cover that later.

We have now created our default control template in generic.xaml, where the control has three PART_s. Here’s how we access them from code so we can use them later:

public override void OnApplyTemplate()
{
  base.OnApplyTemplate();

  mCenterButton = Template.FindName("PART_Button", this) as Button;

  if (null != mCenterButton)
  {
    mCenterButton.Click += new System.Windows.RoutedEventHandler(mCenterButton_Click);
    mCenterButton.MouseEnter += new MouseEventHandler(MenuKillerItem_MouseEnter);
  }

  mPanel = Template.FindName("PART_Panel", this) as CircularPanel;

  mAlignPanel = Template.FindName("PART_AlignPanel", this) as ReferenceAlignPanel;

  if (null != mPanel)
  {
    mPanel.ChildArranged += new CircularPanel.OnChildArranged(mPanel_ChildArranged);
  }
}

An important note: Do not use GetTemplateChild(), because it will only find direct descendants. Also refer to the MSDN Comment of GetTemplateChild(). IntelliSense should also tell you that its use is deprecated. This is a common mistake and it severly limits the extensibility and customizeability of the control.

I introduced this section with “sadly”. Why that? Well, the problem with this kind of dependency is that is somewhat error prone and hidden. It also creates a very tight coupling. However, the reasons for this lie largely in the problem domain: We need these controls to exist, otherwise the control cannot implement the behaviour it is expected to. We can at least loosen the coupling a little by providing interfaces here, that is, we don’t require a CircularPanel for example, but only an ICircularPanel which can then be re-implemented by the control user. This enhances extensibility at little cost, but here, customization is going very far.

In order to enhance visibility of this contract, there is the TemplatePart attribute, which should be applied to the class:

[TemplatePart(Name = "PART_Button", Type = typeof(Button))]
[TemplatePart(Name = "PART_Panel", Type = typeof(CircularPanel))]
[TemplatePart(Name = "PART_AlignPanel", Type = typeof(ReferenceAlignPanel))]
public class MenuKillerItem : TreeViewItem, ICommandSource, ICustomAlignedControl
{
  // ...
}

The attribute doesn’t change anything in terms of behaviour, but it adds meta information which is available to programs such as Expression Blend.

The code snippet above also shows the first reasons why we need to identify these controls: We want to be notified of MouseEnter and of Click events on the Button. The latter activates the command that is associated with this MenuKillerItem, or toggles expanson:

void mCenterButton_Click(object sender, System.Windows.RoutedEventArgs e)
{
  if (this.mPanel.Children.Count > 0)
  {
    MenuKillerCommands.OpenCategory.Execute(null, this);
  }
  else
  {
    if (Command != null)
    {
      RoutedCommand command = Command as RoutedCommand;

      if (command != null)
      {
        command.Execute(CommandParameter, CommandTarget);
      }
      else
      {
        ((ICommand)Command).Execute(CommandParameter);
      }
    }
  }
}

Note that there are esentially two different ways to execute the Command, depending on its type. Also note that we also implement the expansion using a custom routed command. This is convenient, since there is little overhead but it improves extensibility by providing a mechanism to programatically trigger expansion from the outside.

Custom Commands

Declaring a custom command is very easy:

public class MenuKillerCommands
{
  public static readonly RoutedUICommand ToggleExpansion =
        new RoutedUICommand("ToggleExpansion",
                            "ToggleExpansion",
                            typeof(MenuKillerCommands));
}

However, we also need to create a CommandBinding in the static constructor of the MenuKillerItem:

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

  CommandBinding binding = new CommandBinding(MenuKillerCommands.ToggleExpansion);
  binding.Executed += new ExecutedRoutedEventHandler(ToggleCommandHandler);
  binding.CanExecute += new CanExecuteRoutedEventHandler(CanToggleHandler);

  CommandManager.RegisterClassCommandBinding(typeof(MenuKillerItem), binding);
}

private static void ToggleCommandHandler(object target, ExecutedRoutedEventArgs e)
{
  if (null != target && target is MenuKillerItem)
  {
    ((MenuKillerItem)target).ToggleExpand();
  }
}

private static void CanToggleHandler(object target, CanExecuteRoutedEventArgs e)
{
  if (null != target && target is MenuKillerItem)
  {
    e.CanExecute = ((MenuKillerItem)target).CanToggleExpand();
  }
}

The command then simply calls ToggleExpand on the target MenuKillerItem.

Expanding a Node

Now, what happens when we open a node? Essentially, we have to set the TreeViewItem.Expanded property. Databinding will the show the circular panel, and the ReferenceAlignPanel makes sure alignment is OK… But, wait a second, doesn’t that imply that the size of this control changes? Yes, but worst of all, this change propagates to the root of the tree:

void InvalidateTreeMeasure()
{
  InvalidateMeasure();
  mPanel.InvalidateMeasure();
  mAlignPanel.InvalidateMeasure();

  if (Parent is MenuKillerItem)
  {
    ((MenuKillerItem)Parent).InvalidateTreeMeasure();
  }
}

Measure and Arrange in the MKI

Fortunately, since the ReferenceAlignPanel and the CircularPanel do all the gritty work for us, the MeasureOverride of the MenuKillerItem is very simple:

protected override Size MeasureOverride(Size constraint)
{
  // Beware of all this allocations, they are not necessary
  Size infSize = new Size(Double.PositiveInfinity, Double.PositiveInfinity);

  mCenterButton.Measure(infSize);

  mAlignPanel.AlignReferencePoint =
  	new Point(mCenterButton.DesiredSize.Width * 0.5,
  						mCenterButton.DesiredSize.Height * 0.5);

  mPanel.Measure(infSize);
  mAlignPanel.Measure(infSize);

  return base.MeasureOverride(constraint);
}

There is not even an ArrangeOverride! However, when a new MenuKillerItem is opened, we might want to tell it which direction it should open to. Because we can’t get that information easily, we simply have the CircularPanel notify us about its arrangements. In OnApplyTemplate, we already registered to the event. Here comes the implementation:

public override void OnApplyTemplate()
{
	// ...
  if (null != mPanel)
  {
      mPanel.ChildArranged +=
      	new CircularPanel.OnChildArranged(mPanel_ChildArranged);
  }
}

void mPanel_ChildArranged(object sender, UIElement child, double angle)
{
  MenuKillerItem childItem = child as MenuKillerItem;

  childItem.mPanel.AngleSpacing = Double.NaN;

  // These values should not be hardcoded, of course
  childItem.mPanel.StartAngle = angle - 45.0;
  childItem.mPanel.EndAngle = angle + 45.00;
}

Opacity Issues

Next, we want to dial down the opacity of the parent control’s button and all siblings to indicate which ‘path’ is active in a visual manner. Unfortunately, there is an ‘opacity stack’, which makes it impossible to create a child element which is less transparent than its parent. So we have to come up with a little helper method:

public void SetChildrenOpacity(UIElement exception, double dOpacity)
{
  foreach(UIElement elemRover in mPanel.Children)
  {
    if (elemRover != exception)
    {
      elemRover.Opacity = dOpacity;
    }
  }
}

void SetOpacityRecursively(int iLevel)
{
  // 2.0 seemed a little too much. One might want this to be configureable
  double dTargetOp = 1.0 / Math.Pow(1.4, iLevel);

  if (Parent is MenuKillerItem)
  {
    ((MenuKillerItem)Parent).SetChildrenOpacity(this, dTargetOp);
    ((MenuKillerItem)Parent).SetOpacityRecursively(++iLevel);
  }

  mCenterButton.Opacity = dTargetOp;
}

The Tool Tip

We’re almost done!

There’s one thing left, however: The ‘tool tip’ which is presented on the center button. This is meant to provide information about the button the mouse is currently over. So there are esentially two small problems to solve here:

  1. Identify the button that is under the mouse currently and retrieve its tooltip
  2. Present the tool tip

To find the item that is currently under the mouse, we have to get back to our template PART_s. We already registered a MouseEnter handler in the MenuKillerItem

void MenuKillerItem_MouseEnter(object o, MouseEventArgs e)
{
  Button sender = o as Button;

  if (null != sender)
  {
    sender.RaiseEvent(new RoutedEventArgs(MenuKillerItem.MouseHoverEvent, this));
  }
}

The event raised in here is a custom one with a very bad (i.e. misleading, already existing) name:

#region Attached Event MouseHover
public static readonly RoutedEvent MouseHoverEvent =
    EventManager.RegisterRoutedEvent("MouseHover",
                                    RoutingStrategy.Bubble,
                                    typeof(RoutedEventHandler),
                                    typeof(MenuKillerItem));

public static void AddMouseHoverHandler(DependencyObject o, RoutedEventHandler handler)
{
  ((UIElement)o).AddHandler(MenuKillerItem.MouseHoverEvent, handler);
}

public static void RemoveMouseHoverHandler(DependencyObject o, RoutedEventHandler handler)
{
  ((UIElement)o).RemoveHandler(MenuKillerItem.MouseHoverEvent, handler);
}
#endregion

In order to make this available to the root MenuKiller, we have to have the MenuKiller catch the event as soon as it bubbles up and implement a DependencyProperty which makes the current ‘tool tip’ available to the outside:

void HandleMouseHover(object sender, RoutedEventArgs rea)
{
  if (null != rea)
  {
    MenuKillerItem mki = rea.OriginalSource as MenuKillerItem;

    if (null != mki)
    {
      HoverToolTip = mki.RootToolTip;
    }
  }
}
public static readonly DependencyProperty HoverToolTipProperty =
    DependencyProperty.Register(
        "HoverToolTip",
        typeof(object),
        typeof(MenuKiller),
        new PropertyMetadata(null));

public object HoverToolTip
{
  get
  {
    return (object)GetValue(HoverToolTipProperty);
  }
  set
  {
    SetValue(HoverToolTipProperty, value);
  }
}

public override void OnApplyTemplate()
{
  base.OnApplyTemplate();
  AddHandler(MenuKillerItem.MouseHoverEvent,
             new RoutedEventHandler(HandleMouseHover));
}

As you can see, there is no template PART_ that actually displays the tool tip. This is simply because I wanted to allow rich content within the tool tip (thus it is of type object) and I believe this to be a common modification. Requiring the control user to re-template the control just to change the appearance (or worse, the position) of the tool tip seems annoying.

Finally adding the tool tip in the actual ‘users’ window markup is straightforward:


  
      
  
  

As you can see, I chose a simple TextBox, although the system allows me to use images, too. The Binding to the MenuKiller‘s DependencyProperty HoverToolTip does the job, the rest is merely a description of the desired appearance. The Margin makes the TextBox appear beneath the center.

Conclusion

The MenuKiller Control offers a very different way of user interaction. With the introduction of WPF, the creation of this control has been simplified while keeping it customizeable at the same time. The use of databinding even allows to decouple parts of the control completely (e.g. tool tip box), thereby overcoming the issue of retemplating which usually requires to replace the whole template instead of replacing only specific parts.

The development of this control has taken a lof of iterations. For me, this was highly educating and a lot of fun. I hope to have contributed a little to a new discussion and new thinking in user interface design, while also providing a resource on how to develop custom WPF controls.

Open Questions, Todos

This control can certainly be optimized in various ways. There is still a lot of work to do to make this control really nice. For example, ‘adaptive arrange’ is certainly necessary in some cases, furthermore, every modification to the arrangement could be animated for some eye candy. There is a lot to do terms of features and integration with Popups, for example.

What matter most, though, is experience in using this control in a real world application. Customer and end-user feedback are required to help making this control what it is supposed to be one day: A fast, flexible, intuitive and context sensitive control with a small amount of text and happy end-user.

Resources

In the making…

History

  • 2008-05-04 v. 1.0 Initial release on emphess.net

Post to Twitter Post to Delicious Post to Digg Post to Facebook

Related posts:

  1. The MenuKiller Control – Differentiated UX
  2. A MenuKiller Sample Application
  3. Applying ItemContainerStyle Recursively
  4. Custom DependencyProperties and “Auto”
  5. On UIElement.Opacity and the Removal of Storyboards

Tags: , , , ,

← Previous

Next →

8 Comments

  1. [...] A MenuKiller Control – this article is a work in progress detailing how to build a new paradigm for [...]

  2. Rich says:

    Good stuff, hope to see this project continued

  3. SelfishGene says:

    You know Infragistics has a similar DataCarousol control with tons of functionality?

  4. MenuKiller says:

    [...] on over to Christopher Menge’s blog and check out his implementation of the MenuKiller UX I wrote about some time [...]

  5. Gvozdin says:

    Hi Christoph.
    You did a great job.
    I too like MenuKiller idea.
    But i think MenuKiller control without popup integration worth no so much as the MenuKiller idea.
    At now i can think up only one real life application case, when current implementation can be applied, one big window with just one MenuKiller control.

    Hope you will continue working on your control and make it shine :)

  6. Filip says:

    Wow, this looks really awesome!
    Have you ever figured out how to get the DragMove() to work with it (when hosted in fully transparent window)?

  7. Hi Filip,
    unfortunately, no. I have been focusing on web development for a couple of years now, so I never looked back at the menu killer. I think there are some Win 8 screenshots showing such a control, but I haven’t seen a lot of buzz around it, so maybe I’m wrong.

  8. Henning says:

    Hello, this is already a bit dated, though I still like to know:

    Is this basically a pie menu?

    http://en.wikipedia.org/wiki/Pie_menu

    I’m not a coder and thus couldn’t implement your supplied code and I didn’t find any video or live demo – so i can not assess the exact asset of functionality.

Leave a Comment