Let’s use ContentDialog
in MVVM pattern style!
MVVM pattern supposes that the view model doesn’t know about a view. Therefore, if the view model wants to show a dialog window it just calls something like ShowDialog
and doesn’t care how this dialog is implemented. ContentDialog
is tight coupled to the view. To decomposite this relation I prefer to use a service pattern:
public interface IDialogService
{
Task<bool> Show(DialogViewModel viewModel);
}
Show
method shows a dialog and returns true
if a user clicked the primary button, and false
otherwise. The custom enumeration is required to support the second button.
DialogViewModel
is a base class for all the dialog view models. It implements the interaction with dialog buttons:
public abstract class DialogViewModel : ObservableObject
{
private bool isPrimaryEnabled;
private string primaryText, closeText;
protected DialogViewModel()
{
PrimaryCommand = new RelayCommand(OnPrimaryExecuted);
isPrimaryEnabled = true;
primaryText = "Ok";
closeText = "Cancel";
}
public ICommand PrimaryCommand { get; }
public bool IsPrimaryEnabled
{
get => isPrimaryEnabled;
protected set => Set(ref isPrimaryEnabled, value);
}
public string PrimaryText
{
get => primaryText;
protected set => Set(ref primaryText, value);
}
public string CloseText
{
get => closeText;
protected set => Set(ref closeText, value);
}
protected abstract void OnPrimaryExecuted(object? parameter);
}
This view model allows to control the state of the primary button and the text of the primary and close buttons. Also it provides an abstract method that is invoked when the primary button is clicked. ObservableObject
class just provides the implementation of INPC pattern (see the full sample for details).
The possible implementation of IDialogService
interface is the following:
internal class DialogService : IDialogService
{
private readonly IServiceProvider serviceProvider;
public DialogService(IServiceProvider serviceProvider)
{
this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
}
public async Task<bool> Show(DialogViewModel viewModel)
{
ContentDialog dialog = new ContentDialog
{
DataContext = viewModel,
Content = serviceProvider.GetRequiredKeyedService<UserControl>(viewModel.GetType().Name),
XamlRoot = serviceProvider.GetRequiredService<IApp>().XamlRoot
};
BindProperty(
dialog,
ContentDialog.PrimaryButtonCommandProperty,
nameof(DialogViewModel.PrimaryCommand),
BindingMode.OneTime);
BindProperty(
dialog,
ContentDialog.IsPrimaryButtonEnabledProperty,
nameof(DialogViewModel.IsPrimaryEnabled),
BindingMode.OneWay);
BindProperty(
dialog,
ContentDialog.PrimaryButtonTextProperty,
nameof(DialogViewModel.PrimaryText),
BindingMode.OneWay);
BindProperty(
dialog,
ContentDialog.CloseButtonTextProperty,
nameof(DialogViewModel.CloseText),
BindingMode.OneWay);
var result = await dialog.ShowAsync();
dialog.Content = null;
return result == ContentDialogResult.Primary;
}
private void BindProperty(
ContentDialog contentDialog,
DependencyProperty property,
string path,
BindingMode mode)
{
contentDialog.SetBinding(
property,
new Binding
{
Path = new PropertyPath(path),
Mode = mode
});
}
}
ContentDialog
object is created and its properties are binded to the view model properties. Btw, this sample is based on the post about DI in WinUI with some additions.
XamlRoot
property is provided by IApp
interface. For the single window application dialogs are shown in the main window, so XamlRoot
is the value of the content property of the main window.
Content
property is set with the view associated with the given view model. Keyed services are used to implement this behaviour:
// Add view models
builder.Services.AddTransient<ViewModels.IconSizeViewModel>();
// Add views
builder.Services.AddKeyedSingleton<UserControl, Views.IconSizeView>(nameof(ViewModels.IconSizeViewModel));
The view is registered with the name of the view model that is associated with it. Note that the view model is registered as transient
(the new view model is created for every request) and the view is registered as singleton
(one view per an app). This is a controversial decision, and I don’t know if it’s for better or worse. Anyway, the single instance of the view requires to unlink it from ContentDialog
:
dialog.Content = null;
Let’s consider the example of this architecture usage. IconSizeViewModel
view model provides the ability to select one or more icon sizes from the list. A user can’t click the primary button if there is no selected item in the list:
IconSizeViewModel
view model is the following:
public class IconSizeViewModel : DialogViewModel
{
public IconSizeViewModel()
{
IsPrimaryEnabled = false;
Items = new Item[] {
new(this) { Text="32x32" },
new(this) { Text="48x48" },
new(this) { Text="64x64" },
new(this) { Text="256x256" },
}.AsReadOnly();
}
public IReadOnlyCollection<Item> Items { get; }
protected override void OnPrimaryExecuted(object? parameter)
{
; // primary button is clicked
}
private void OnItemSelected()
{
IsPrimaryEnabled = Items.Any(i => i.IsSelected);
}
public sealed class Item : ObservableObject
{
private readonly IconSizeViewModel owner;
private bool isSelected = false;
private string? text;
public Item(IconSizeViewModel owner)
{
this.owner = owner ?? throw new ArgumentNullException(nameof(owner));
}
public bool IsSelected
{
get => isSelected;
set
{
if (Set(ref isSelected, value))
{
owner.OnItemSelected();
}
}
}
public string? Text
{
get => text;
set => Set(ref text, value);
}
}
}
In the view model constructor the primary button is disabled (as no item is selected by default):
IsPrimaryEnabled = false;
Item
class stores a text and state of the list item. When the state changes, this class invokes the parent’s OnItemSelected
method to update the state of the primary button.
The view is as simple as possible:
<Grid>
<ListView
Grid.Column="1"
SelectionMode="Multiple"
IsMultiSelectCheckBoxEnabled="True"
ItemsSource="{Binding Items, Mode=OneTime}"
SelectedValuePath="IsSelected"
DisplayMemberPath="Text"
SelectionChanged="ListView_SelectionChanged">
</ListView>
</Grid>
It’s represented by the list view with checkboxes. Some of the code in the view is used to implement the checkbox state binding:
private void ListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
foreach (var i in e.AddedItems)
{
if (i is IconSizeViewModel.Item item)
{
item.IsSelected = true;
}
}
foreach (var i in e.RemovedItems)
{
if (i is IconSizeViewModel.Item item)
{
item.IsSelected = false;
}
}
}
This code sets the value to IsSelected
corresponding to the state of the checkbox. This is a simplification. In a real project we need to at least bind the initial states of the items to the checkboxes. Also it’s preferable to use behaviours to avoid using the code in views.
Finally, to display a dialog from the view, the following code is used:
var model = serviceProvider.GetRequiredService<IconSizeViewModel>();
if (await dialogService.Show(model) == true)
{
Text = $"Ok is clicked. Selected: {string.Join(", ", model.Items.Where(i => i.IsSelected).Select(i => i.Text))}";
}
else
{
Text = "Cancel is clicked";
}
This code displays the clicked button and the list of selected items (if the primary button is clicked).
The full sample code available here.