A Dialog Service for WinUI 3

I this article we build an MVVM DialogService in a WinUI 3 Desktop application. It comes with the following features:

  • Message Dialog, Confirmation Dialog, Input Dialog
  • Works with {Binding} and {x:Bind}
  • Bindable to Command and Event Handler
  • Callable from View, ViewModel, Service and more
  • Theme-aware

Here’s a screenshot from the corresponding demo app:

The core class of the dialog service is based on the ModalView static class that we built long ago in a similar solution for UWP. It programmatically creates ContentDialog instances for which you can provide the title, and the content of all buttons. Here’s an extract of the original code:

var dialog = new ContentDialog
{
    Title = title,
    PrimaryButtonText = yesButtonText,
    SecondaryButtonText = noButtonText,
    CloseButtonText = cancelButtonText
};

When you run this code in a WinUI 3 Desktop app, nothing really happens. You get a “This element is already associated with a XamlRoot, it cannot be associated with a different one until it is removed from the previous XamlRoot.” exception:

The screenshot above comes from a click event handler. The app crashes and tell you what’s wrong, and that’s OK. However, when using a Command to open the dialog, the binding engine swallows the exception, and the app continues to run without the dialog opening. It’s not a bug -it’s what the binding engine does- but it smells like a bug, so people logged issues in WinUI and people logged issues in Prism.

When programmatically instantiating a ContentDialog in WinUI 3 -even in a View or Page- you need to provide a XamlRoot. We decided to transform our static methods into extension methods for FrameworkElement. Not only does this class come with a XamlRoot property, it also has a RequestedTheme that we can pass to ensure that the dialog follows the app’s theme. Here’s the set of methods to open a message box – a content dialog with a title, a piece of text (the message), and one single button (the classic OK button). It returns no result.

public static async Task MessageDialogAsync(
     this FrameworkElement element, 
     string title, 
     string message)
{
    await MessageDialogAsync(element, title, message, "OK");
}

public static async Task MessageDialogAsync(this FrameworkElement element, string title, string message, string buttonText)
{
    var dialog = new ContentDialog
    {
        Title = title,
        Content = message,
        CloseButtonText = buttonText,
        XamlRoot = element.XamlRoot,
        RequestedTheme = element.ActualTheme
    };

    await dialog.ShowAsync();
}

For each of the dialog types, the call to the extension method in the ModalView class is more or less the same. Only the return type is different: void, bool, nullable bool, string, …

For the first test, we create a button in the View, and call a classic event handler:

<Button Content="Message Dialog"
        Click="MessageBox_Click" />

In the event handler, we use the page itself (this) as the Framework element to pass. Here’s the call:

private async void MessageBox_Click(object sender, RoutedEventArgs e)
{
    await this.MessageDialogAsync("All we are saying:", "Give peace a chance.", "Got it");
}

Here’s how the result looks like:

Our second dialog type is the ConfirmationDialog, with a title and two (Yes/No) or three (Yes/No/Cancel) buttons. Here’s the main extension method for this one:

public static async Task<bool?> ConfirmationDialogAsync(
     this FrameworkElement element, 
     string title, 
     string yesButtonText, 
     string noButtonText, 
     string cancelButtonText)
{
    var dialog = new ContentDialog
    {
        Title = title,
        PrimaryButtonText = yesButtonText,
        SecondaryButtonText = noButtonText,
        CloseButtonText = cancelButtonText,
        XamlRoot = element.XamlRoot,
        RequestedTheme = element.ActualTheme
    };
    var result = await dialog.ShowAsync();

    if (result == ContentDialogResult.None)
    {
        return null;
    }

    return (result == ContentDialogResult.Primary);
}

To test it, we added a ViewModel, set it as DataContext to the View, and added a button:

<Button Content="2-Button Confirmation Dialog"
        Command="{Binding ConfirmationCommandYesNo}" />

Our next step was finding an appropriate Framework element to pass – not easy in a ViewModel that is unaware of the View it’s bound to. In UWP we could use Window.Current (and its Content). In WinUI 3 Window.Current is still in the API but always returns null. As an alternative we declared a MainRoot property in our application class. It refers to the Content element of the main window of our app, that we traditionally named ‘Shell’. Here’s the declaration and initialization:

public partial class App : Application
{
    private Shell shell;

    public static FrameworkElement MainRoot { get; private set; }

    protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
    {
        shell = new Shell();
        shell.Activate();
        MainRoot = shell.Content as FrameworkElement;
    }
}

When you’re building a multi-window app, you will probably need to change that logic. Anyway, all ViewModels and Services now have access to a Framework element to pass to the dialog service. Here’s how the ViewModel from our sample app opens a 2-Button Confirmation Dialog via an AsyncRelayCommand:

public ICommand ConfirmationCommandYesNo => new AsyncRelayCommand(ConfirmationYesNo_Executed);

private async Task ConfirmationYesNo_Executed()
{
    var confirmed = await App.MainRoot.ConfirmationDialogAsync(
            "What Pantone color do you prefer?",
            "Freedom Blue",
            "Energizing Yellow"
        );
}

This is how the dialog looks like:

The overload (extension) method with three buttons, opens the dialog that you saw in the first screenshot. There’s no need to paste the code here, it’s similar to the previous one.

Let’s jump to another dialog type: an Input Dialog to request a string from the user. In this scenario, we programmatically set a TextBox as Content of the Dialog, and return its text when the Dialog closes:

public static async Task<string> InputStringDialogAsync(
     this FrameworkElement element, 
     string title, 
     string defaultText, 
     string okButtonText, 
     string cancelButtonText)
{
    var inputTextBox = new TextBox
    {
        AcceptsReturn = false,
        Height = 32,
        Text = defaultText,
        SelectionStart = defaultText.Length
    };
    var dialog = new ContentDialog
    {
        Content = inputTextBox,
        Title = title,
        IsSecondaryButtonEnabled = true,
        PrimaryButtonText = okButtonText,
        SecondaryButtonText = cancelButtonText,
        XamlRoot = element.XamlRoot,
        RequestedTheme = element.ActualTheme
    };

    if (await dialog.ShowAsync() == ContentDialogResult.Primary)
    {
        return inputTextBox.Text;
    }
    else
    {
        return string.Empty;
    }
}

This time we use {x:Bind} to a command in the View:

<Button Content="String Input Dialog"
        Command="{x:Bind ViewModel.InputStringCommand}" />

Here’s the code in the ViewModel:

public ICommand InputStringCommand => new AsyncRelayCommand(InputString_Executed);

private async Task InputString_Executed()
{
    var inputString = await App.MainRoot.InputStringDialogAsync(
            "How can we help you?",
            "I need ammunition, not a ride.",
            "OK",
            "Forget it"
        );
}

And the result:

The last type of input dialog in this article, is a multi-line text input dialog – typically one that you would use to collect comments or remarks:

public static async Task<string> InputTextDialogAsync(
     this FrameworkElement element, 
     string title, 
     string defaultText)
{
    var inputTextBox = new TextBox
    {
        AcceptsReturn = true,
        Height = 32 * 6,
        Text = defaultText,
        TextWrapping = TextWrapping.Wrap,
        SelectionStart = defaultText.Length
    };
    var dialog = new ContentDialog
    {
        Content = inputTextBox,
        Title = title,
        IsSecondaryButtonEnabled = true,
        PrimaryButtonText = "Ok",
        SecondaryButtonText = "Cancel",
        XamlRoot = element.XamlRoot,
        RequestedTheme = element.ActualTheme
    };

    if (await dialog.ShowAsync() == ContentDialogResult.Primary)
    {
        return inputTextBox.Text;
    }
    else
    {
        return string.Empty;
    }
}

For the sake of completeness, we’ll bind it to an event handler in the ViewModel:

<Button Content="Text Input Dialog"
        Click="{x:Bind ViewModel.InputText_Click}" />

Here’s the code in the ViewModel:

public async void InputText_Click(object sender, RoutedEventArgs e)
{
    var inputText = await App.MainRoot.InputTextDialogAsync(
            "What would Faramir say?",
            "“War must be, while we defend our lives against a destroyer who would devour all; but I do not love the bright sword for its sharpness, nor the arrow for its swiftness, nor the warrior for his glory. I love only that which they defend.”\n\nJ.R.R. Tolkien"
        );
}

And this is what it looks like at runtime:

With just a handful lines of code, we built a dialog service for WinUI 3 Desktop applications. Our sample solution lives here in GitHub. Feel free to add your own dialogs, like for numeric or date input.

Enjoy!

Leave a comment