Monday, 30 April 2007

“Default” buttons in WPF and multiple default buttons per page

Most user interface frameworks have the concept of a default button. The default button is the button which is activated when the Enter key is pressed. This is an important aspect for usability. For example, on a search form, most users expect to be able to perform a search by pressing Enter on any form element. If this does not happen as expected, the user gets frustrated as they need to either tab onto the button and press Enter, or use the mouse to click. I think the web has pushed this standard as HTML forms automatically post themselves when the user presses Enter within them. WPF offers the ability to have both Default and Cancel buttons. The Cancel button is activated by pressing the ESC key, although in my opinion this is not quite as common as the Enter button as most users don’t expect to be able to press ESC within forms – only dialog windows maybe. How do default buttons work? There is nothing particularly special going on under the covers when you set a button to be the default button. When the IsDefault property is set, AccessKeyManager.Register is called, passing in “\r” as the access key. Similarly, the act of setting IsCancel calls AccessKeyManager.Register passing in the character code for ESC - “\x001b”. The only special thing that setting IsDefault will do is ensure that the IsDefaulted property is updated correctly. This allows, for example, buttons to be styled differently if they are defaulted. How do I get multiple default buttons per page? In ASP.NET a common problem was that because you are forced to have a single server-side form, it was not possible to have multiple logical forms on the page and have the Enter button behave as expected without a lot of hacking. For example, you might want the template for all your pages to have a small search form at the top with a search button, and also have the search performed when the user presses enter within the search box. When the user is on a page with its own form on it, you would want that form to be submitted when the user pressed enter. This was not possible natively. This issue was solved in ASP.NET2 by allowing any Panel or Table to have set a DefaultButton property. Behind the scenes this would set up an event handler in JavaScript which would activate the button when enter was pressed. In WPF, there is no such property. If you define two panels on a page, and into each panel place a TextBox and a Button with IsDefault="True", you will see that pressing enter in either TextBox always activates the first button. This is because that button registered itself first so is at the top of the invocation list of the Enter access key:

<Window x:Class="WindowsApplication7.Window1"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:WindowsApplication7"
    Title="WindowsApplication7" Height="300" Width="300"
    >
    <StackPanel>
        <StackPanel Margin="5" Background="Yellow">
            <TextBox Margin="5" />
            <Button Margin="5" IsDefault="True" Content="Yellow" />
        </StackPanel>
        <StackPanel Margin="5" Background="Green">
            <TextBox Margin="5" />
            <Button Margin="5" IsDefault="True" Content="Green" />
        </StackPanel>
    </StackPanel>
</Window>
In the above example, no matter which TextBox has focus, the Yellow button is always activated when you press enter. To solve this problem, there is a fairly undocumented feature within the AccessKeyManager called scoping. This allows you to define a scope for an access key such that it is only activated within that scope. The way this is done is by handling the routed event and adding the scope to the event args. Then when it returns to the AccessKeyManager, it detects that it has been scoped and will only apply it to buttons within that scope. The code is quite simple to write, but I have created a small helper class to encourage readibility and code reuse. In WPF it’s so easy to write small helper classes and seamlessly integrate via the use of attached properties, so I made an attached property called AccessKeyScoper.IsAccessKeyScope which can be applied to any element. With this, the above example simply needs to be modified as follows, and the correct button will be activated:
<StackPanel Margin="5" Background="Yellow" local:AccessKeyScoper.IsAccessKeyScope="True">
    <TextBox Margin="5" />
    <Button Margin="5" IsDefault="True" Content="Yellow" />
</StackPanel>
<StackPanel Margin="5" Background="Green" local:AccessKeyScoper.IsAccessKeyScope="True">
    <TextBox Margin="5" />
    <Button Margin="5" IsDefault="True" Content="Green" />
</StackPanel>
The code for the helper class is here:
using System;
using System.Windows;
using System.Windows.Input;
namespace WindowsApplication7
{
    /// <summary>
    ///    Class used to manage generic scoping of access keys
    /// </summary>
    public static class AccessKeyScoper
    {
        /// <summary>
        ///    Identifies the IsAccessKeyScope attached dependency property
        /// </summary>
        public static readonly DependencyProperty IsAccessKeyScopeProperty =
            DependencyProperty.RegisterAttached("IsAccessKeyScope", typeof(bool), typeof(AccessKeyScoper), new PropertyMetadata(false, HandleIsAccessKeyScopePropertyChanged));
        /// <summary>
        ///    Sets the IsAccessKeyScope attached property value for the specified object
        /// </summary>
        /// <param name="obj">The object to retrieve the value for</param>
        /// <param name="value">Whether the object is an access key scope</param>
        public static void SetIsAccessKeyScope(DependencyObject obj, bool value)
        {
            obj.SetValue(AccessKeyScoper.IsAccessKeyScopeProperty, value);
        }
        /// <summary>
        ///    Gets the value of the IsAccessKeyScope attached property for the specified object
        /// </summary>
        /// <param name="obj">The object to retrieve the value for</param>
        /// <returns>The value of IsAccessKeyScope attached property for the specified object</returns>
        public static bool GetIsAccessKeyScope(DependencyObject obj)
        {
            return (bool) obj.GetValue(AccessKeyScoper.IsAccessKeyScopeProperty);
        }
        private static void HandleIsAccessKeyScopePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            if (e.NewValue.Equals(true))
            {
                AccessKeyManager.AddAccessKeyPressedHandler(d, HandleScopedElementAccessKeyPressed);
            }
            else
            {
                AccessKeyManager.RemoveAccessKeyPressedHandler(d, HandleScopedElementAccessKeyPressed);
            }
        }
        private static void HandleScopedElementAccessKeyPressed(object sender, AccessKeyPressedEventArgs e)
        {
            if (!Keyboard.IsKeyDown(Key.LeftAlt) && !Keyboard.IsKeyDown(Key.RightAlt) && GetIsAccessKeyScope((DependencyObject)sender))
            {
                e.Scope = sender;
                e.Handled = true;
            }
        }
    }
}

28 comments:

Anonymous said...

I have come across a situation where I am in need of your implementation of the AccessKeyScoper. When I build my solution, I am getting the following error. Does this look familiar to you?

'AccessKeyScoper.IsAccessKeyScope' property is read-only and cannot be set from markup.

Thanks!

-Jeremy

Neil Mosafi said...

Thanks for the feedback Jeremy. I haven't seen that before when using the scoper. Can I ask which version of WPF you are using - 3.5 or 2.0? There may have been some changes in 3.5 which have broken this.

A couple of things to try:
- Change the PropertyMetadata to FrameworkPropertyMetadata
- Try setting the property from code (not ideal I know, but just to see if it works)

I will look into this later and post back any findings

Neil Mosafi said...

Hi Jeremy, I have tried with VS2008 and .NET3.5 and I cannot reproduce the problem. Can you send me some more details please to help me reproduce it?

Thanks
Neil

Paul said...

Thanks for the article. Exactly what I was looking for, however I seem to have some issues (probably my understanding!).

I have a user control with a default button. Clicking the default button creates another user control which overlays on top of the previous one. This also has a default button. I've tried to scope the button and the handler seems to attach ok. The problem is, the new user control with the default button never has its click event fired when pressing enter. The underlying button always gets the click event. I'm not sure what I'm doing wrong, but have you tried your code on SP1 Beta of .NET 3.5?

Thanks!

Paul

Neil Mosafi said...

HI Paul, not tried it in the SP1 beta yet. So does the new user control have any focusable elements, such as a TextBox? When you create the new user control, do you assign keyboard focus to an element within the new user control?

Paul said...

Hi Neil,

No, I didn't set focus to any control within the new user control. I did set focus to the user control itself, but that didn't seem to make any difference.

The new user control is a simulated MessageBox that overlays itself on top of the other windows. It also has a partially transparent 'mask' that blocks click events so it feels like the MessageBox is modal. However, when the message box displays, the underlying button is activated when pressing enter, not the button on the message box that also has the IsDefault property set to true.

Thanks for your reply :)

Paul

Paul said...

Oh sorry, forgot to reply to all of your questions!

No, I don't explicitly set keyboard focus. I wasn't aware that I needed to - but I've just found an MSDN article that talks about keyboard and logical focus as two separate things. Maybe that's my issue..?

Paul

Neil Mosafi said...

Hello Paul

OK so that is going to be the issue. You definitely need to set focus on an element within your "message box" when it displays. I would probably also make the message box its own focus scope, like:

<UserControl FocusManager.IsFocusScope="true" ...

You can then in code, when the message box is displayed, do either

Keyboard.Focus(okButton)

or

FocusManager.SetFocusedElement(okButton)

I think either would work, but not sure offhand

Cheers
Neil

Paul said...

Hi Neil,

If I explicitly set the keyboard focus to a button on the overlayed user control, it works fine. However, there's no point having a default button if the button's already got focus :) Anyway, I tried setting keyboard focus to the user control and it won't 'stick' !! Also, in the instance that I set the focus (including logical focus with a focus scope) to the user control, the default button just doesn't work and fire the click event.

It's all very weird!!

Cheers
Paul

Paul said...

Right, sorry to burden you with my problems Neil, but you're the only guy that seems to know anything about this black art!!

I've got the overlay control (MessageDialog) getting logical focus and keyboard focus. I was stupidly trying to set focus in the custom Show() method before I set the visibility of the control to Visible!

So, it's got focus. If I use your code to set the AccessKeyScope to the button on the message dialog, the underlying button still for some reason handles the enter key but my own control doesn't (interestingly my button does not have the default button look either).

If I switch off the AccessKeyScope, the control is displayed with the button looking like a default button but when I press enter, the button loses the default look, it doesn't fire and the underlying button gets the focus which when you press enter again, another overlay control appears.

In the case with the AccessKeyScope set to true, the handler does indeed fire and I am setting it to handled, but the click event never also gets fired thus the control continues to display (rather than being closed).

Cheers
Paul

Neil Mosafi said...

Hello Paul. Am happy to help you resolve your issues, but I think I'd need to see some code. Please email me at nmosafi at gmail and explain what you are trying to do and i'll see if I can help

Paul said...

Hi Neil,

Have sent you a zip file. Not sure whether it got through as the first zip file was blocked due to it containing an executable.

Cheers
Paul

Anonymous said...

Hi There,
I am facing a problem not exactly related to the class mentioned here.
I have a tab control in which there are 8 tab items.
I need to hide last tab item out of 8 and make it visible and accessible only when user presses Alt+C.
If the user presses Alt+C again, the tab item must hide again.
Can you help me in this?

Andrew Jones said...

Neil,

Great article. I have a grid of textboxes and buttons. All the text boxes are in column 1 and the buttons are in column 2. I would like to set a default button for each text box. Because the textbox and button do not have a shared parent I cannot use your technique. Do you have any ideas for solving this?

Neil Mosafi said...

Not really.. the whole point of this is that default buttons need to be in the same container as the input controls which activate them.

Can I suggest changing your layout to put the textbox and button in the same container. Perhaps using nested grid with SharedSizeScope or something? Alternatively you'll have to manually handle the AccessKey events and trigger the button yourself

buy wow gold said...
This comment has been removed by a blog administrator.
Josh said...

I know I'm replying to an old post here but this is what I did in order to have more control over multiple buttons with isdefault/iscancel.

[Panel]

[Panel Name="InputTray"]

[Button Content="Run"
IsDefault="{Binding IsKeyboardFocusWithin, ElementName=InputTray}" /]

[Button Content="Clear"
IsCancel="{Binding IsKeyboardFocusWithin, ElementName=InputTray}" /]

[TextBox /]

[/Panel]

[TextBox /]

[/Panel]

Neil Mosafi said...

That looks like it would probably work quite well Josh, nice!

Ashish Mundra said...

Excellent Josh.. many thanks! Saved me lot of time.

Thom Carver said...

Awesome helper class, cheers very much!

Josh said...

Thanks for posting this, it just fixed my problem.

I was trying to get the 'Enter' key to execute a command when a particular Panel instance had focus. Until implementing your solution the element that kept coming through was the default Button, now it's finally the Panel.

By the way, why the check to ensure that the Alt key is not pressed?

Robert C said...

Excellent post. I used your code and it worked like a charm. Thanks.

Unknown said...

Brilliant!

Anonymous said...

Thanks!

Anonymous said...

Thanks a lot! Your solution helped me out.

Unknown said...

Hi Neil,

Under what license is this source code?

Neil Mosafi said...

Its under the "do whatever you want with it" licence

Anonymous said...

Brilliant. Works like a charm. Thanks for posting.