Dialogs in AvaloniaUI
Ingvar
Posted on January 6, 2021
Hello, in this post I gonna explain how to create dialogs infrastructure in your Avalonia app! As always I gonna use my app Camelot as an example of implementation.
Why do you need dialogs
Why do we need dialogs? Sometimes apps contain functionality that could be shown in separate window. Another option is to replace part of main window with this content, but sometimes it's not possible or doesn't look good from UI/UX side. In this case dialogs could help
How typical dialog looks like
Typical dialog looks like a small window shown in front of main window. In most implementations background of main window is blurred or semi-transparent that indicates that main window is not active at the moment. Here is an example from Camelot:
Also please note that dialog window is centered relatively to parent window. Also dialog is not always just a window, it could be a selector that returns some value to parent window (for example, create directory dialog returns new directory name).
Dialog view and view model requirements
Dialog view is not a simple user control because dialog itself is a window. Also it requires communication between view and view model because dialog can be closed from vm code. For this purpose I added Close
request to base dialog view model:
public class DialogViewModelBase<TResult> : ViewModelBase
where TResult : DialogResultBase
{
public event EventHandler<DialogResultEventArgs<TResult>> CloseRequested;
protected void Close() => Close(default);
protected void Close(TResult result)
{
var args = new DialogResultEventArgs<TResult>(result);
CloseRequested.Raise(this, args);
}
}
Here TResult
represents type of dialog result. I created empty DialogResultBase
that should be inherited by specific result but you can omit this step. Also I created class for dialog without result:
public class DialogViewModelBase : DialogViewModelBase<DialogResultBase>
{
}
For views I created similar structure (code here). Dialog view centers itself and subscribes view model events.
What if we want to pass some parameter to dialog before opening? For example, if I want to create new directory in current one, I will pass current directory path to create directory dialog because it validates if directory name is correct and doesn't exist. For this purpose I added thing called navigation parameter. I added base class for such dialog too:
public abstract class ParameterizedDialogViewModelBase<TResult, TParameter> : DialogViewModelBase<TResult>
where TResult : DialogResultBase
where TParameter : NavigationParameterBase
{
public abstract void Activate(TParameter parameter);
}
Activate
is called when dialog is shown, TParameter
is our parameter types that extends empty NavigationParameterBase
class. Full VM code is here. Note that this view model doesn't need specific view to work, all navigation parameter logic is done on backend side.
Dialog service interface
Let's encapsulate dialogs opening logic in separate dialog service. Interface for it looks like this:
public interface IDialogService
{
Task<TResult> ShowDialogAsync<TResult>(string viewModelName)
where TResult : DialogResultBase;
Task ShowDialogAsync(string viewModelName);
Task ShowDialogAsync<TParameter>(string viewModelName, TParameter parameter)
where TParameter : NavigationParameterBase;
Task<TResult> ShowDialogAsync<TResult, TParameter>(string viewModelName, TParameter parameter)
where TResult : DialogResultBase
where TParameter : NavigationParameterBase;
}
Note that it supports result and navigation parameter as generic overloads and accepts view model name. View model name is mandatory. I use it to determine which dialog should be opened (different dialogs could have same results and navigation parameters so there is no other way to understand which one should be used).
I put IDialogService
into view models project because view models open dialogs. But implementation itself is done in UI project because it depends on some Avalonia-specific things, view models shouldn't care about this.
Dialog service implementation
How to implement this interface? Let's dive deeper inside implementation. Full code is available here.
Typically I have 6 steps inside dialog service:
1) Create view
2) Create view model
3) Bind view and view model
4) Activate dialog with navigation parameter
5) Show dialog and wait for result (also here I show/hide overlay on main window)
6) Return dialog result if needed
Looks complicated? Let's go through it step by step:
Create view
I have naming convention that view model and view have almost similar names, like CreateDirectoryDialogViewModel
and CreateDirectoryDialog
. It allows me to find view by view model type name easily:
private static DialogWindowBase<TResult> CreateView<TResult>(string viewModelName)
where TResult : DialogResultBase
{
var viewType = GetViewType(viewModelName);
if (viewType is null)
{
throw new InvalidOperationException($"View for {viewModelName} was not found!");
}
return (DialogWindowBase<TResult>) GetView(viewType);
}
private static Type GetViewType(string viewModelName)
{
var viewsAssembly = Assembly.GetExecutingAssembly();
var viewTypes = viewsAssembly.GetTypes();
var viewName = viewModelName.Replace("ViewModel", string.Empty);
return viewTypes.SingleOrDefault(t => t.Name == viewName);
}
private static object GetView(Type type) => Activator.CreateInstance(type);
Note that I use reflection for creating instance of view.
Create view model
Flow here is similar. Note that here I use Locator
to create instance of view model. It's required because dialogs often have dependencies. You should register your view model with Splat to make this code work. See more about dependency injection in AvaloniaUI in my previous blog post.
private static DialogViewModelBase<TResult> CreateViewModel<TResult>(string viewModelName)
where TResult : DialogResultBase
{
var viewModelType = GetViewModelType(viewModelName);
if (viewModelType is null)
{
throw new InvalidOperationException($"View model {viewModelName} was not found!");
}
return (DialogViewModelBase<TResult>) GetViewModel(viewModelType);
}
private static Type GetViewModelType(string viewModelName)
{
var viewModelsAssembly = Assembly.GetAssembly(typeof(ViewModelBase));
if (viewModelsAssembly is null)
{
throw new InvalidOperationException("Broken installation!");
}
var viewModelTypes = viewModelsAssembly.GetTypes();
return viewModelTypes.SingleOrDefault(t => t.Name == viewModelName);
}
private static object GetViewModel(Type type) => Locator.Current.GetRequiredService(type);
Bind view and view model
Bind is super simple. I set DataContext
property of dialog with my view model:
private static void Bind(IDataContextProvider window, object viewModel) => window.DataContext = viewModel;
Activate dialog
If view model supports activation, I call Activate
method.
switch (viewModel)
{
case ParameterizedDialogViewModelBase<TResult, TParameter> parameterizedDialogViewModelBase:
parameterizedDialogViewModelBase.Activate(parameter);
break;
case ParameterizedDialogViewModelBaseAsync<TResult, TParameter> parameterizedDialogViewModelBaseAsync:
await parameterizedDialogViewModelBaseAsync.ActivateAsync(parameter);
break;
default:
throw new InvalidOperationException(
$"{viewModel.GetType().FullName} doesn't support passing parameters!");
}
Show dialog
This part is a bit tricky. I have to get main window, show overlay on it, show dialog and hide overlay:
private async Task<TResult> ShowDialogAsync<TResult>(DialogWindowBase<TResult> window)
where TResult : DialogResultBase
{
var mainWindow = (MainWindow) _mainWindowProvider.GetMainWindow();
window.Owner = mainWindow;
mainWindow.ShowOverlay();
var result = await window.ShowDialog<TResult>(mainWindow);
mainWindow.HideOverlay();
if (window is IDisposable disposable)
{
disposable.Dispose();
}
return result;
}
Overlay is a semi-transparent Grid
. Show/hide methods just change ZIndex
for it:
public void ShowOverlay()
{
OverlayGrid.ZIndex = 1000;
}
public void HideOverlay()
{
OverlayGrid.ZIndex = -1;
}
This Grid
has following style:
<Style Selector="Grid#OverlayGrid">
<Setter Property="ZIndex" Value="-1" />
<Setter Property="Background" Value="{DynamicResource DialogOverlayBrush}" />
<Setter Property="Opacity" Value="0.2" />
</Style>
Return result
Easy part. Avalonia supports dialogs results:
var result = await window.ShowDialog<TResult>(mainWindow);
// some code here
return result;
Conclusion
In this post I explained how to create and use dialogs in your app. Do you have dialogs in your app? Tell me in comments.
Posted on January 6, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.