AvaloniaUI / Avalonia

Develop Desktop, Embedded, Mobile and WebAssembly apps with C# and XAML. The most popular .NET UI client technology
https://avaloniaui.net
MIT License
24.57k stars 2.12k forks source link

IMessageDialog & IContentDialog Services #16234

Open robloo opened 4 days ago

robloo commented 4 days ago

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 ```cs /// /// Specifies the return value of a message dialog (which button was pressed by the user). /// public enum MessageDialogResult { /// /// The dialog returns no result. /// Either no button was pressed or the user pressed cancel. /// None = 0, /// /// The OK button was pressed by the user. /// OK, /// /// The Cancel button was pressed by the user. /// Cancel, /// /// The Yes button was pressed by the user. /// Yes, /// /// The No button was pressed by the user. /// No } /// /// Specifies the buttons displayed on a message dialog. /// public enum MessageDialogButton { /// /// Display only an OK button. /// OK = 0, /// /// Display OK and Cancel buttons. /// OKCancel, /// /// Display Yes and No buttons. /// YesNo, /// /// Display Yes, No and Cancel buttons. /// YesNoCancel } /// /// Defines a contract for displaying simple messages with defined buttons to the user. /// public interface IMessageDialog { /// /// Displays a default message dialog with an OK button. /// /// The message text to display. /// A value that specifies which button was pressed by the user. Task ShowAsync(string message); /// /// Displays a message dialog with a title and an OK button. /// /// The message text to display. /// The title bar caption to display above the message. /// A value that specifies which button was pressed by the user. Task ShowAsync(string message, string title); /// /// Displays a message dialog with a title and defined buttons. /// /// The message text to display. /// The title bar caption to display above the message. /// A value that specifies which button or buttons to display. /// A value that specifies which button was pressed by the user. Task ShowAsync( string message, string title, MessageDialogButton buttons); } ```
IContentDialog ```cs /// /// Specifies the return value of a content dialog (which button was pressed by the user). /// public enum ContentDialogResult { /// /// The dialog returns no result. /// Either no button was pressed or the user pressed cancel. /// None = 0, /// /// The Primary button was pressed by the user. /// Primary, /// /// The Secondary button was pressed by the user. /// Secondary } /// /// Specifies a button in a content dialog. /// public enum ContentDialogButton { /// /// No button is specified. /// None = 0, /// /// The Primary button. /// Primary, /// /// The Secondary button. /// Secondary, /// /// The Close button. /// Close } /// /// Defines a contract for displaying arbitrary content with customizable buttons to the user. /// public interface IContentDialog { /// /// Displays a default content dialog with a single close button. /// /// The content to display. /// The close button text. /// A value that specifies which button was pressed by the user. Task ShowAsync( object content, string closeButtonText); /// /// Displays a content dialog with a title and a single close button. /// /// The content to display. /// The title bar caption to display above the content. /// The close button text. /// A value that specifies which button was pressed by the user. Task ShowAsync( object content, string title, string closeButtonText); /// /// Displays a content dialog with a title, a primary button and a close button. /// /// The content to display. /// The title bar caption to display above the content. /// The close button text. /// The primary button text. /// Specifies which button is the default action to invoke. /// A value that specifies which button was pressed by the user. Task ShowAsync( object content, string title, string closeButtonText, string primaryButtonText, ContentDialogButton defaultButton = ContentDialogButton.Close); /// /// Displays a content dialog with a title, a primary button, a secondary button and a close button. /// /// The content to display. /// The title bar caption to display above the content. /// The close button text. /// The primary button text. /// The secondary button text. /// Specifies which button is the default action to invoke. /// A value that specifies which button was pressed by the user. Task 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

stevemonaco commented 4 days ago

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 ```cs public interface IInteractionService { /// Displays a message to the user Task AlertAsync(string heading, string message); /// Prompts the user to make a choice Task PromptAsync(PromptChoice choices, string heading, string? message = default); /// Requests an interaction with the user /// The mediation object to interact with /// The result of the interaction Task RequestAsync(IRequestMediator mediator); } public interface IRequestMediator : INotifyPropertyChanged { string Title { get; } string AcceptName { get; } string CancelName { get; } TResult? RequestResult { get; set; } ICommand AcceptCommand { get; } ICommand CancelCommand { get; } } ```
robloo commented 4 days ago

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 commented 4 days ago

@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 commented 4 days ago

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.

robloo commented 4 days ago

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.