This sample application was created for educational purposes as well as to serve as a quick base app you can quickly wire up and start building actual business logic on. This documentation explains the building blocks present in this solution.
You should also explore Avalonia documentation and MVVM Toolkit documentation. You may also use ReactiveUI as a replacement for the MVVM Toolkit.
ℹ️ There is a short summary at the end of the guide.
The solution follows the Model-View-ViewModel (MVVM) design philosophy i.e. Views are defined declaratively in AXAML markup. They reflect data within the View Model (a C# class) through data-binding. Often View Models present the state of a Model object instance, but tailor its data so that it is suitable for the View to consume.
There are a couple of ways how different MVVM frameworks typically bundle Views and View Models within the project structure. You may have seen a convention where all views are located in the "Views" folder, all view models in the "ViewModels" folder, and Models in their own folder. Due to the feature driven design in the sample application, it makes more sense to store these components into their Feature folder instead. For example:
📁Features/
📁MyFeature/
📄MyFeature.cs
📄MyView.axaml
📄MyView.axaml.cs
📄MyViewModel.cs
📄MyModel.cs
💡 TIP:
Of course nothing stops you from creating the "Views", "ViewModels", and "Models" sub-folders in your Feature folder if you prefer to arrange the files that way. This however will require you to use moreusingstatements across your files.
As a rule of thumb:
- Derive your view models from the
ViewModelbase class - Derive your views from Avalonia's
UserControl
💡 TIP:
Make sure you have the Avalonia Visual Studio extension installed. It contains the templates for Avalonia-specific project items as well as the AXAML visual designer.
There is also a handy utility ViewLocator that implicitly attempts to match Views based on a view model DataContext [using a naming convention]. It is instantiated in App.axaml and thus applies to the entire application.
Features are autonomous modules that are meant to implement a single aspect of the application. A feature groups all views, view models, models, interfaces, and resources together to make up a logical feature. Features can contain visual UI (in the form of Published Views), or just be services that provide access to data that some other features may then utilize.
Examples of visual features include ToolBar, Menu, Navigation, or open file view. Examples of data-only features include ConfigurationProvider, CommandSink, or AuthenticationService.
By convention features are placed in the "Features" folder, in their respective sub-folders. Each feature folder should then contain at least the Feature class that derives from App.Feature and its name ending with "Feature.cs", for example "MyCoolFeature.cs".
At start-up the application will enumerate all classes that derive from App.Feature and instantiates them. The Feature class should contain a parameterless constructor. This behavior is defined in the App.LoadFeatures method, and could be extended to also load features from external assemblies to support plugins.
Here is an example of a simple feature:
public class MyCoolFeature : App.Feature
{
public override Task InitializeAsync()
{
return Task.CompletedTask;
}
public override Task StartingAsync(CancellationTokenSource cancel)
{
return Task.CompletedTask;
}
public override Task RunningAsync(string[] args)
{
return Task.CompletedTask;
}
public override Task ExitRequestedAsync(CancellationTokenSource cancel)
{
return Task.CompletedTask;
}
public override Task ShutdownAsync()
{
return Task.CompletedTask;
}
}The overrides shown in the above code snippet are optional, but it is important to understand when these are called.
When the application starts, the following sequence is performed:
- The Feature classes are discovered and instantiated. You may implement a parameterless constructor to do basic setup for the class, but you may not utilize the Common Application Services (
IHost) yet, and you may not depend on any other feature at this point either. - Published Views are discovered and added to an internal registry.
- Resources are discovered.
- Features' dependencies are enumerated and resolved into all feature instances.
- At this point all required information is gathered, and features will begin their life-time cycle. All features'
InitializeAsyncmethods are called in parallel. This is the earliest point you may utilize Common Application Services (IHost). Also all properties decorated with theDependencyAttributehave been populated, so you may also start utilizing other features' services (although it is generally advisable to do this in the next step instead). - All features'
StartingAsyncmethods are called in sequence. ACancellationTokenSourceis provided. Should any feature decide to cancel it, then the entire application will shut down. The purpose here is mainly to recognize some kind of incompatibility or misconfiguration where the feature would otherwise end up in an inoperable state. - The Main Window will show.
- At this point all features are considered fully initialized, configured, and ready to go. All features'
RunningAsyncmethods are called in parallel. This method also provides the command line argument array, so the chosen feature may for example, open the supplied file, or otherwise perform the instructed action.
The application is set to exit when the Main Window is fully closed. Closing the window either manually by user, or programmatically from code (App.Current.Exit) will attempt to close the window, but this can be canceled.
Similarly to the start-up sequence, here is how the shut down goes:
- All features'
ExitRequestedAsyncmethods are called in parallel. Like withStartingAsyncalso this method is provided with aCancellationTokenSource. Should any feature choose to cancel it, the Main Window will remain open and the application continues to run normally. If no feature reacts to the event, the shutdown begins. A typical action to perform here is to check whether the user has any unsaved data and prompt saving if necessary. - All features'
ShutdownAsyncmethods are called in parallel. This is where all features should stop any background services, clean-up their data and save state if needed. There is no time limit to complete this task, but generally speaking features should take no longer than a few seconds to finish up.
💡 TIP:
If you need to forcefully close the application (without invoking the shutdown mechanism), you can call theEnvironment.Exitmethod.
⚡ The example application demonstrates the "Unsaved changes" functionality when the user has made changes to a project's TextBox content (and not having clicked the 'Save' button) before attempting to exit the app.
In the class constructor you should:
- The
Hostis not populated yet, so you should only perform simple setup work such as instantiating lists or other collections used by the class. - Note that localized strings are not available yet (and they are accessed via the
Hostproperty anyway). - Do not attempt to bypass the absence of the Host instance by accessing it via
App.Current.Host- it is a bad practice and may result in undefined behavior.
In InitializeAsync you should:
- Check that all the required Dependencies have been populated.
- Load necessary data from disk.
- Since the
Hostis available, you may read the Settings and initialize feature state based on them. - Use
Host.Register(you can also register them inStartingAsync). - Perform any initialization that is specific to the feature in question only.
- You should not utilize other features' services yet because
InitializeAsyncruns in parallel and they might not be ready for consumption yet. - Register Messenger message handlers. However, you should not send any messages yet.
- Generally all services offered by the
Hostare ready. - For
AppSettingsfeature only: You may register the default setting categories so that they exist by the time the features start placing their own settings in them. - The UI is not visible yet, so it is safe to manipulate Language and Theme specific properties in the App class "in advance". The Shell feature, for example, sets them according to saved settings values.
In StartingAsync you should:
- Use
Host.Register(you can also register them inInitializeAsync). - Start any background services, but assume that the application may not run, and that
RunningAsyncmay not get called next, andShutdownAsyncmay get called instead. - All features have been initialized, so now it is possible to start communicating between them. This includes utilizing the dependencies, sending Messenger messages, or registering to other features' lists (for example place new settings in a settings category).
- This is the only phase that is called in sequence (instead of in parallel) because the registrations mentioned in above bullet point would otherwise occur simultaneously, or at least in random order. Imagine if two features would attempt adding a custom ToolBar button in the same slot at the same time, or during an active enumeration.
- You have the option to trigger the supplied Cancellation Token. Doing so will stop the application start-up sequence, and cause the application to exit (
ShutdownAsyncwill be called next). In this rare scenario the UI is never shown, andRunningAsyncnever gets called either.- One example of such cancellation would be a failed "license check".
- Another example would be a serious malfunction or misconfiguration (without which the feature or application cannot run properly). If the feature for example is providing essential interfaces that you know other features rely upon, the absence of such service could be important enough to prevent the whole app from starting.
In RunningAsync you should:
- The UI is now visible and all features are up and running. You can utilize the supplied
argsarray that contains the command line arguments to determine what to do next. For example, to activate a certain view in your application, open the requested file, and things like that... - This is the earliest point may start using general Avalonia functionality (such as accessing
App.Current, operating with Resource Dictionaries, or displaying message boxes etc.)
In ExitRequestedAsync you should:
- Application is about to close. You can prevent it by triggering the supplied Cancellation Token.
- You should not stop background services, or otherwise prepare to shutdown. Assume that the application may not close. Truly wind down only in the next step (
ShutdownAsync). - You may check whether there are unsaved changes, and optionally show a confirmation dialog to the user. Cancel the operation if the user clicked "Cancel". The sample app demonstrates this, and showing the MessageBox is awaitable.
In ShutdownAsync you should:
- Application is now shutting down (with no option to cancel anymore).
- Do not do any UI specific work anymore (such as showing dialogs).
- Gracefully shutdown any background services.
- Make sure that all settings you want to save are stored in
Host.Settings. - Save custom data to disk.
- Avoid performing any long running operations, the application should exit within a few seconds.
Published Views can be seen as Avalonia User Controls that are instantiated by name (and an optional parameter). In MVVM you typically define the view and its view model in pairs, and this is also true for Published Views. Published Views are defined by special methods in Feature classes. Here is an example:
public class MyCoolFeature : App.Feature
{
[PublishedView<MainView>("MyCoolFeature.MainView")]
public ViewModel? CreateMainView(object? parameter)
{
return new MainViewModel();
}
// ...
}Define a method that takes one parameter (of type object) and returns an instance of ViewModel. Decorate the method with the generic PublishedViewAttribute whose type parameter should refer to an Avalonia UserControl derived class. The sample application uses a naming convention Create<ViewName>.
Also create the view model class:
public class MainViewModel : ViewModel
{
}Then add the Avalonia view: Add → New Item... → User Control (Avalonia), using the name "MainView.axaml".
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="MyApp.Features.MyCoolFeature.MainView">
<Grid>
<TextBlock>This is my cool main view!</TextBlock>
</Grid>
</UserControl>This is your published view! You can now spawn it in AXAML layouts (for example the MainWindow or any other AXAML view) via the PublishedView control:
<!-- xmlns:ui="clr-namespace:MyApp.UI" -->
<ui:PublishedView ViewName="MyCoolFeature.MainView"></ui:PublishedView>Since you refer to the Published View simply by string name, you can utilize other features' views without having strong references to said features. The view may be implemented in some external assembly, too.
Remember the object parameter in the view creator method's signature? You may also pass a value to the method using the ViewParameter attribute in AXAML:
<ui:PublishedView ViewName="MyCoolFeature.MainView" ViewParameter="Hello"></ui:PublishedView>Both attributes can also be databinded:
<ui:PublishedView ViewName="{Binding Name}" ViewParameter="{Binding MyParam}"></ui:PublishedView>📜 NOTE:
When eitherViewNameorViewParametervalue changes, the view will be re-created. This is good to keep in mind if the creation is an expensive operation. If this is not desired, pass an object that itself does not change, but it holds some other property whose value can change. Pass this value to your view model instance and handle the value change there.Also, if you return null from the creation method, the view will not be created!
The framework will instantiate the view and enrich its Resources with localized strings (if the owning Feature specified any).
Then, the view model instance will have its dependencies resolved and its Host property set (this also causes the virtual Initialize method to be called). Finally, the view model is set as the view's DataContext and the newly created control is attached in the Visual Tree.
Similarly to Feature classes, the base ViewModel class also provides access to common application services via the Host property. Whereas this instance becomes available in Feature.InitializeAsyncmethod, for view models it is available when the overridable Initialize method is called.
💡 TIP:
If you want to test your view models, you can implement theIHostinterface to create a custom testing Host. Setting theHostproperty from outside simulates the activation of the view model.
Like with Features, you should only perform basic instantiation and setup in the view model's constructor, and start using the Host services only inside Initialize.
Please notice that this mechanism only applies to view models that are created as part of the Published View creation process. You can of course also manually set the Host property of sub view models, like an ObservableCollection's item view models.
In addition to Initialize the base ViewModel contains two additional overridable methods: Activate and Deactivate. They are automatically called when the Published View is attached or detached from the Logical Tree i.e. used within an AXAML layout. You can use these for event subscription/unsubscription (including Messenger subscriptions) or winding up and down background tasks.
The sample application contains a number of common services and utilities that will come in handy across the board. In order to keep parts of the application testable (should you choose to do so), access to such resources via static members or a singleton is not ideal. Instead, the framework will auto-populate the instance to the Host services for all Feature classes and the view models of Published Views. The Host instance is a single access point to common folders, settings, service container, localization, diagnostics and Messenger.
You can access the Host in three ways:
- Feature classes have the
Hostproperty. The framework will set its value before theInitializeAsyncmethod is called. - View model classes have the
Hostproperty. The framework will set its value as part of Published View creation before the view model'sInitializemethod is called. If your view model is not for a Published View, you may manually set theHostproperty after object construction. - All other UI parts, such as Value Converters or views' code-behinds may use the static
App.Currentinstance, and itsHostproperty.
The Host provides the following folder paths:
| Property | Purpose |
|---|---|
AppPath |
Location of the running executable. |
DataPath |
Location for user-specific miscellaneous data. |
ExtensionsPath |
Base path for external Features. |
PatchingPath |
Location for temporary update files and patch notes. |
TempPath |
Location for temporary files. |
ProjectsPath |
Base path for project data. |
UserSettingsPath |
Location for settings files. |
The Host.UI provides localization related functionality:
- The
Languageproperty gets the current language. - The
GetLocalizedText(string)method gets a localized string using the current language, or falls back to the default language. - The
GetLocalizedText<TFeature>(string)method gets a localized string using the current language, or falls back to the default language. The scope of available strings is limited to the specified Feature.
These utilities are suitable to be used from C#. In contrast, there are better ways to access localized strings from AXAML. For more information, see the Localization section later in this guide.
The Host.Settings provides access to the settings dictionary. Settings are just key-value pairs.
- Use
GetValue<TValue>(string)andGetValue<TValue>(string, TValue)to read a value from settings. TheTValueshould be of elementary data type such asint,double, orstring. Any other type will internally use JSON serialization. - Use
SetValue<TValue>(string, TValue)to store a value. SameTValuerules apply as for reading. - Use
ClearValue<TValue>(string)to completely remove the value from the dictionary. After that, it will not be serialized when the application saves settings to disk.
To learn more about how to use settings, see the Settings section later in this guide.
The Host.Diagnostics provides statistics regarding Features, Published Views, and Resources.
- The
GetFeaturesmethod lists all loaded features by their fully qualified name. You may for example check whether a certain feature is present and adjust another feature's functionality based on that. - The
GetPublishedViewsmethod lists all registered Published View names, and their current instance count. - The
GetLocalizationsmethod lists all languages and their localized resource count. You may use this information to determine which languages are "complete enough" to be listed in a language selection.
The Host.Messenger provides access to the default Weak-Event Messenger. Internally the framework utilizes the Messenger from MVVM Toolkit, and features may use that functionality also. However, the intent was to provide an alternative way to send and receive Messages without a dependency to the MVVM Toolkit (this is more important if Features are loaded from external assemblies).
The Messenger is an alternative way to the dependency system, to send and receive signals. It is suitable for broadcasting messages to objects that do not have access to the Host. Message subscriptions also use weak references (in contrast to events provided by a dependency's interface).
- Use the
Registermethod (and its overloads) to introduce a target object that will receive certain type messages. - Use the
Unregistermethod (and its overloads) orUnregisterAllmethod to unsubscribe from messages of certain type. - Use the
Sendmethod (and its overloads) to send a new message of certain type to all registered recipients.
Note that you cannot know whether the sent message found any recipients.
To learn more about how to use the Messenger, see the Messaging section later in this guide.
The Host provides a service container that maps an interface type to its actual implementation (practically a singleton instance).
📜 NOTE:
Avalonia has its own ServiceLocator, but it seems to be for Avalonia-specific services only. For Host the intent was to provide a container dedicated for the application only. For example, interfaces published by Features are automatically available in this container. Nothing stops you from using the ServiceLocator though.
- Use the
Register<TInstance>(TInstance, object?)method to register an object instance and have it be available by an interface type (TInstanceshould be an interface type). You can only register the same interface once. You can use the return value to determine whether the object was registered successfully. Usually the ideal time for registering instances is at the Features'InitializeAsyncandStartingAsyncmethods. - Use the
Unregister<TInstance>(object)method to remove an instance from the container. Only the instance's owner (whoever registered it in the first place) should do this. - Use the
GetInstance<TInstance>method to request the object instance.
⚡ The example application demonstrates the Register/GetInstance functionality by registering the ShellView as a globally accessible IViewContainer.
The example application has Language selection on the Home screen. It is wired up to change the App.Language property, and when it does, all active Published Views will have their Resources updated with language-specific strings. In other words, localization is tightly tied to Features and their Published Views.
In order to define localizable strings, you need to create a Resource Dictionary, and then introduce it in your Feature class. More specifically, decorate the class with the LocalizedResources and specify the language identifier and the (relative) location of the corresponding Resource Dictionary:
[LocalizedResources(Culture = "en-us", Path = "Locales/en-us.axaml")]
public class MyCoolFeature : App.Feature
{
// ...
}The recommended way is to create a new sub-folder named "Locales" in your Feature folder, and then Add → New Item... → Resource Dictionary (Avalonia), using the name like "en-us.axaml".
Here is an example how the Resource Dictionary might look like:
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<x:String x:Key="MyCoolFeature.Yes">Yes</x:String>
<x:String x:Key="MyCoolFeature.No">No</x:String>
<x:String x:Key="MyCoolFeature.Cancel">Cancel</x:String>
</ResourceDictionary>You can define as many LocalizedResources attributes (and Resource Dictionaries) as you like, however you should have only one dictionary per language.
⚠️ IMPORTANT:
When you localize a feature, it must have at least en-us.axaml defined. This is the default language, and its strings will be used as fall-back if a string is missing from the current language. The en-us version should always be the most complete set of strings.
When accessing localized strings, they resolve based on standard Avalonia layout hierarchy. For example in the demo application, since the Shell serves as a container for all other Published Views, its localized strings contain general-purpose terms like "Yes" and "No". This means that other features will not have to declare such strings the second time, and a DynamicResource reference will find the value from the ShellView's resources (unless another View deeper in the Visual Tree overrides this resource key).
Overriding known existing resources as well as inheriting resources from closer to the Visual Tree root are both powerful mechanisms you may leverage.
See the Avalonia Resource resolution for more info about the discovery order.
<TextBlock Text="{DynamicResource MyCoolFeature.Yes}" /><!-- xmlns:ui="clr-namespace:MyApp.UI" -->
<TextBlock Text="{Binding Key, Converter={x:Static ui:ConvertKeyToLocalizedString.Instance}}" />Please note that in the above code snippet, the ValueConverter will not automatically react to App.Language change. You will need to manually notify that the binding has changed:
public class MyViewModel : ViewModel
{
[Dependency]
public IShell? Shell { get; set; }
public string Key => "MyCoolFeature.Yes";
protected override void Initialize()
{
if (this.Shell is not null)
{
this.Shell.LanguageChanged += (sender, args) => this.OnPropertyChanged(nameof(this.Key));
}
}
}<!-- xmlns:ui="clr-namespace:MyApp.UI" -->
<TextBlock Text="{ui:Localize {Binding Key}}" />Please note that in the above code snippet, the markup extension will not automatically react to App.Language change. You will need to manually notify that the binding has changed. See the Value converter example above.
// Limit the scope to MyCoolFeature's resources only
var localized1 = this.Host!.UI.GetLocalizedText<MyCoolFeature>("MyCoolFeature.Yes");
// Look up from the "flattened" list that contains all currently active strings
var localized2 = this.Host!.UI.GetLocalizedText("Yes");The GetLocalizedText method returns null if the resource was not found. Consider using a fall-back string like this:
var text = this.Host!.UI.GetLocalizedText("S0METH1ng") ?? "Some default";💡 SUMMARY:
- Within AXAML, use
DynamicResourcewhenever possible- Within C#, use the
IHost.UI.GetLocalizedText
⚡ In the demo application, there is a Language selection on the Home screen where you can check how Language can be changed at runtime. This application is partially localized to Finnish. Observe how some strings do not change when switching the language. Those strings are missing from the Finnish Resource Dictionaries, and will fall-back to defaults (English).
⚡ In the ShellFeature.InitializeAsync method the "APP.Language" setting is loaded at application start-up, and stored every time the ShellFeature.Language property changes. The next time the app starts, the last used Language is thus remembered.
⚡ The AppSettingsViewModel subscribes to IShell.LanguageChanged event, and refreshes all properties in setting view models that are used as localization keys. This will ensure that the views' Localize markup extensions are updated. The reason for this is that Settings are created programmatically, and thus the AXAML layout is templated. The only way to localize them is to either have the view model serve an already localized string, or use value conversion from AXAML. The latter is more exciting so that is the implementation for now. Either way, the view model must react to the Language change event.
The example application has the ability to switch the theme between Avalonia's Fluent light and dark variants freely at runtime. Like Language, the current Theme is considered an application-wide setting and thus it is controlled by the Shell feature. The theme related functionality is exposed in the IShell interface that can easily be imported as a dependency to other features and view models. More specifically, please check the following IShell members:
- Use the
Themeproperty to get or set the current theme. The choice is either Light or Dark. - Use the
AccentColorproperty to get or set the accent color used in control styles. - Subscribe to the
ThemeChangedand/orAccentColorChangedevents to get notified when their respective properties change.
Internally the Shell feature manipulates the App.RequestedThemeVariant and App.ActualThemeVariant properties (who do all the heavy lifting to change control styles at runtime).
⚡ The Home screen provides selectors for the Light/Dark switch and a set of example accent colors. Basically these controls use the IShell interface for applying the custom appearance. These values are also written to IHost.Settings, and are remembered the next time the app starts.
⚡ The demo application uses a lot of partially transparent grayish colors to apply shading on various parts of the UI. This way shading works on both dark and light backgrounds. See ProjectView.axaml for example (#33AAAAAA in particular).
⚡ Sometimes the above mentioned shading trick is just not enough to make some UI elements work on both dark and light backgrounds. The ViewSelectorFeature.cs demonstrates how to change some Resources (such as SolidColorBrush used by the View) based on the theme. Basically, it updates the MainWindow's Resource Dictionary with theme specific brushes (to which DynamicResources then react).
The general application architecture is heavily designed around Features where each of them provides a coherent unit of functionality. Features may build on each other, where elementary features form the building blocks of more sophisticated features. This will require a way of features to communicate with each other. You could use the Messenger, but creating dozens of requests will not fly very far.
Enter dependencies. It allows features to publish interfaces, and also import said interfaces directly to other Feature classes or view models through property injection.
Add this interface:
public interface IMyService
{
Task<string> GetSecretAsync();
}We want MyCoolFeature to implement this interface and publish it:
public class MyCoolFeature : App.Feature, IMyService
{
public async Task<string> GetSecretAsync()
{
return await Task.FromResult("My secret value.");
}
// ...
}When another feature (or view model) wants to consume the interface, add a property (of the interface type) and decorate it with the DependencyAttribute:
public class SomeOtherFeature : App.Feature
{
[Dependency]
public IMyService? MyService { get; set; }
// Use the dependency
private async Task DoStuffAsync()
{
var secret = await this.MyService!.GetSecretAsync();
}
// Ensure that the required dependencies are properly filled in
public override Task StartingAsync(CancellationTokenSource cancel)
{
if (this.MyService is null)
{
Trace.TraceError("A critical dependency is missing. Forcing application shutdown.");
cancel.Cancel();
}
return Task.CompletedTask;
}
}Dependencies are populated for Feature classes and those view models that are part of Published View creation.
💡 TIP:
You may check whether the dependencies are fulfilled in the Feature'sInitializeAsync, or optionally inStartingAsyncif you want to prevent the app from starting if a dependency is missing. For view models the corresponding check would be theInitializemethod.
Most features in the example application provide an interface that can be used to control how the feature's View(s) behave. These interfaces follow a naming convention where the feature class is named ThingFeature and its interface IThing. The interface may provide methods, properties, and events for synchronous and asynchronous use. The interface exists in the same namespace as the Feature.
If your Feature provides a "main view" you might want to pass the interface also to the view model because that is where interaction logic resides. Consider this example from the StatusBar feature:
public class StatusBarFeature : App.Feature, IStatusBar
{
[PublishedView<StatusBarView>("IDE.StatusBar", Description = "The status bar.")]
public ViewModel? CreateStatusBar(object? parameter)
{
return new StatusBarViewModel(this); // Pass in IStatusBar
}
}Another scenario is that since the Published View creation method receives a parameter, you can databind it from AXAML, and pass that into the view model constructor:
<ui:PublishedView ViewName="MyCoolFeature.MainView" ViewParameter="{Binding SomeInterfaceComingFromOutside}" />[PublishedView<MainView>("MyCoolFeature.MainView")]
public ViewModel? CreateMain(object? parameter)
{
if (parameter is ISomeExpectedInterface obj)
{
return new MainViewModel(obj);
}
return null; // Cannot create view
}The Messenger can be accessed via IHost (Feature.Host, ViewModel.Host, App.Current.Host) that contains a few methods for sending signals, and a way to register handlers to react to them.
Messages are just class instances and may thus contain as much information as necessary. However, message classes should be immutable. If you are only interested in declaring member properties and do not care about validation, this can be achieved by using C# records.
First create a message class:
public record TestMessage
{
public required string What { get; init; }
}Use the Host to register a handler for that type of message:
this.Host!.Messenger.Register<TestMessage>(this, (r, m) =>
{
// Handle message
Debug.WriteLine($"Recipient:{r}, Message:{m.What}");
});A good place to register the message handler, is a Feature's InitializeAsync method, or a view model's Initialize method.
For view models, another pattern is to register to messages in the Activate method, and unregister those handlers in the corresponding Deactivate method. Please note that these methods are called by the framework only for those view models that were part of Published View construction:
public override void Deactivate()
{
this.Host!.Unregister<TestMessage>(this);
}📜 NOTE:
Even though Messenger recipients are registered using weak references (which does not prevent garbage collection) it is a good practise to always unregister from message types you have previously registered.
💡 TIP:
If your recipient registers to multiple message types, you can use theUnregisterAllmethod to conveniently clear all message handlers at once (instead of callingUnregisterfor each one separately).
Please note that the Unregister method requires the original recipient as an argument. This is to make sure that the calling code at least knows who registered the message in the first place. Typically both register and unregister operations use this as the recipient.
Finally, use Messenger to send a message to all registered recipients:
this.Host!.Messenger.Send(new TestMessage { What = "Yeah!" });Note that you cannot know whether the message reached any recipient.
There are overloads for Register, Unregister and Send to also accept an integer channel parameter. This allows you to use the same message class, but also to filter (by channel) which recipients should receive the message.
// Send the message only to recipients who are listening to channel 2
this.Host!.Messenger.Send(new TestMessage { What = "Bla bla" }, 2);
// Send the message only to recipients who are listening to channel 5
this.Host!.Messenger.Send(new TestMessage { What = "Hihi" }, 5);Internally the framework uses the MVVM Toolkit (Weak-Event) Messenger. The Host provides an abstraction because Features could be loaded from external assemblies (like "plug-ins"), and utilizing the Messenger from these features would require a reference to the Toolkit. By providing the base functionality that does not depend on Toolkit Messenger types the choice of which MVVM framework to use is still at the hands of the plug-in developer.
There is one notable feature in the MVVM Toolkit's Messenger that is absent from the IHost version and that is Request Messages. It is missing because request messages must derive from a base class which the framework cannot expose in the Messaging API for reasons mentioned above. If your code uses the Toolkit, you may however, still utilize this feature.
💡 TIP:
The work-around to Request Messages is to use Feature Dependencies, where a Feature implements an interface and exposes that as a dependency. Such an interface could provide synchronous or asynchronous methods that serve a similar purpose to sending request messages. For more information, see the Dependency system section earlier in this guide.
For more information about the MVVM Toolkit Messenger capabilities, check out its documentation.
⚡ There is one example in the demo application where the Shell.axaml.cs publishes a MainViewReadyMessage when the view has been loaded.
Apart from the program icon all graphics in the example application are vector based SVG shapes. Of course nothing stops you from using traditional raster images, but the SVG approach has some strong up-sides in a modern UI. Most importantly they are scalable to any size without getting blurry like bitmaps do. This trait fits very well to Avalonia layouts because they are DPI-aware; the SVG icons will always look crisp regardless of screen resolution or OS UI scaling.
The second trait is that since SVG graphics are built using shapes, individual elements [that make up the icon] can be colorized, and these colors can be dynamically changed at runtime. This enables all kinds of interesting scenarios including:
- Icon colors can dynamically be changed based on the selected theme
- Icon colors can dynamically be changed based on mouseover state or some view model property
- Icon variants can be created where the geometry of the icon is the same but the fill color varies - in other words less assets for more actions
- Create more engaging icons by animating their colors
📜 NOTE:
Icons in the demo application use single fill color, but the system could be easily extended to support multi-color icons.
The sample icons are from the Material icons gallery.
Here is how you would import a new icon to the application:
- On the website, make sure you select "Material Icons" (not "Material Symbols")
- Click on the desired icon. A sidebar will appear.
- Make sure that Size is "24dp" and Color is "Black".
- Click on the "SVG" button at the bottom. This will download the SVG file.
- Copy the downloaded SVG file to the project folder
/Assets/Iconsand give it a name with no special characters (follow the existing naming convention). - Make sure that the item's Build Action is "AvaloniaResource".
Use the Icon control. It has databindable IconName and Color properties. The system assumes that all Icons are located in the aforementioned "Icon" project folder, and you refer to them simply by filename (excluding the file extension). For example, if your icon is named "MyIcon.svg", simply assign IconName = "MyIcon".
<!-- xmlns:ui="clr-namespace:MyApp.UI" -->
<!-- Example: Use custom color -->
<ui:Icon IconName="Project" Color="#FFFFAA00" Width="24" Height="24" />
<!-- Example: Databind the name -->
<ui:Icon IconName="{Binding Icon}" Width="16" Height="16" />⚡ Icons are used in HomeScreenView.axaml and ViewSelectorView.axaml where the latter demonstrates databinding and custom coloring.
SVG rendering is done with Svg.Skia. If you need other ways to display SVG content, for example using the Svg control, please refer to their code examples. The Icon control is just an easy-to-use databindable and colorizable helper control.
The example project is quite minimal, but features the most prominent UI parts commonly found in desktop software: navigation, workspace, menu and status bar. Main Window is the root visual container, and hosts one Published View, the Shell. The Shell wires up the rest of the UI by specifying the general layout, and spawning other Published Views to fill those regions. Due to the "starting point" nature of the Shell it is also the most natural place to handle application-wide visual configuration such as theming and localization.
Home Screen, Menu and StatusBar are self-explanatory, so this section will focus on two topics, the Main Navigation and the idea behind the Settings view.
Applications often maintain a list of "Most Recently Used" files. Usually this list is accessible either by menu or some sort of start view. However in the demo application these project items have been combined into the main navigation bar on the left. The idea is simple: Since the nav bar already presents buttons for activating views - why not make recent projects just as easily accessible.
The navigation bar (ViewSelectorFeature) consists of three parts: At the top there is a static section of views - the Home button in this case. In the middle there is the project item list whose content is dynamic. And finally at the bottom there is another static section of views - the Settings button in this case.
The middle part occupies the remaining space, listing project items vertically. If the list takes more space than is available, it will scroll. The Add and Open buttons are appended to the project item list, however they will always remain on the screen and will not scroll as part of the items list.
You can easily generate new items to show by clicking on the Add button. The Open button will do the same thing, but shows how to use the Avalonia's Folder picking dialog. New items are automatically added to the end of the list, and then activated. They use seemingly random content (derived from the selected folder, for example). You can delete a project from the list via the right-click context menu, and selecting Remove. If you remove the currently active view, Home will be auto-selected.
Clicking an item in the navigation bar will load and display its view. The views are lazy-loaded so that their Published View is not instantiated until the view is requested. The Shell's view contains a Panel control (a container that just layers multiple controls in it) and it is controlled by the code-behind that is also published into the Host's service container. This is OK because the Shell view is basically a single instance and remains in place during the entire application life-time.
📜 NOTE:
The Published Views hosted in the Shell's "views panel" will have theirViewModel.Activatemethod called when the navigation bar activates their view the first time. Correspondingly theirViewModel.Deactivatemethod is called only if the view was previously activated and then the user manually removes the item from the project list. This is not the same as showing and hiding the view when the user navigates to another view. To detect when a certain view is shown or hidden, subscribe to theIViewSelector.ViewChangedevent.
Project items will activate a Published View from the ProjectFeature - with the associated ProjectItem passed to the creator method as ViewParameter. The same item is then also passed to the view model. The view model may then load the project's data (perhaps utilizing the ProjectItem.Path property).
The workspace here is quite minimal in terms of functionality, and it mainly shows a title, editable property, and the Dirty state. When the user types text in the TextBox, the IsDirty property is flagged true. Clicking on the "Save" button will clear the flag.
Since IsDirty is defined in ProjectItem, also the navigation bar can visualize this state. You will see an orange circle next to the project item's icon to denote that that project has "unsaved changes".
Speaking of unsaved changes, since the ViewSelectorFeature is aware of all projects (and is managing the project item list), it is the best feature to handle the exit behavior. In its ExitRequestedAsync method all dirty projects are checked, and if any, a dialog is shown to the user. The dialog lists all projects that have unsaved changes and asks whether they should be saved before exiting.
- The "Yes" button will call the
SaveAsyncdelegate (set by the view model) on all affected items and then continue (resulting in the app shutting down). - The "No" button will not do anything (resulting in the app shutting down).
- The "Cancel" button will just cancel the token (resulting in the app resuming normally).
It also demonstrates how to localize the dialog's content.
Host.Settings provides access to the common settings dictionary where features and view models may store key-value pairs that will persist through application restarts. You could provide a custom view to present such options in the UI, but the demo application also has a centralized place for all settings.
The Settings view can be activated via the Settings button at the bottom of the navigation bar. This view and all its associated functionality is managed by the AppSettingsFeature.
You can access the settings collection programmatically via the IAppSettings dependency. Using this interface you can create Categories and place Settings in them. (or access some pre-configured ones) and register Settings of different types into them. Categories may contain sub-categories, and you can build any kind of hierarchy this way.
The Category structure will translate into Headers (and their sub headers) and Settings in one big scrollable page. At the left side there is also a Table of Contents that consists of clickable "hyperlinks" that immediately scroll the page to that section.
Categories are accessed by name (or via a localization key to be more precise). The AppSettingsFeature will create these default Categories by default:
var general = this.RegisterCategory(CommonCategories.General);
var test1 = this.RegisterCategory(CommonCategories.Test1);
var test1sub1 = this.RegisterCategory(CommonCategories.Test1Sub1, test1);
var test2 = this.RegisterCategory(CommonCategories.Test2);They are created during InitializeAsync, so all other features should place their Settings to these Categories during StartingAsync. Use the constants found in the CommonCategories class to refer to these in-built Category names.
⚡ The CustomStartupFeature will generate some dummy Settings and Categories just for the show.
Although you may identify Categories and Settings using any string, it is recommended that you use a localization key instead. That way also your settings' language will change when the entire application Language changes. Moreover, you should provide at least the "en-us" ResourceDictionary for these setting names. For more information, see the Localization section earlier in this guide.
In the current implementation a Setting can be one of the following types:
IntegerSettingBooleanSettingStringSetting
Each of these will use their own DataTemplate for displaying the setting widget. If you want to add more setting types (for example a Folder picker setting etc.) just implement the ISetting and create the corresponding view model and DataTemplate (in AppSettingsView.axaml's Resources).
Here is how you would create a new integer setting and put it the in-built "General" category:
var mySetting = this.AppSettings!.RegisterIntegerSetting(
"MyFeature.ExampleSetting",
new[] { CommonCategories.General },
defaultValue: 0);The first argument should be a unique identifier. It is recommended to also use a localization key here.
The second argument is an array of category keys that denotes a path (i.e. a category plus possible sub-categories).
You may also specify the default value and a validator call-back. You can also subscribe to its ValueChanged event to get notified when the value is changed by the user (you might want to set a view model property accordingly, or update a Host.Setting value). If you do so, remember to also unsubscribe from the event when it is no longer needed.
✔️ DO prefix localization keys, Published View names, and setting keys with your feature name. For example if your feature is called "MyFeature", a localization key could be "MyFeature.AskUserConfirmation", a Published View name could be "MyFeature.Options" and a setting key could be "MyFeature.ShowDetails".
✔️ DO only have one feature responsible for checking whether there are unsaved changes (and displaying a dialog) before application exits.
❌ DO NOT set global locale in Feature code (System.Threading.Thread.CurrentThread.CurrentCulture) because it may cause unexpected behavior in different features (such as formatters producing wrong results).
The core design principle is that the application is built using self-contained Features. Think of a Feature like dedicated view or functionality such as Status Bar, Main Menu, Navigation Panel, or Workspace. The Shell Feature acts as the "root", defines the main layout, and places other features' views in those UI regions.
Features may define Published Views that bundle an Avalonia UserControl and a ViewModel together. Features may introduce ResourceDictionaries that are associated with a Language, and then those PublishedViews may use localizable resources (such as strings) from them via DynamicResource.
Features have life-time methods as follows:
InitializeAsyncis called for all features. Load data and prepare the feature's own state here.StartingAsyncis called for all features. Start services and connect to other features' functionality.RunningAsyncis called for all features. Main Window exists and the app is now running.- When the user attempts to exit the app,
ExitRequestedAsyncis called for all features. The operation can be canceled here. ShutdownAsyncis called for all features. Stop services, clean up, and save data here.
Derive your features from the App.Feature base class. Derive your view models from the ViewModel base class. Feature classes and view model classes have access to the Host (property). This instance provides many common services such as Settings, Localization, Messenger and a Service Container.
The Messenger can be used to send message objects to recipients in a decoupled way. Alternatively, feature classes may implement interfaces, and an instance to their implementation can be automatically injected into a view model's or feature's property using the DependencyAttribute.
You can use vector based (SVG) icons with the Icon control and easily colorize them. Vector graphics always render clearly without blurring, making them a great alternative to traditional bitmap images.
END OF GUIDE 😎