Search code examples
c#user-interfacedialogwinui-3winui

WinUI 3 - How to set focus to a ContentDialog button


I have a WinUI 3 application written in C# which shows a ContentDialog to ask the user to confirm file deletions. There is a checkbox on the dialog which can be checked by the user to hide the dialog from being displayed in the future:

enter image description here

Although this works, I have a usability issue. The default behaviour for when a ContentDialog is opened is to set focus on the first interactive element, which is the checkbox. I don't want this. I want focus to be on the default button, which is the CloseButton. This is the safest non-destructive UI element.

Update: this is actually related to the inconsistent display of the focus highlight - the rounded rectangle around the current control. Opening the ContentDialog via a Button click does not show the focus rectangle, whereas a keyboard event like KeyUp or handling a KeyboardAccelerator results in the undesired behaviour of showing it. This looks like a WinUI 3 bug.

I'm aware of VisualTreeHelper.GetOpenPopupsForXamlRoot() and I've experimented with that in the dialog's Opened event handler, but navigating the subsequent hierarchy is not straightforward (FindName("CloseButton") does not work, for example), and I can't help thinking there's either a more direct way of accessing the button, or someone has written a helper to do the same.

I have also tried adding a GettingFocus event handler for the checkbox and doing args.TryCancel() if the dialog has just opened, but this actually ended up kicking the focus to the parent window, which is definitely not what I want!

To recreate this issue:

  1. Create a new project in Visual Studio. Choose "Blank App, Packaged (WinUI 3 in Desktop)" as the project template.

  2. Name the project "CheckboxTest".

  3. Replace the MainWindow.xaml with the XAML below:

<Window
    x:Class="CheckboxTest.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:CheckboxTest"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    Title="ContentDialog Focus Test">

    <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
        <ContentDialog
            x:Name="DeleteConfirmationDialog"
            Title="Delete file"
            PrimaryButtonText="Move to Recycle Bin"
            CloseButtonText="Cancel"
            DefaultButton="Close">
            <StackPanel VerticalAlignment="Stretch" HorizontalAlignment="Stretch" Spacing="12">
                <TextBlock TextWrapping="Wrap" Text="Are you sure you want to move file 'somefile.jpg' to the Recycle Bin?" />
                <CheckBox x:Name="DeleteDontAskCheckbox" Content="Don't ask me again" />
            </StackPanel>
        </ContentDialog>
    </StackPanel>
</Window>
  1. Replace the MainWindow.xaml.cs code with:
using System;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;

namespace CheckboxTest;

public sealed partial class MainWindow : Window
{
    public MainWindow()
    {
        this.InitializeComponent();
        this.Content.KeyUp += Content_KeyUp;
    }

    private async void Content_KeyUp(object sender, KeyRoutedEventArgs e)
    {
        if (e.Key == Windows.System.VirtualKey.Delete)
        {
            DeleteConfirmationDialog.XamlRoot = Content.XamlRoot;
            if (await DeleteConfirmationDialog.ShowAsync() == ContentDialogResult.Primary)
            {
                // Do delete operation...
            }
        }
    }
}
  1. Run the project.

  2. Press Delete and observe the confirmation dialog displayed has the checkbox focussed.


Solution

  • I see there is an answer that provides a simple way to keep DeleteDontAskCheckbox from taking the focus. In terms of the other requirement of accessing the buttons and setting focus to [Cancel], enumerating all of the controls might work where FindName won't.

    private IEnumerable<DependencyObject> Traverse(DependencyObject parent)
    {
        if (parent == null)
            yield break;
    
        yield return parent; 
        if (parent is Popup popup && popup.Child is DependencyObject popupContent)
        {
            foreach (var descendant in Traverse(popupContent))
            {
                yield return descendant;
            }
        }
        else
        {
            int childCount = VisualTreeHelper.GetChildrenCount(parent);
            for (int i = 0; i < childCount; i++)
            {
                var child = VisualTreeHelper.GetChild(parent, i);
                foreach (var descendant in Traverse(child))
                {
                    yield return descendant;
                }
            }
        }
    }
    

    You said:

    I'm aware of VisualTreeHelper.GetOpenPopupsForXamlRoot() and I've experimented with that in the dialog's Opened event handler, but navigating the subsequent hierarchy is not straightforward (FindName("CloseButton") does not work, for example), and I can't help thinking there's either a more direct way of accessing the button, or someone has written a helper to do the same.

    The usage of the enumerator, the (somewhat) "more direct way", would look something like this:

    var buttonCancel = Traverse(dialog)
        .OfType<Button>()
        .FirstOrDefault(_ => _.Content?.ToString() == "Cancel");
    

    Using, as you mentioned, the dialog's Opened event handler, I tested this as a proof of concept only. You may still have to play around with it a bit. I'll put a link to the code I used to test it if you want to make sure it works on your end.

    private void OnContentDialogOpened(ContentDialog sender, ContentDialogOpenedEventArgs args)
    {
        if (sender is ContentDialog dialog)
        {
            if( !dialog.DispatcherQueue.TryEnqueue(() => 
            {
                const string BUTTON_TO_FOCUS = "Cancel";
    
                // Find by text
                if (Traverse(dialog)
                    .OfType<Button>()
                    .FirstOrDefault(_ => _.Content?.ToString() == BUTTON_TO_FOCUS) is { } button)
                {
                    if (button.FocusState == FocusState.Unfocused)
                    {
                        button.Focus(FocusState.Programmatic);
                    }
                }
            }))
            {
                Debug.WriteLine("Failed to enqueue action.");
            }
        }
    }
    

    cancel button focused


    XAML with Opened event handler

    <Window
        x:Class="CheckboxTest.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="using:CheckboxTest"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        mc:Ignorable="d"
        Title="ContentDialog Focus Test">
    
        <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
            <ContentDialog
                x:Name="DeleteConfirmationDialog"
                Title="Delete file"
                PrimaryButtonText="Move to Recycle Bin"
                CloseButtonText="Cancel"
                DefaultButton="Close"
                Opened="OnContentDialogOpened">
                <StackPanel VerticalAlignment="Stretch" HorizontalAlignment="Stretch" Spacing="12">
                    <TextBlock TextWrapping="Wrap" Text="Are you sure you want to move file 'somefile.jpg' to the Recycle Bin?" />
                    <CheckBox x:Name="DeleteDontAskCheckbox" IsTabStop="False"  Content="Don't ask me again" />
                </StackPanel>
            </ContentDialog>
        </StackPanel>
    </Window>