Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

IMessageDialog & IContentDialog Services #16234

Open
robloo opened this issue Jul 4, 2024 · 5 comments
Open

IMessageDialog & IContentDialog Services #16234

robloo opened this issue Jul 4, 2024 · 5 comments
Labels
api API addition enhancement

Comments

@robloo
Copy link
Contributor

robloo commented Jul 4, 2024

Is your feature request related to a problem? Please describe.

We've had MessageBox available in Windows apps since WinForms. This was carried over to WPF. Then in WinRT this was transitioned to a more general-purpose (albeit quickly deprecated) MessageDialog. Finally in WinUI we have the ContentDialog which is more powerful but also not quite as fast to use.

Today in Avalonia we have no similar concept. I think this if for a few reasons including lack of time, already-available third party libraries and also a question of what to do in cross-platform scenarios: Do we just want an overlay in the current Window rather than a new Window just for the dialog?

Describe the solution you'd like

I think we are at the point where some of these ideas can start coming into Avalonia; however, with a twist. I would like to see it done as a service. Here is why:

  1. A service means Avalonia still doesn't need to provide implementations at this point. It's simply standardizing an API third-party libraries can implement.
  2. We can defer the questions of what to do in all cross-platform situations for the future (personally, I would go with the WinUI overlays for everything rather than separate dialog windows). However, we can implement these dialogs in a platform-native or specific way as well (like IStorageProvider).
  3. As a service, it can be used directly in ViewModels without breaking MVVM patterns. In real-world apps it's quite common for user confirmation to be needed when running the view logic at some point.

But not just one service, actually two like in UWP: one for simple/fast text-only message dialogs and another for hosting general content (think TextBoxes to set a name, etc.) in dialogs with fully-customizable buttons (up to 3 total).

IMessageDialog is heavily based on the most used methods of MessageBox and this service is the easiest to understand. IContentDialog is based on the ideas of the ContentDialog in WinUI; however, it transforms the API into one that matches MessageBox. This is needed as in WinUI you have to create the ContentDialog instance, set all the properties, then show it -- there are no single methods to do this which makes it much less useful as a service. This API transform does loose some of the customization and functionality of the WinUI ContentDialog.

Also note that I'm using the more modern terminology decided in WinUI. Here are the full services proposed (expand each section).

IMessageDialog
    /// <summary>
    /// Specifies the return value of a message dialog (which button was pressed by the user).
    /// </summary>
    public enum MessageDialogResult
    {
        /// <summary>
        /// The dialog returns no result.
        /// Either no button was pressed or the user pressed cancel.
        /// </summary>
        None = 0,

        /// <summary>
        /// The OK button was pressed by the user.
        /// </summary>
        OK,

        /// <summary>
        /// The Cancel button was pressed by the user.
        /// </summary>
        Cancel,

        /// <summary>
        /// The Yes button was pressed by the user.
        /// </summary>
        Yes,

        /// <summary>
        /// The No button was pressed by the user.
        /// </summary>
        No
    }

    /// <summary>
    /// Specifies the buttons displayed on a message dialog.
    /// </summary>
    public enum MessageDialogButton
    {
        /// <summary>
        /// Display only an OK button.
        /// </summary>
        OK = 0,

        /// <summary>
        /// Display OK and Cancel buttons.
        /// </summary>
        OKCancel,

        /// <summary>
        /// Display Yes and No buttons.
        /// </summary>
        YesNo,

        /// <summary>
        /// Display Yes, No and Cancel buttons.
        /// </summary>
        YesNoCancel
    }

    /// <summary>
    /// Defines a contract for displaying simple messages with defined buttons to the user.
    /// </summary>
    public interface IMessageDialog
    {
        /// <summary>
        /// Displays a default message dialog with an OK button.
        /// </summary>
        /// <param name="message">The message text to display.</param>
        /// <returns>A value that specifies which button was pressed by the user.</returns>
        Task<MessageDialogResult> ShowAsync(string message);

        /// <summary>
        /// Displays a message dialog with a title and an OK button.
        /// </summary>
        /// <param name="message">The message text to display.</param>
        /// <param name="title">The title bar caption to display above the message.</param>
        /// <returns>A value that specifies which button was pressed by the user.</returns>
        Task<MessageDialogResult> ShowAsync(string message, string title);

        /// <summary>
        /// Displays a message dialog with a title and defined buttons.
        /// </summary>
        /// <param name="message">The message text to display.</param>
        /// <param name="title">The title bar caption to display above the message.</param>
        /// <param name="buttons">A value that specifies which button or buttons to display.</param>
        /// <returns>A value that specifies which button was pressed by the user.</returns>
        Task<MessageDialogResult> ShowAsync(
            string message,
            string title,
            MessageDialogButton buttons);
    }
IContentDialog
    /// <summary>
    /// Specifies the return value of a content dialog (which button was pressed by the user).
    /// </summary>
    public enum ContentDialogResult
    {
        /// <summary>
        /// The dialog returns no result.
        /// Either no button was pressed or the user pressed cancel.
        /// </summary>
        None = 0,

        /// <summary>
        /// The Primary button was pressed by the user.
        /// </summary>
        Primary,

        /// <summary>
        /// The Secondary button was pressed by the user.
        /// </summary>
        Secondary
    }

    /// <summary>
    /// Specifies a button in a content dialog.
    /// </summary>
    public enum ContentDialogButton
    {
        /// <summary>
        /// No button is specified.
        /// </summary>
        None = 0,

        /// <summary>
        /// The Primary button.
        /// </summary>
        Primary,

        /// <summary>
        /// The Secondary button.
        /// </summary>
        Secondary,

        /// <summary>
        /// The Close button.
        /// </summary>
        Close
    }

    /// <summary>
    /// Defines a contract for displaying arbitrary content with customizable buttons to the user.
    /// </summary>
    public interface IContentDialog
    {
        /// <summary>
        /// Displays a default content dialog with a single close button.
        /// </summary>
        /// <param name="content">The content to display.</param>
        /// <param name="closeButtonText">The close button text.</param>
        /// <returns>A value that specifies which button was pressed by the user.</returns>
        Task<ContentDialogResult> ShowAsync(
            object content,
            string closeButtonText);

        /// <summary>
        /// Displays a content dialog with a title and a single close button.
        /// </summary>
        /// <param name="content">The content to display.</param>
        /// <param name="title">The title bar caption to display above the content.</param>
        /// <param name="closeButtonText">The close button text.</param>
        /// <returns>A value that specifies which button was pressed by the user.</returns>
        Task<ContentDialogResult> ShowAsync(
            object content,
            string title,
            string closeButtonText);

        /// <summary>
        /// Displays a content dialog with a title, a primary button and a close button.
        /// </summary>
        /// <param name="content">The content to display.</param>
        /// <param name="title">The title bar caption to display above the content.</param>
        /// <param name="closeButtonText">The close button text.</param>
        /// <param name="primaryButtonText">The primary button text.</param>
        /// <param name="defaultButton">Specifies which button is the default action to invoke.</param>
        /// <returns>A value that specifies which button was pressed by the user.</returns>
        Task<ContentDialogResult> ShowAsync(
            object content,
            string title,
            string closeButtonText,
            string primaryButtonText,
            ContentDialogButton defaultButton = ContentDialogButton.Close);

        /// <summary>
        /// Displays a content dialog with a title, a primary button, a secondary button and a close button.
        /// </summary>
        /// <param name="content">The content to display.</param>
        /// <param name="title">The title bar caption to display above the content.</param>
        /// <param name="closeButtonText">The close button text.</param>
        /// <param name="primaryButtonText">The primary button text.</param>
        /// <param name="secondaryButtonText">The secondary button text.</param>
        /// <param name="defaultButton">Specifies which button is the default action to invoke.</param>
        /// <returns>A value that specifies which button was pressed by the user.</returns>
        Task<ContentDialogResult> ShowAsync(
            object content,
            string title,
            string closeButtonText,
            string primaryButtonText,
            string secondaryButtonText,
            ContentDialogButton defaultButton = ContentDialogButton.Close);
    }

Describe alternatives you've considered

We could continue on using 3rd party solutions for everything.

Additional context

  • There is an open question if IContentDialog should be further extended to allow general object content for the buttons rather than only string text. This would extend the API past that in WinUI to allow things like images next to the text, etc.
  • The APIs are designed fully async. Maybe synchronous versions are wanted too. If so we might want to separate interfaces to say IMessageDialog and IMessageDialogAsync where IMessageDialog would be the blocking synchronous implementation.
  • Usefulness of these services heavily depends on the ability to swap out implementations. We need a way of registering and overriding services built-in to Avalonia. This was discussed separately.
  • Relates to Native MessageBox API #670 and PR Add Messagebox #3542
  • This is already a system used in a few production apps and seems to work quite well. I'm sharing it here as I think it would be generally useful.
@stevemonaco
Copy link
Contributor

As a service, it can be used directly in ViewModels without breaking MVVM patterns.

I disagree here. This interface would be directly coupled to (owned by) Avalonia. As such, it's not MVVM pure to use in a ViewModel because then your VM isn't portable to other frameworks. It's not a hard blocker for most, but I can see devs writing Avalonia for desktop and Maui for mobile. Some devs also put ViewModels in a separate project for isolation will need to include Avalonia (or write an adapter).

There's also the question: why two separate interfaces? There may be merit in following existing art, but it has two other issues if it were to be used directly in a ViewModel: 1. It has a view concept, Dialog, in the name. 2. You need to design your VM injections based on whether you want to display a message box or a content dialog. Why?

On the ViewModel side, I use View-neutral terminology though I'm only providing this for discussion, not proposing an exact implementation. See the following for the (user) interaction service API:

IInteractionService
public interface IInteractionService
{
    /// Displays a message to the user
    Task AlertAsync(string heading, string message);

    /// Prompts the user to make a choice
    Task<PromptResult> PromptAsync(PromptChoice choices, string heading, string? message = default);

    /// Requests an interaction with the user
    /// <param name="mediator">The mediation object to interact with</param>
    /// <returns>The result of the interaction</returns>
    Task<TResult?> RequestAsync<TResult>(IRequestMediator<TResult> mediator);
}

public interface IRequestMediator<TResult> : INotifyPropertyChanged
{
    string Title { get; }
    string AcceptName { get; }
    string CancelName { get; }
    TResult? RequestResult { get; set; }

    ICommand AcceptCommand { get; }
    ICommand CancelCommand { get; }
}

@robloo
Copy link
Contributor Author

robloo commented Jul 4, 2024

I disagree here. This interface would be directly coupled to (owned by) Avalonia. As such, it's not MVVM pure to use in a ViewModel because then your VM isn't portable to other frameworks.

This is no different than using IServiceProvider to show a file selection dialog we have today. The line has to be drawn some place as well. ICommand is part of .NET itself even though all useful implementations are provided by MVVM libraries. Pulling the interface into the framework means it can actually be standardized and widely used. I understand finding that line we might have different leanings; however, I don't think the fundamental point should be overlooked. Foundational pieces, even of MVVM, are part of .NET or the UI framework. Prior examples like IServiceProvider, ILauncher, etc. exist.

MessageBox concepts are so widely used we need to offer something in this area eventually. This is the most powerful solution I can think of. I would be more curious of whether you agree with the overall concept at this point rather than critical of the specific naming and implementations. If we can agree on the idea to start that helps.

There's also the question: why two separate interfaces?

Not only prior art (UWP) but the IMessageDialog is designed to be much easier to use and cover 90% of your use cases. It's basically MessageBox. If you need to go beyond strings to generalized object content (like a TextBox) or need full control over button text then you fall back to IContentDialog. The distinction and use cases are clear here.

But it has two other issues if it were to be used directly in a ViewModel: 1. It has a view concept, Dialog, in the name

Yes, that's what it is and what it does. View models need to sometimes invoke a view. Then again if MVVM principles were widely agreed we would actually have a standard in .NET itself instead of a handful of 3rd party libraries. It's constantly a give/take with easy of use and pure abstractions. In this case the term dialog seems fine to me -- others can disagree.

  1. You need to design your VM injections based on whether you want to display a message box or a content dialog. Why?

I leave the door open here. There are no requirements on how you want to implement this in apps. You have to decide how you want to do things with IServiceProvider already anyway. These interfaces are more for third party libraries so we can swap between them. FluentAvalonia implementation can differ from Material, Semi, Cherry, etc. yet if they are all compatible with one interface in this area we can switch out themes per platform easily. MessageBox-class controls are foundational.

Personally, I find the concept of injecting services directly in a view model a mistake. Having a global registry of services like App.Current.Services is much faster to use. Then you can use it anywhere. You won't agree with me here and that's fine.

I use View-neutral terminology though I'm only providing this for discussion, not proposing an exact implementation. See the following for the (user) interaction service API:

Again, I would not go this far as it's an unnecessary abstraction in this case and makes things more difficult to understand and use. I never advocate for pure MVVM in applications though as it inevitably leads to a lot of time spent to get that last 5% and it's sometimes quite a lot of added complexity. Practicality and when to save time is a judgment call.


The main point is to define an interface that 3rd partly libraries can adhere to to show message dialogs. We could also provide platform native versions of these in the future like IStorageProvider. It is still up to the application at this point to figure out how they want to consume the service.

@robloo
Copy link
Contributor Author

robloo commented Jul 4, 2024

@stevemonaco These interfaces should be thought of more like IControl (before it was removed). It's a standard implementation of a view-level control. However, it can also be used in view models directly for those that can stomach injected view-layer references. I would have no problem with that in this case, just like sometimes its much faster to pass a control reference in a command parameter. MVVM purists will vehemently disagree.

If at the end of the day people agree that this idea is directionally correct but also think we need to be much more strict about MVVM abstraction: IMessageDialog, IContentDialog would remain an abstraction for controls in the view. A new service (like the one you suggested) would be required for use in view models. I'm not proposing that here. So if you want to be pure, strict MVVM it is up to the application to use IMessageDialog, IContentDialog more carefully and not pass them to the view models. They would be wrapped in another service. That fundamentally wouldn't change this idea though.

@stevemonaco
Copy link
Contributor

Design-wise, I think the general feature abstraction is necessary and works fine for the View layer. It should be accessed similarly to IStorageProvider through a TopLevel because dialogs should know their parent (for positioning). It will help standardize within the Avalonia ecosystem instead of having many bespoke implementations, including mine.

I primarily disagree with the parts aimed towards being usable in MVVM. Ideally, Avalonia would publish a separate package containing the interfaces and types with no dependencies. I'll disagree with the naming semantics, but at least the contract would then be reusable. However, Avalonia isn't a platform integration framework (this should be left to BCL, a MS extensions package, or a Xamarin/Maui package), so the reach is limited. MS hasn't exactly been motivated in the standardization area either: see the ObservableCollection<T>.AddRange saga over the past 8 years. We haven't gotten much in the area since netfx4.5 (INotifyDataErrorInfo), AFAIK.

@maxkatz6 maxkatz6 added the api API addition label Jul 4, 2024
@robloo
Copy link
Contributor Author

robloo commented Jul 4, 2024

However, Avalonia isn't a platform integration framework (this should be left to BCL, a MS extensions package, or a Xamarin/Maui package), so the reach is limited.

Here the line is a bit fuzzy. It's certainly true Avalonia is a UI library and isn't trying to be a platform integration framework. But IMO this is clearly a UI feature -- even more so than IStorageProvider. So if IStorageProvider passed the cut this definitely should too (as long as we can all agree to the API). BTW, IStorageProvider is extremely useful and I'm glad it's here already.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
api API addition enhancement
Projects
None yet
Development

No branches or pull requests

3 participants