Sunday, October 29, 2006

DropDownButtons in WPF

Whilst Windows Presentation Foundation (WPF) provides good support for menus, one omission is a drop-down button. By this I mean a button, that when clicked will result in a menu dropping down from the control.

So I started to write my own. Easy I thought, just style a MenuItem to look like a button. Well, this doesn't work as the MenuItem control will only work if it is a child of a Menu control. I could just wrap all my DropDownButtons in Menus, but that becomes a little messy wrapping everything in extra menus. I tried a number of other approaches, none of which worked as I would like.


This was several months ago. Recently however I was informed of a couple of related blog articles at Sheva's Techspace and Lester's piece on WPF. In particular I liked Lester's approach. This is to use the Button's ContextMenu to apply add a context menu, and then to use code to open this on a left mouse click. A simple solution that works well. But what if I wanted to have a real context-menu too?


Solution: Well, in fact ContextMenu doesn't need to be attached to a control to work. All you need to do is create a new ContextMenu object, add your MenuItems, and set IsOpen to true. So I put together the control as below.



public class DropDownButton : ToggleButton
{
// *** Dependency Properties ***


public static readonly DependencyProperty DropDownProperty = DependencyProperty.Register("DropDown", typeof(ContextMenu), typeof(DropDownButton), new UIPropertyMetadata(null));

// *** Constructors ***

public DropDownButton()
{
// Bind the ToogleButton.IsChecked property to the drop-down's IsOpen property

Binding binding = new Binding("DropDown.IsOpen");
binding.Source = this;
this.SetBinding(IsCheckedProperty, binding);
}

// *** Properties ***

public ContextMenu DropDown
{
get
{
return (ContextMenu)GetValue(DropDownProperty);
}
set
{
SetValue(DropDownProperty, value);
}
}

// *** Overridden Methods ***

protected override void OnClick()
{
if (DropDown != null)
{
// If there is a drop-down assigned to this button, then position and display it

DropDown.PlacementTarget = this;
DropDown.Placement = PlacementMode.Bottom;

DropDown.IsOpen = true;
}
}
}


This control derives directly from ToggleButton so we inherit the full behavior and styling of a button. We then provide a DropDown dependency property that takes a ContextMenu to show for the drop-down. In the OnClick event we simply position the menu under the control, and open it by setting IsOpen to true. Finally to tidy up, we bind the ToggleButton's IsChecked property to the drop-down menu's IsOpen property to ensure that these are synchronized.


To use this in your own code you simply use,



<ctrl:DropDownButton Content="Drop-Down">
<ctrl:DropDownButton.DropDown>
<ContextMenu>
<MenuItem Header="Item 1"/>
<MenuItem Header="Item 2"/>
<MenuItem Header="Item 3"/>
</ContextMenu>
</ctrl:DropDownButton.DropDown>
</ctrl:DropDownButton>



Technorati tags: , ,

17 comments:

JChrisman said...

For those who don't know where to find ToggleButton, you will need to add a using statement like the one below:

using System.Windows.Controls.Primitives;

Unknown said...

Thanks for that Joe - I'd forgotten to mention the namespaces.

All together you need,

using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Data;

Don't think I've missed any there.

PBJJ said...

I'm doing this in one of the MenuItem's PreviewMouseLeftButtonUp event:

Window w = new Window();
w.Show();

The new window appears, then goes behind its creator. I think it's related to DropDownButton collapsing and forcing the active window to change. Any ideas on how to get around this?

Unknown said...

pbjj,

The problem you are having is that the MenuItem is handling the MouseLeftButtonUp event itself. In its handler it then grabs back the focus. There are three general methods that I can think of to solve this.

(1) The first thought I have is why are you handling PreviewMouseButtonUp and not the Click event? If you handled the Click event instead then you avoid problems with focus, and also enable other goodness such as the ability to select the menu item with the keyboard.

This is probably the easiest and most reliable solution, however I will assume that you are handling the PreviewMouseButtonUp event for some special reason. Therefore my other suggestions are,

(2) Make the MenuItem non-focusable. Then it cannot grab back the focus,

MenuItem Focusable="False"

(3) Mark the MouseButtonUp event as handled,

private void MenuItem_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
Window w = new Window();
w.Show();
e.handled = true;
}

Let me know if any of these options solves your problem, or if not do you have any more details on why you are using PreviewMouseLeftButtonUp rather than Click?

Andy

Huy Pham said...

This is my version of this control (base on your toggle button idea).
Thank you, excite idea Andy.

http://huydinhpham.blogspot.com/2008/09/wpf-drop-down-and-split-button.html

Anonymous said...

thanks for your post
Is there any way in which I can alter the behaviour of the context menu that it opens on the right side of the button instead of the bottom?

Anonymous said...

thanks for the post. I have tried oder .But this one i like most. simply done :))

fatema :)

Unknown said...

David,

If you wish to alter the position for the popup then then you need to look at the PlacementMode enumeration. To achieve what you are looking for it should be as simple as changing...

DropDown.Placement = PlacementMode.Bottom;

... to ...

DropDown.Placement = PlacementMode.Right;

Andy

Unknown said...

Fatema,

Pleased you liked the post and found it useful.

Andy

Kevin Berridge said...

Very helpful thanks!

I just wanted to point out that because you setup the Binding on IsChecked, you don't need to override the OnClick. The Binding takes care of opening the ContextMenu for you.

To setup the placement info, you can just add a PropertyChnagedCallback to your DropDownProperty and set the PlacementTarget there. Like this:

public static readonly DependencyProperty DropDownProperty =
DependencyProperty.Register( "DropDown", typeof( ContextMenu ), typeof( DropDownButton ),
new FrameworkPropertyMetadata( DropDownPropertyChangedCallback ) );

static void DropDownPropertyChangedCallback( DependencyObject d, DependencyPropertyChangedEventArgs e )
{
( (DropDownButton)d ).DropDownPropertyChangedCallback( e );
}

void DropDownPropertyChangedCallback( DependencyPropertyChangedEventArgs e )
{
if ( DropDown != null )
{
DropDown.PlacementTarget = this;
DropDown.Placement = PlacementMode.Bottom;
}
}

Thanks again!

Unknown said...

Kevin,

Thanks for the comment - sounds good to me.

Andy

Unknown said...

I'm pretty sure you can accomplish the same thing just using the Button's content property.
It looks like I have to leave the tags off, but here's the tagless XAML:

Button
Menu
MenuItem Header="MyButton"
MenuItem Header="Choice1" /
MenuItem Header="Choice2" /
MenuItem Header="Choice3" /
/MenuItem
/Menu
/Button

alex said...

In the OnClick() handler you should also call 'base.OnClick()' to enable users catch Click event.

Unknown said...

I actually did the exact same thing you did without even knowing it but i came upon a bug.

The ContextMenu's Width was bounded to the ToggleButton's ActualWidth and apparently if you open the ContextMenu and then increase the width, next time it will only half render the ContexMenu.

Did you come across it? Know anything about it?

http://stackoverflow.com/questions/1184706/changing-context-menus-width-results-in-poor-rendering

Anonymous said...

How can i achieve that when the drop down is open a click closes the menu again? Currently when i click it closes and opens again....

Shemesh said...

NICE!!
based on your work i came up with some minor changes:
an answer to the question above me, simplification of code, and refined placement of code.

public class DropDownButton : ToggleButton
{
public enum Placement { Bottom, Right }
public Placement DropDownPlacement { private get; set; }

#region DropDown (DependencyProperty)

public ContextMenu DropDown
{
get { return (ContextMenu)GetValue(DropDownProperty); }
set { SetValue(DropDownProperty, value); }
}
public static readonly DependencyProperty DropDownProperty =
DependencyProperty.Register("DropDown", typeof(ContextMenu), typeof(DropDownButton),
new PropertyMetadata(null, OnDropDownChanged)
);

private static void OnDropDownChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
{
((DropDownButton)sender).OnDropDownChanged(e);
}

void OnDropDownChanged(DependencyPropertyChangedEventArgs e)
{
if (DropDown != null)
{
DropDown.PlacementTarget = this;

switch (DropDownPlacement)
{
default:
case Placement.Bottom:
DropDown.Placement = PlacementMode.Bottom;
break;
case Placement.Right:
DropDown.Placement = PlacementMode.Right;
break;
}

this.Checked += new RoutedEventHandler((a, b) => { DropDown.IsOpen = true; });
this.Unchecked += new RoutedEventHandler((a, b) => { DropDown.IsOpen = false; });
DropDown.Closed += new RoutedEventHandler((a, b) => { this.IsChecked = false; });
}
}

#endregion

}

Unknown said...

Great comment Shemesh. Pleased that my code was useful and thanks for sharing your adapted code with the community.