The purpose of this article is to continue explaining advanced Avalonia concepts using simple coding samples. In particular, the following topics are covered: Routed Events, Avalonia Commands, Avalonia User Controls, Avalonia Custom Controls, Data Templates and View Models.
Introduction
Note, this article has been updated to reflect Avalonia 11 changes and the samples referred here were updated to run under Avalonia 11.
This article can be considered as the fourth instalment in the following sequence of articles:
- Multiplatform UI Coding with AvaloniaUI in Easy Samples. Part 1 - AvaloniaUI Building Blocks
- Basics of XAML in Easy Samples for Multiplatform Avalonia .NET Framework
- Multiplatform Avalonia .NET Framework Programming Basic Concepts in Easy Samples
For general information about creating a project in Avalonia 11 and the general changes, please see the first article in the above series and also Multiplatform XAML/C# Miracle Package: Avalonia. Comparing Avalonia to WinUI based Solutions.
If you know WPF, you can read this article without reading the previous ones, otherwise, you should read the previous articles first.
About Avalonia
Avalonia is a new open source package which closely resembles WPF but, unlike WPF or UWP works on most platforms - Windows, MacOS and various flavors of Linus and is in many respects more powerful than WPF.
The reasons why Avalonia is a better framework than web programming frameworks or Xamarin is described in detail in the previous article: Multiplatform UI Coding with Avalonia in Easy Samples. Part 1 - Avalonia Building Blocks. Here, I am only going to reiterate the two main reasons:
- Avalonia 11 is a package that allows creating
- Desktop applications for Windows, Linux, MacOS
- Mobile apps for Android, iOS and Tizen
- Web applications (via WebAssembly)
- Avalonia code is 99% re-usable - very little platform dependent code is required and that only if you need to work with multiple windows. As long as you stay within a single window - Avalonia code is 100% re-usable and whatever works on Windows will also work as a Web application or as a mobile application.
- Avalonia code can be compiled very fast - allowing fast prototyping.
- Avalonia is also very performant - considerably better than any of its competitors.
- Avalonia framework (just like WPF) is 100% compositional - the simple button can be assembled out of primitives like geometric paths, borders and images in the same fashion in which very complex pages or views can be made. It is very much up to the developer to choose how the control would look and behave and which properties of it will be customizable. Moreover, the simpler primitives can be organized into more complex ones reducing the complexity. Neither HTML/JavaScript/TypeScript framework nor Xamarin are compositional to the same degree - in fact, their primitives are buttons and checkboxes and menus that come with many properties to modify for customization (some properties can be specific to a platform or a browser). In that respect, the Avalonia developer has much more freedom to create whatever controls the customer needs.
- WPF came up with a lot of new development paradigms that can help to develop visual applications considerably faster and cleaner among them are Visual and Logical Trees, Bindings, Attached Properties, Attached Routed Events, Data and Control Templates, Styles, Behaviors. Very few of these paradigms are implemented in Web frameworks and Xamarin and they are considerably less powerful there, while in Avalonia - all of them are implemented and some, e.g., properties and bindings are implemented even in a more powerful way than in WPF.
Purpose of this Article
The purpose of this article is to continue explaining advanced Avalonia concepts using simple coding samples.
Organization of this Article
The following topics will be covered:
- Routed Events
- Avalonia Commands
- Avalonia User Controls
- Avalonia Control Template and Custom Controls
- Data Templates and View Models
Code for the Samples
The code for the samples is located under the Demo Code for Avalonia Advanced Concepts Article. All the samples here have been tested on Windows 10, MacOS Catalina and Ubuntu 20.4
All the code should compile and run under Visual Studio 2019 - this is what I've been using. Also, make sure that your internet connect is on when you compile a sample for the first time since some nuget packages will have to be downloaded.
Explaining the Concepts
Routed Events
Routed Event Concepts
Same as WPF, Avalonia has a concept of Attached Routed Events that propagate up and down the Visual Tree. They are more powerful and easier to deal with than WPF Routed Events (as will be explained below).
Unlike the usual C# events, they:
- can be defined outside of the class that fires them and 'Attached' to the objects.
- can propagate up and down the WPF visual tree - in the sense that the event can be fired by one tree node and handled on another tree node (one of the firing node's ancestors).
There are three different modes of propagation for the routed events:
- Direct - This means that the event can only be handled on the same visual tree node that fires it.
- Bubbling - The event travels from the current node (the node that raises the event) to the root of the visual tree and can be handled anywhere on the way. For example, if the visual tree consists of the Window containing a
Grid
containing a Button
and a bubbling event is fired on the Button
, then this event will travel from the Button
to the Grid
and then to the Window
. - Tunneling - The event travels from the root node of the visual tree to the current node (the node that raises the event). Using the same example as above, the
Tunneling
event will first be raised on the Window, then on the Grid
and finally on the button
.
The following pictures depict bubbling and tunneling event propagation:
The Avalonia routed events are more powerful and logical than their WPF counterparts because in WPF, the event has to choose only one of the routing strategies - it can either be direct or bubbling or tunneling. In order to allow some preprocessing before handing the main (usually bubbling) events, many bubbling events have their tunneling peers firing before them - the so called Preview events. The preview events are completely different events in WPF and there is no logical connection (aside from their names) between them and the corresponding bubbling events.
In Avalonia, the same event can be registered to have multiple routing strategies - the so called Preview events are no longer necessary - since the same event can be first raised as a tunneling event (used for preview) and then as the bubbling event - doing the real stuff. This can also lead to errors, e.g., if you handle the event the same on the tunneling and bubbling state - the event might be handled twice instead of once. A simple filtering during the event handler subscription or a simple check within the event handler will resolve this problem.
Do not worry if you are not a WPF expert and a bit confused about the routed events - there will be examples to illustrate what was stated above.
Built-In Routed Event Example
There are many Routed Events that already exist in Avalonia (as there are many built-in events in WPF). We will demonstrate the routing event propagation by using PointerPressedEvent
routed event, which fires when a user presses mouse button on some visual element in Avalonia. WPF LeftMouseButtonDown
routed event is very similar to PointerPressedEvent
.
The sample code is located under NP.Demos.BuiltInRoutedEventSample solution.
Take a look at the very simple MainWindow.axaml file:
<Window x:Name="TheWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="NP.Demos.BuiltInRoutedEventSample.MainWindow"
Title="NP.Demos.BuiltInRoutedEventSample"
Background="Red"
Width="200"
Height="200">
<Grid x:Name="TheRootPanel"
Background="Green"
Margin="35">
<Border x:Name="TheBorder"
Background="Blue"
Margin="35"/>
</Grid>
</Window>
We have a Window
(with Red background) containing a Grid
with Green background containing a Border
with Blue background.
Run the project in the Visual Studio debugger - here is what you shall see:
Click on the blue square in the middle and take a look at the "Output" pane of the Visual Studio. Here is what you see there:
Tunneling Routed Event PointerPressed raised on TheWindow; Event Source is TheBorder
Tunneling Routed Event PointerPressed raised on TheRootPanel; Event Source is TheBorder
Tunneling Routed Event PointerPressed raised on TheBorder; Event Source is TheBorder
Bubbling Routed Event PointerPressed raised on TheBorder; Event Source is TheBorder
Bubbling Routed Event PointerPressed raised on TheRootPanel; Event Source is TheBorder
Bubbling Routed Event PointerPressed raised on TheWindow; Event Source is TheBorder
The event travels as the tunneling event from window to the blue border first and then as bubbling event in the opposite direction.
Now take a look at MainWindow.axaml.cs file that contains all the code for handling the event and assigning the handlers:
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
...
this.AddHandler
(
Control.PointerPressedEvent,
HandleClickEvent,
RoutingStrategies.Bubble | RoutingStrategies.Tunnel
);
Grid rootPanel = this.FindControl<Grid>("TheRootPanel");
rootPanel.AddHandler
(
Control.PointerPressedEvent,
HandleClickEvent,
RoutingStrategies.Bubble | RoutingStrategies.Tunnel);
Border border = this.FindControl<Border>("TheBorder");
border.AddHandler(
Control.PointerPressedEvent,
HandleClickEvent,
RoutingStrategies.Bubble | RoutingStrategies.Tunnel);
}
private void HandleClickEvent(object? sender, RoutedEventArgs e)
{
Control senderControl = (Control) sender!;
string eventType = e.Route switch
{
RoutingStrategies.Bubble => "Bubbling",
RoutingStrategies.Tunnel => "Tunneling",
_ => "Direct"
};
Debug.WriteLine($"{eventType} Routed Event {e.RoutedEvent!.Name}
raised on {senderControl.Name}; Event Source is {(e.Source as Control)!.Name}");
}
}
We assign the handlers to the Window
, Grid
and Border
by using AddHandler
method. Let us take a closer look at one of them:
this.AddHandler
(
Control.PointerPressedEvent,
HandleClickEvent,
RoutingStrategies.Bubble | RoutingStrategies.Tunnel
);
First argument to AddHandler
is the RoutedEvent
- a static
object that contains a map of visual objects into event handlers. This is analogous to the AttachedProperty
object maintaining a map of visual object into object values. Same as AttachedProperty
, the RoutedEvent
can be defined outside of the class and will not affect the memory except for objects that have handlers for it.
Second argument is the event handler method HandleClickEvent
. Here is the method implementation:
private void HandleClickEvent(object? sender, RoutedEventArgs e)
{
Control senderControl = (Control) sender!;
string eventTypeString = e.Route switch
{
RoutingStrategies.Bubble => "Bubbling",
RoutingStrategies.Tunnel => "Tunneling",
_ => "Direct"
};
Debug.WriteLine($"{eventTypeStr} Routed Event {e.RoutedEvent!.Name}
raised on {senderControl.Name}; Event Source is {(e.Source as Control)!.Name}");
...
}
All it does is just writing the sentence to Debug
output (for Visual Studio Debugger, it means that it is writing it into the Output pane).
Third argument (RoutingStrategies.Bubble | RoutingStrategies.Tunnel
) is the routing strategy filter. For example, if you remove RoutingStrategies.Tunnel
from it, it will start reacting only to bubbling event run (try doing it as an exercise). By default, it is set to RoutingStrategies.Direct | RoutingStrategies.Bubble
.
Note that all (or almost all) built-in Routed Events have their plain C# event counterparts which are raised when the routed event is raised. We could have used, e.g., PointerPressed
C# event connecting it to HandleClickEvent
handler:
rootPanel.PointerPressed += HandleClickEvent;
But in that case, we would not be able to choose the RoutingStrategies
filtering (it would have remained default - RoutingStrategies.Direct | RoutingStrategies.Bubble
). Also, we would not be able to choose an important handledEventsToo
argument which is going to be explained shortly.
At the end of the HandleClickEvent
method, there are several extra commented out lines of code which you should uncomment now:
if (e.Route == RoutingStrategies.Bubble && senderControl.Name == "TheBorder")
{
e.Handled = true;
}
The purpose of this code is to set the event to Handled
once it does all the tunneling and bubbles first time on the border. Try running the application again and click on the Blue border. Here is what will print in the Output pane of the Visual Studio:
Tunneling Routed Event PointerPressed raised on TheWindow; Event Source is TheBorder
Tunneling Routed Event PointerPressed raised on TheRootPanel; Event Source is TheBorder
Tunneling Routed Event PointerPressed raised on TheBorder; Event Source is TheBorder
Bubbling Routed Event PointerPressed raised on TheBorder; Event Source is TheBorder
Since the event has been handled after the first bubbling on the border, the handlers higher up on the Visual Tree (those on the Grid and Window) will not be fired any more. There is however a way to force them to fire even on a routed event that had already been handled. For example, in order to do it on the Window level, uncomment the last argument of AddHandler(...)
call on the Window:
this.AddHandler
(
Control.PointerPressedEvent,
HandleClickEvent,
RoutingStrategies.Bubble | RoutingStrategies.Tunnel
,true
);
Last argument is called handledEventsToo
and if it is true
- it fires the corresponding handler also on events that had been handled before. By default, it is false
.
After uncommenting, run the application again and press the mouse button on the Blue border. Here is what the output is going to be:
Tunneling Routed Event PointerPressed raised on TheWindow; Event Source is TheBorder
Tunneling Routed Event PointerPressed raised on TheRootPanel; Event Source is TheBorder
Tunneling Routed Event PointerPressed raised on TheBorder; Event Source is TheBorder
Bubbling Routed Event PointerPressed raised on TheBorder; Event Source is TheBorder
Bubbling Routed Event PointerPressed raised on TheWindow; Event Source is TheBorder
The last line show that the bubbling pass of the event was raised (and also handled) on the window, even though the event had been marked as handled before.
Now start the Avalonia Development Tool by mouse-clicking on the window of the sample and pressing F12. Click on the "Events" tab and out of all events displayed on the left pane, choose PointerPressed
to be checked and undo the check on the rest of them:
After that, press on the blue border within the application, an entry for the event will show the main window:
Now mouse-click on the event entry in the main window - the Event Chain pane will show how the event was propagating on the visual tree:
Unfortunately currently, the Event Chain of the tool shows only the propagation of the unhandled event. It stops showing at the last point when the event was unhandled - in our case, the first item of the bubble pass. You can see that there are more instances of the tunneling of our event shown in the tool than in our previous printing. This is because the tool shows all elements within the Visual tree that the event is being raised on, while we only connected the handler to the Window
, the Grid
and the Border
.
Custom Routed Event Sample
This sample is located within NP.Demos.CustomRoutedEventSample solution. It is very similar to the previous sample, only here we fire the custom routed event MyCustomRoutedEvent
defined within StaticRoutedEvents.cs file:
using Avalonia.Interactivity;
namespace NP.Demos.CustomRoutedEventSample
{
public static class StaticRoutedEvents
{
public static readonly RoutedEvent<RoutedEventArgs> MyCustomRoutedEvent =
RoutedEvent.Register<object, RoutedEventArgs>
(
"MyCustomRouted",
RoutingStrategies.Tunnel
);
}
}
As you see, defining the event is very simple - just calling RoutedEvent.Register(...)
method passing the name of the event and the routing strategy.
MainWindow.axaml file is exactly the same as in the previous section. MainWindow.axaml.cs code is also very similar to one of the previous sections, only here we handle MyCustomRoutedEvent
, e.g.:
this.AddHandler
(
StaticRoutedEvents.MyCustomRoutedEvent,
HandleCustomEvent,
RoutingStrategies.Bubble | RoutingStrategies.Tunnel
);
We also add some code to raise the MyCustomRoutedEvent
when mouse is pressed on the blue border:
border.PointerPressed += Border_PointerPressed;
}
private void Border_PointerPressed(object? sender, PointerPressedEventArgs e)
{
Control control = (Control)sender!;
control.RaiseEvent(new RoutedEventArgs(StaticRoutedEvents.MyCustomRoutedEvent));
}
The code line for raising the event specifically is:
control.RaiseEvent(new RoutedEventArgs(StaticRoutedEvents.MyCustomRoutedEvent));
Here is the (almost) full code-behind from MainWindow.axaml.cs file:
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
...
this.AddHandler
(
StaticRoutedEvents.MyCustomRoutedEvent,
HandleClickEvent,
RoutingStrategies.Bubble | RoutingStrategies.Tunnel
);
Grid rootPanel = this.FindControl<Grid>("TheRootPanel");
rootPanel.AddHandler
(
StaticRoutedEvents.MyCustomRoutedEvent,
HandleCustomEvent,
RoutingStrategies.Bubble | RoutingStrategies.Tunnel);
Border border = this.FindControl<Border>("TheBorder");
border.AddHandler(
StaticRoutedEvents.MyCustomRoutedEvent,
HandleCustomEvent,
RoutingStrategies.Bubble | RoutingStrategies.Tunnel
);
border.PointerPressed += Border_PointerPressed;
}
private void Border_PointerPressed(object? sender, PointerPressedEventArgs e)
{
Control control = (Control)sender!;
control.RaiseEvent(new RoutedEventArgs(StaticRoutedEvents.MyCustomRoutedEvent));
}
private void HandleCustomEvent(object? sender, RoutedEventArgs e)
{
Control senderControl = (Control) sender!;
string eventTypeStr = e.Route switch
{
RoutingStrategies.Bubble => "Bubbling",
RoutingStrategies.Tunnel => "Tunneling",
_ => "Direct"
};
Debug.WriteLine($"{eventTypeStr} Routed Event
{e.RoutedEvent!.Name} raised on {senderControl.Name};
Event Source is {(e.Source as Control)!.Name}");
}
...
}
When we run the project and click on the blue square in the middle, the following will be printed onto the Visual Studio Output pane:
Tunneling Routed Event MyCustomRouted raised on TheWindow; Event Source is TheBorder
Tunneling Routed Event MyCustomRouted raised on TheRootPanel; Event Source is TheBorder
Tunneling Routed Event MyCustomRouted raised on TheBorder; Event Source is TheBorder
Note that only tunneling pass is being handled. This is because we defined the event to be a pure tunneling event by passing its last argument to be RoutingStrategies.Tunnel
. If we change it to RoutingStrategies.Tunnel | RoutingStrategies.Bubble
, and restart the solution again, we shall see both tunneling and bubbling passes:
Tunneling Routed Event MyCustomRouted raised on TheWindow; Event Source is TheBorder
Tunneling Routed Event MyCustomRouted raised on TheRootPanel; Event Source is TheBorder
Tunneling Routed Event MyCustomRouted raised on TheBorder; Event Source is TheBorder
Bubbling Routed Event MyCustomRouted raised on TheBorder; Event Source is TheBorder
Bubbling Routed Event MyCustomRouted raised on TheRootPanel; Event Source is TheBorder
Bubbling Routed Event MyCustomRouted raised on TheWindow; Event Source is TheBorder
Avalonia Commands
Command Concepts
When someone builds an application, it is customary to place the logic controlling the visuals into some non-visual classes (called the View Models) and then use bindings and other ways of connecting your visuals in XAML to the View Models. The idea behind it is that the non-visual objects are much simpler and easier to test than the Visuals therefore if you deal primarily with non-visual objects, you'll have easier coding and testing. Such pattern is called MVVM.
Command provides a way to execute some C# methods within the View Model when a Button
or a MenuItem
is clicked.
Avalonia Button
and MenuItem
each have a property Command
which can be bound to a Command
defined within a view model. Such command can execute a View Model method hooked to it. Avalonia does not have its own command implementation, but it is recommended to use ReactiveUI's ReactiveCommand
. One can also control whether the Button
(or a MenuItem
) is enabled or not via a command object placed within the View Model.
Yet such approach of placing the commands within the View Models has major drawbacks:
- It forces the View Models to depend on visual .NET assemblies (which implement the commands). This breaks the hard barrier that should be placed between the non-visual View Models and the Visuals. After that, it becomes much more difficult to control (especially on a project with many developers) that the visual code does not leak into the View Models.
- It unnecessarily pollutes the View Models.
Avalonia therefore provides a considerably cleaner way of calling a method on a View Model - by binding the Command to the method's name.
Using Avalonia Commands for Calling Methods on a View Model
Run this sample located under NP.Demos.CommandSample solution. Here is what you'll see:
There is a Status
field value shown in the middle of the window. When you press "Toggle Status" button, it will toggle between True
and False
.
Note Avalonia 11 - Change
With previous version of Avalonia, Set Status to True" button was enabled, and clicking it was setting the status value to True
and unchecking "Can Toggle Status" checkbox will disable "Toggle Status" button.
The latest version of Avalonia (11), however - would only bind to a method without parameters or a method a single parameter of type object
. Since our method SetStatus(bool status)
has a parameter of type bool
and not of type object
, the button bound to it will be disabled. This is btw so far the only feature regression I managed to find in Avalonia 11 in comparison to Avalonia 10 and it is easy to get around this problem by using behaviors or DelegateCommands
.
Take a look at the file called ViewModel.cs. It contains purely non-visual code:
public class ViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged(string propName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propName));
}
#region Status Property
private bool _status;
public bool Status
{
get
{
return this._status;
}
set
{
if (this._status == value)
{
return;
}
this._status = value;
this.OnPropertyChanged(nameof(Status));
}
}
#endregion Status Property
#region CanToggleStatus Property
private bool _canToggleStatus = true;
public bool CanToggleStatus
{
get
{
return this._canToggleStatus;
}
set
{
if (this._canToggleStatus == value)
{
return;
}
this._canToggleStatus = value;
this.OnPropertyChanged(nameof(CanToggleStatus));
}
}
#endregion CanToggleStatus Property
public void ToggleStatus()
{
Status = !Status;
}
public void SetStatus(bool status)
{
Status = status;
}
}
It provides:
- a Boolean property
Status
ToggleStatus()
method that toggles the Status
property SetStatus(bool status)
method that set Status
property to whatever argument was passed to it CanToggleStatus
property that controls whether ToggleStatus()
action is enabled or not.
Whenever any property changes, the PropertyChanged
event fires, so that the Avalonia bindings will be notified about the property change.
MainWindow
constructor located within MainWindow.axaml.cs file sets the DataContext
of the Window
to be an instance of our ViewModel
class.
public MainWindow()
{
InitializeComponent();
...
this.DataContext = new ViewModel();
}
DataContext
is a special StyledProperty
that is inherited by the descendants of the visual tree (unless changed explicitly), so it will also be the same for the window descendants.
Here is the content of MainWindow.axaml file:
<Window x:Name="TheWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="NP.Demos.CommandSample.MainWindow"
Title="NP.Demos.CommandSample"
Width="200"
Height="300">
<Grid x:Name="TheRootPanel"
RowDefinitions="*, *, *, *"
Margin="20">
<CheckBox IsChecked="{Binding Path=CanToggleStatus, Mode=TwoWay}"
Content="Can Toggle Status"
HorizontalAlignment="Left"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding Path=Status, StringFormat='Status={0}'}"
Grid.Row="1"
HorizontalAlignment="Left"
VerticalAlignment="Center"/>
<Button Content="Toggle Status"
Grid.Row="2"
HorizontalAlignment="Right"
VerticalAlignment="Center"
IsEnabled="{Binding Path=CanToggleStatus}"
Command="{Binding Path=ToggleStatus}"/>
<Button Content="Set Status to True"
Grid.Row="3"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Command="{Binding Path=SetStatus}"
CommandParameter="True"/>
</Grid>
</Window>
Checkbox
at the top has its IsChecked
property two-way bound to CanToggleStatus
of the ViewModel
:
<CheckBox IsChecked="{Binding Path=CanToggleStatus, Mode=TwoWay}"
Content="Can Toggle Status"
.../>
so that when it changes, the corresponding property changes also.
TextBlock
displays status (true
or false
):
<TextBlock Text="{Binding Path=Status, StringFormat='Status={0}'}"
... />
Top Button (via its command calls ToggleStatus()
method on the ViewModel
and its IsEnabled
property is bound to CanToggleStatus
property on the ViewModel
:
<Button Content="Toggle Status"
...
IsEnabled="{Binding Path=CanToggleStatus}"
Command="{Binding Path=ToggleStatus}"/>
The bottom button is there to demonstrate calling a method with an argument on the view model. Its Command
property is bound to SetStatus(bool status)
method that has one Boolean
argument - status
. To pass this argument, we set the CommandParameter
property to "True
":
<Button Content="Set Status to True"
Grid.Row="3"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Command="{Binding Path=SetStatus}"
CommandParameter="True"/>
Avalonia User Controls
User controls is something that almost never should be created or used since for controls, look-less (also called custom) controls are more powerful and have better separation between visual and non-visual concerns and the for Views of the MVVM pattern, DataTemplates
are better.
Yet, the Avalonia tale will not be complete unless we speak about UserControls
. They are also the easiest to create and understand.
The sample's code is located under NP.Demos.UserControlSample solution:
It contains MyUserControl
user control:
To create such User Control from scratch - use Add->New Item context menu and then, in the opened dialog, choose Avalonia on the left and "User Control (Avalonia)" on the right and then press Add button.
Run the sample, here is the window that pops up:
Start typing within the TextBox
. Buttons "Cancel" and "Save" will become enabled. If you press Cancel, the text will revert to the saved value (in the beginning, it is empty). If you press Save, the new saved value will become whatever currently is in the TextBox
. The buttons "Cancel" and "Save" are disabled when the Entered text is the same as the Saved Text and are enabled otherwise:
MainWindow.axaml file has only one non-trivial element: MyUserControl
:
<Window x:Name="TheWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="NP.Demos.UserControlSample.MainWindow"
xmlns:local="clr-namespace:NP.Demos.UserControlSample"
...>
<local:MyUserControl Margin="20"/>
</Window>
MainWindow.axaml.cs file does not have any non-default code, so all the code for this functionality is located within MyUserControl.axaml and MyUserControl.axaml.cs files - the C# file is just the code behind for the XAML file.
Here is the content of MyUserControl.axaml file:
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="NP.Demos.UserControlSample.MyUserControl">
<Grid RowDefinitions="Auto, Auto, *, Auto">
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Left"
VerticalAlignment="Center">
<TextBlock Text="Enter Text: "
VerticalAlignment="Center"/>
<TextBox x:Name="TheTextBox"
MinWidth="150"/>
</StackPanel>
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Grid.Row="1"
Margin="0,10">
<TextBlock Text="Saved Text: "
VerticalAlignment="Center"/>
<TextBlock x:Name="SavedTextBlock"/>
</StackPanel>
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Right"
Grid.Row="3">
<Button x:Name="CancelButton"
Content="Cancel"
Margin="5,0"/>
<Button x:Name="SaveButton"
Content="Save"
Margin="5,0"/>
</StackPanel>
</Grid>
</UserControl>
It has no bindings, and no commands - just a passive arrangement of various Visual elements.
The functionality that makes it all work is located in the code-behind file MyUserControl.axaml.cs:
public partial class MyUserControl : UserControl
{
private TextBox _textBox;
private TextBlock _savedTextBlock;
private Button _cancelButton;
private Button _saveButton;
private string? SavedValue
{
get => _savedTextBlock.Text;
set => _savedTextBlock.Text = value;
}
private string? NewValue
{
get => _textBox.Text;
set => _textBox.Text = value;
}
public MyUserControl()
{
InitializeComponent();
_cancelButton = this.FindControl<Button>("CancelButton");
_cancelButton.Click += OnCancelButtonClick;
_saveButton = this.FindControl<Button>("SaveButton");
_saveButton.Click += OnSaveButtonClick;
_savedTextBlock = this.FindControl<TextBlock>("SavedTextBlock");
_textBox = this.FindControl<TextBox>("TheTextBox");
NewValue = SavedValue;
_textBox.GetObservable(TextBox.TextProperty).Subscribe(OnTextChanged);
}
private void OnCancelButtonClick(object? sender, RoutedEventArgs e)
{
NewValue = SavedValue;
}
private void OnSaveButtonClick(object? sender, RoutedEventArgs e)
{
SavedValue = NewValue;
OnTextChanged(null);
}
private void OnTextChanged(string? obj)
{
bool canSave = NewValue != SavedValue;
_cancelButton.IsEnabled = canSave;
_saveButton.IsEnabled = canSave;
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}
The visual elements defined within MyUserControl.xaml file are obtained inside the C# code by using FindControl<TElement>("ElementName")
method, e.g.:
_cancelButton = this.FindControl<Button>("CancelButton");
Then the button's Click
event is assigned a handler, e.g.:
_cancelButton.Click += OnCancelButtonClick;
All the interesting processing is done inside the Click
event handlers and inside the TextBox
'es Text
observable subscription:
_textBox.GetObservable(TextBox.TextProperty).Subscribe(OnTextChanged);
The main problem with the User Control is that we are tightly coupling the visual representation provided by MyUserControl.axaml file and the C# logic contained within MyUserControl.axaml.cs file.
Using Custom Control, we can completely separate them as will be shown below.
Also, the visual representation can be separated from the C# logic using View-ViewModel part of MVVM pattern, so that one could use completely different visual representations (furnished by different DataTemplates
) with the same View Model that defines the business logic. Such MVVM example will be given below.
Avalonia ControlTemplates and Custom Controls
You can find this sample under NP.Demos.CustomControlSample solution. The sample behaves in exactly the same way as the previous sample, but is built very differently. All non-default C# functionality is located under MyCustomControl.cs file:
Here is its code:
public class MyCustomControl : TemplatedControl
{
#region NewValue Styled Avalonia Property
public string? NewValue
{
get { return GetValue(NewValueProperty); }
set { SetValue(NewValueProperty, value); }
}
public static readonly StyledProperty<string?> NewValueProperty =
AvaloniaProperty.Register<MyCustomControl, string?>
(
nameof(NewValue)
);
#endregion NewValue Styled Avalonia Property
#region SavedValue Styled Avalonia Property
public string? SavedValue
{
get { return GetValue(SavedValueProperty); }
set { SetValue(SavedValueProperty, value); }
}
public static readonly StyledProperty<string?> SavedValueProperty =
AvaloniaProperty.Register<MyCustomControl, string?>
(
nameof(SavedValue)
);
#endregion SavedValue Styled Avalonia Property
#region CanSave Direct Avalonia Property
private bool _canSave = default;
public static readonly DirectProperty<MyCustomControl, bool> CanSaveProperty =
AvaloniaProperty.RegisterDirect<MyCustomControl, bool>
(
nameof(CanSave),
o => o.CanSave
);
public bool CanSave
{
get => _canSave;
private set
{
SetAndRaise(CanSaveProperty, ref _canSave, value);
}
}
#endregion CanSave Direct Avalonia Property
private void SetCanSave(object? _)
{
CanSave = SavedValue != NewValue;
}
public MyCustomControl()
{
this.GetObservable(NewValueProperty).Subscribe(SetCanSave);
this.GetObservable(SavedValueProperty).Subscribe(SetCanSave);
}
public void Save()
{
SavedValue = NewValue;
}
public void Cancel()
{
NewValue = SavedValue;
}
}
Do not be scared by the number of lines, most of the code is there because of the StyledProperty
and DirectProperty
definitions and was created by the snippets avsp and avdr available and described at Avalonia Snippets.
There are two Styled Properties: NewValue
and SavedValue
and one Direct Property: CanSave
. Whenever any of the styled properties change, the direct property is reevaluated to be false
if and only if NewValue == SavedValue
. This is achieved by subscribing to NewValue
and SavedValue
changes within the class constructor:
public MyCustomControl()
{
this.GetObservable(NewValueProperty).Subscribe(SetCanSave);
this.GetObservable(SavedValueProperty).Subscribe(SetCanSave);
}
and by setting it within the callback SetCanSave(...)
method:
private void SetCanSave(object? _)
{
CanSave = SavedValue != NewValue;
}
The unneeded argument to this method is passed in order for its signature to match the one required by Subscribe(...)
method.
There are also two public
methods to be called by the Button
s' commands: void Save()
and void Cancel()
:
public void Save()
{
SavedValue = NewValue;
}
public void Cancel()
{
NewValue = SavedValue;
}
The difference between this C# file and the MyUserControl.axaml.cs file (which we described in the above section) is that this file is completely unaware of the XAML implementation and does not have any references to XAML elements.
Instead, the XAML built as a ControlTemplate
within MainWindow.axaml file refers to the properties and methods defined within MyCustomControl.cs file via bindings and commands.
First of all, notice that we derived our MyCustomControl
class from TemplatedControl
:
public class MyCustomControl : TemplatedControl
{
...
}
Because of that, it has Template
property of ControlTemplate
type which we can set to any object of this type. Here is the corresponding XAML code located within the MainWindow.axaml file:
<Window x:Name="TheWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="NP.Demos.CustomControlSample.MainWindow"
xmlns:local="clr-namespace:NP.Demos.CustomControlSample"
...>
<local:MyCustomControl Margin="20">
<local:MyCustomControl.Template>
<ControlTemplate TargetType="local:MyCustomControl">
<Grid RowDefinitions="Auto, Auto, *, Auto">
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Left"
VerticalAlignment="Center">
<TextBlock Text="Enter Text: "
VerticalAlignment="Center"/>
<TextBox x:Name="TheTextBox"
Text="{Binding Path=NewValue, Mode=TwoWay,
RelativeSource={RelativeSource TemplatedParent}}"
MinWidth="150"/>
</StackPanel>
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Grid.Row="1"
Margin="0,10">
<TextBlock Text="Saved Text: "
VerticalAlignment="Center"/>
<TextBlock x:Name="SavedTextBlock"
Text="{TemplateBinding SavedValue}"/>
</StackPanel>
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Right"
Grid.Row="3">
<Button x:Name="CancelButton"
Content="Cancel"
Margin="5,0"
IsEnabled="{TemplateBinding CanSave}"
Command="{Binding Path=Cancel,
RelativeSource={RelativeSource TemplatedParent}}"/>
<Button x:Name="SaveButton"
Content="Save"
Margin="5,0"
IsEnabled="{TemplateBinding CanSave}"
Command="{Binding Path=Save,
RelativeSource={RelativeSource TemplatedParent}}"/>
</StackPanel>
</Grid>
</ControlTemplate>
</local:MyCustomControl.Template>
</local:MyCustomControl>
</Window>
We set the Template
property to the ControlTemplate
object via the following lines:
<local:MyCustomControl Margin="20">
<local:MyCustomControl.Template>
<ControlTemplate TargetType="local:MyCustomControl">
...
Note that we are populating the Template
property in line - which is good for prototyping, but bad for the reuse. Usually the Control Template is created as a resource in some resource file and then we use {StaticResource <ResourceKey>}
markup extension to set the Template
property. So the lines above would look like:
<local:MyCustomControl Margin="20"
Template="{StaticResource MyCustomControlTemplate}">
This way, we'd be able to re-use the same template for multiple controls. Alternatively, we can place the Control Templates with Styles and use Styles for our custom controls, but this will be explained in a future article.
Note that we specify the TargetType
of the ControlTemplate
:
<ControlTemplate TargetType="local:MyCustomControl">
This will allow us to connect to the properties defined by MyCustomControl
class by using TemplateBinding
or {RelativeSource TemplatedParent}
.
The TextBox
is bound to the NewValue
property of the control in the TwoWay
mode, so that changes of one will affect the other:
<TextBox x:Name="TheTextBox"
Text="{Binding Path=NewValue, Mode=TwoWay,
RelativeSource={RelativeSource TemplatedParent}}"
MinWidth="150"/>
"SavedTextBlock
" TextBlock
is bound to SavedValue
:
<TextBlock x:Name="SavedTextBlock"
Text="{TemplateBinding SavedValue}"/>
And the buttons' commands are bound to the corresponding public
methods: Cancel()
and Save()
, while the buttons' IsEnabled
property is bound to CanSave
property of the control:
<Button x:Name="CancelButton"
Content="Cancel"
Margin="5,0"
IsEnabled="{TemplateBinding CanSave}"
Command="{Binding Path=Cancel, RelativeSource={RelativeSource TemplatedParent}}"/>
<Button x:Name="SaveButton"
Content="Save"
Margin="5,0"
IsEnabled="{TemplateBinding CanSave}"
Command="{Binding Path=Save, RelativeSource={RelativeSource TemplatedParent}}"/>
NP.Demos.DifferentVisualsForCustomControlSample shows exactly the same custom control displayed in two different ways:
The representation at the top is the same as in the previous sample - while at the bottom, I changed the row orders, so that the buttons are at the top, saved text in the middle and TextBox
is at the bottom. That would not be possible with the User Control.
Take a look at the code of the sample. Templates for both visual representations are located within Resources.axaml file under Themes project folder. MainWindow.axaml file contains a ResourceInclude
for that file and StaticResource
references to the two implementations - CustomControlTemplate1
and CustomControlTemplate2
:
<Window x:Name="TheWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="NP.Demos.DifferentVisualsForCustomControlSample.MainWindow"
xmlns:local="clr-namespace:NP.Demos.DifferentVisualsForCustomControlSample"
...>
<Window.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source=
"avares://NP.Demos.DifferentVisualsForCustomControlSample/Themes/Resources.axaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Window.Resources>
<Grid RowDefinitions="*, *">
<local:MyCustomControl Margin="20"
Template="{StaticResource CustomControlTemplate1}"/>
<local:MyCustomControl Margin="20"
Grid.Row="1"
Template="{StaticResource CustomControlTemplate2}"/>
</Grid>
</Window>
DataTemplates and View Models
Introduction to View / View Models Concepts
MVVM is an abbreviation for Model-View-View Model pattern.
View is the visuals that determine the look, feel and visual behaviors of the application.
View Model is a completely non-visual class or set of classes that has two major roles:
- It provides some functionality that the view can mimic or call via bindings, commands or by other means, e.g., behaviors. For example, the View Model can have a method
void SaveAction()
and a property IsSaveActionAllowed
, while the View will have a button calling SaveAction()
method whose IsEnabled
property will be bound to IsSaveActionAllowed
property on the View Model. - It wraps the model (e.g., data that comes from the backend), provides notifications to the view if Model changed and vice versa and can also provide the communication functionality between different View Models and models.
In this article, we are not interested in communications between the View Model and the Model - this is an important topic which deserves an article of its own. Instead, we shall concentrate here on the View - View Model (VVM) part of the MVVM pattern.
VVM pattern is best achieved in Avalonia by using ContentPresenter
(for a single object) or ItemsPresenter
for a collection of objects.
ContentPresenter
with the help of a DataTemplate
converts a non-visual object into a visual object (a view).
Content
property of ContentPresenter
is usually set to a non-visual object, while ContentTemplate
should be set to a DataTemplate
. ContentPresenter
combines them into a visual object (View
) where the DataContext
is given by the ContentPresenter's
Content
property while the Visual tree is provided by the DataTemplate
.
ItemsControl
with the help of an ItemTemplate
(of type DataTemplate
) converts a collection of non-visual objects into a collection of visual objects, each containing a ContentPresenter
that converts the individual View Model item within the collection into a Visual object. The Visual objects are arranged according to the panel provided by ItemsControl.ItemsPanel
property value.
Note the change in Avalonia 11: in previous version of Avalonia (Avalonia 10), it was ItemsPresenter that was performing this function. In Avalonia 11, however, ItemsPresenter
is simply part of ItemsControl
and should not be used outside of it (which is closer to how WPF worked). Also instead of Items
property of ItemsPresenter
for non-visual collection, one needs to use ItemsSource
property of ItemsControl
instead (also closer to WPF).
ItemsSource
property of ItemsControl
can contain a collection of non-visual objects. ItemTemplate
has DataTemplate
object in it and ItemsControl
combines them into a collection of Visual objects.
ContentPresenter Sample
Code for this sample is located in NP.Demos.ContentPresenterSample solution. The demo application behaves in exactly the same fashion as the samples in "Avalonia User Controls" and "Avalonia Control Templates and Custom Controls" sections.
Start typing within the TextBox
. Buttons "Cancel" and "Save" will become enabled. If you press Cancel, the text will revert to the saved value (in the beginning, it is empty). If you press Save, the new saved value will become whatever currently is in the TextBox
. The buttons "Cancel" and "Save" are disabled when the Entered text is the same as the Saved Text and enabled otherwise.
Unlike in previous cases, we are not creating either User or Custom Controls to achieve this. Instead, we are using a completely non-visual View Model and a DataTemplate
married together by a ContentPresenter
.
The important code is located within ViewModel.cs and MainWindow.axaml files.
Here is the content of ViewModel.cs file:
using System.ComponentModel;
namespace NP.Demos.ContentPresenterSample
{
public class ViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged(string propName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propName));
}
#region SavedValue Property
private string? _savedValue;
public string? SavedValue
{
get
{
return this._savedValue;
}
private set
{
if (this._savedValue == value)
{
return;
}
this._savedValue = value;
this.OnPropertyChanged(nameof(SavedValue));
this.OnPropertyChanged(nameof(CanSave));
}
}
#endregion SavedValue Property
#region NewValue Property
private string? _newValue;
public string? NewValue
{
get
{
return this._newValue;
}
set
{
if (this._newValue == value)
{
return;
}
this._newValue = value;
this.OnPropertyChanged(nameof(NewValue));
this.OnPropertyChanged(nameof(CanSave));
}
}
#endregion NewValue Property
public bool CanSave => NewValue != SavedValue;
public void Save()
{
SavedValue = NewValue;
}
public void Cancel()
{
NewValue = SavedValue;
}
}
}
We have NewValue
and SavedValue
string
properties that fire PropertyChanged
notification event when either of them is changed. They also notify of possible change of CanSave
Boolean
property that is true
if and only if NewValue
and SavedValue
are not the same:
public bool CanSave => NewValue != SavedValue;
There are also two public
methods for saving and cancelling:
public void Save()
{
SavedValue = NewValue;
}
public void Cancel()
{
NewValue = SavedValue;
}
MainWindow.axaml file defines the ViewModel
instance and the DataTemplate
as Resources and the ContentPresenter
that marries them. Here is the ContentPresenter
:
<Window ...>
<Window.Resources>
...
</Window.Resources>
<ContentPresenter Margin="20"
Content="{StaticResource TheViewModel}"
ContentTemplate="{StaticResource TheDataTemplate}"/>
</Window>
The instance of the view model and the data template are assigned to the Content
and ContentTemplate
properties of the ContentPresenter
via StaticResource
markup extension.
Here is how we define an instance of the ViewModel
as a Window
resource:
<Window ...>
<Window.Resources>
<local:ViewModel x:Key="TheViewModel"/>
...
</Window.Resources>
...
</Window>
And here is how we define the DataTemplate
:
<Window ...>
<Window.Resources>
<local:ViewModel x:Key="TheViewModel"/>
<DataTemplate x:Key="TheDataTemplate">
<Grid RowDefinitions="Auto, Auto, *, Auto">
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Left"
VerticalAlignment="Center">
<TextBlock Text="Enter Text: "
VerticalAlignment="Center"/>
<TextBox x:Name="TheTextBox"
Text="{Binding Path=NewValue, Mode=TwoWay}"
MinWidth="150"/>
</StackPanel>
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Grid.Row="1"
Margin="0,10">
<TextBlock Text="Saved Text: "
VerticalAlignment="Center"/>
<TextBlock x:Name="SavedTextBlock"
Text="{Binding Path=SavedValue}"/>
</StackPanel>
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Right"
Grid.Row="3">
<Button x:Name="CancelButton"
Content="Cancel"
Margin="5,0"
IsEnabled="{Binding Path=CanSave}"
Command="{Binding Path=Cancel}"/>
<Button x:Name="SaveButton"
Content="Save"
Margin="5,0"
IsEnabled="{Binding Path=CanSave}"
Command="{Binding Path=Save}"/>
</StackPanel>
</Grid>
</DataTemplate>
</Window.Resources>
...
</Window>
Remember that the ViewModel
object provided as Content
property to the ContentPresenter
becomes a DataContext
for the Visuals created by the DataTemplate
, so we can bind the properties on the DataTemplate
to the properties of the View Model without specifying the binding's source object (since DataContext
is the default source of the binding).
We bind the TextBox
to the NewValue
property of the ViewModel
in the TwoWay
mode so that if either changes, the other would also change:
<TextBox x:Name="TheTextBox"
Text="{Binding Path=NewValue, Mode=TwoWay}"
MinWidth="150"/>
We bind the SavedTextBlock
's Text
property to SavedValue
:
<TextBlock x:Name="SavedTextBlock"
Text="{Binding Path=SavedValue}"/>
And we bind the buttons' Commands to the Save()
and Cancel()
methods while also binding the buttons' IsEnabled
property to CanSave
Boolean
property of the ViewModel
:
<Button x:Name="CancelButton"
Content="Cancel"
Margin="5,0"
IsEnabled="{Binding Path=CanSave}"
Command="{Binding Path=Cancel}"/>
<Button x:Name="SaveButton"
Content="Save"
Margin="5,0"
IsEnabled="{Binding Path=CanSave}"
Command="{Binding Path=Save}"/>
Of course, we can pull the DataTemplate
into a different file and even into a different project and re-use it in many places.
ItemsControl Sample
This sample describes how to use ItemsControl
to display a collection of non-visual objects. The sample's code is located in NP.Demos.ItemsControlSample solution.
Run the sample, here is what you'll see:
Try making the window more narrow, the name will wrap down, e.g.:
This is because we are using a WrapPanel
to display multiple items each item containing first and last names of a person.
Press "Remove Last" button and the last person item will be removed and the "Number of People" text will be updated:
Keep pressing the button, until there are no items left - the button "Remove Last" will become disabled:
Take a look a the code for the sample. There are two View Model files added: PersonViewModel.cs and TestViewModel.cs.
PersonViewModel
is the simplest possible class containing immutable properties FirstName
and LastName
:
public class PersonViewModel
{
public string FirstName { get; }
public string LastName { get; }
public PersonViewModel(string firstName, string lastName)
{
FirstName = firstName;
LastName = lastName;
}
}
TestViewModel
represents the top level view model containing a collection of PersonViewModel
objects in its property People
of ObservableCollection<PersonViewModel>
type:
public class TestViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged(string propName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propName));
}
public ObservableCollection<PersonViewModel> People { get; } =
new ObservableCollection<PersonViewModel>();
public int NumberOfPeople => People.Count;
public TestViewModel()
{
People.CollectionChanged += People_CollectionChanged;
People.Add(new PersonViewModel("Joe", "Doe"));
People.Add(new PersonViewModel("Jane", "Dane"));
People.Add(new PersonViewModel("John", "Dawn"));
}
private void People_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
OnPropertyChanged(nameof(NumberOfPeople));
OnPropertyChanged(nameof(CanRemoveLast));
}
public bool CanRemoveLast => NumberOfPeople > 0;
public void RemoveLast()
{
People.RemoveAt(NumberOfPeople - 1);
}
}
It is populated with the names of three in its constructor:
public TestViewModel()
{
People.CollectionChanged += People_CollectionChanged;
People.Add(new PersonViewModel("Joe", "Doe"));
People.Add(new PersonViewModel("Jane", "Dane"));
People.Add(new PersonViewModel("John", "Dawn"));
}
Property NumberOfPeople
contains the current number of items in collection People
and property CanRemoveLast
specifies if there are any items in the collection:
public int NumberOfPeople => People.Count;
...
public bool CanRemoveLast => NumberOfPeople > 0;
Every time the collection People
changes, we notify the binding that these two properties might have been updated:
public TestViewModel()
{
People.CollectionChanged += People_CollectionChanged;
...
}
private void People_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
OnPropertyChanged(nameof(NumberOfPeople));
OnPropertyChanged(nameof(CanRemoveLast));
}
There is a RemoveLast()
method for removing the last item in the People
collection:
public void RemoveLast()
{
People.RemoveAt(NumberOfPeople - 1);
}
MainWindow.axaml file contains all the XAML code for displaying the application:
<Window x:Name="TheWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="NP.Demos.ItemsPresenterSample.MainWindow"
xmlns:local="clr-namespace:NP.Demos.ItemsPresenterSample"
...>
<Window.Resources>
<local:TestViewModel x:Key="TheViewModel"/>
<DataTemplate x:Key="PersonDataTemplate">
<Grid RowDefinitions="Auto, Auto"
Margin="10">
<TextBlock Text="{Binding Path=FirstName, StringFormat='FirstName: {0}'}"/>
<TextBlock Text="{Binding Path=LastName, StringFormat='LastName: {0}'}"
Grid.Row="1"/>
</Grid>
</DataTemplate>
<DataTemplate x:Key="TestViewModelDataTemplate">
<Grid RowDefinitions="*, Auto, Auto">
<ItemsControl ItemsSource="{Binding Path=People}"
ItemTemplate="{StaticResource PersonDataTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<TextBlock Text="{Binding Path=NumberOfPeople, StringFormat='Number of People: {0}'}"
Grid.Row="1"
HorizontalAlignment="Left"
Margin="10"/>
<Button Content="Remove Last"
IsEnabled="{Binding Path=CanRemoveLast}"
Command="{Binding Path=RemoveLast}"
Grid.Row="2"
HorizontalAlignment="Right"
Margin="10"/>
</Grid>
</DataTemplate>
</Window.Resources>
<ContentPresenter Content="{StaticResource TheViewModel}"
ContentTemplate="{StaticResource TestViewModelDataTemplate}"
Margin="10"/>
</Window>
The view model instance is defined at the top of the Window
's resources section:
<local:TestViewModel x:Key="TheViewModel"/>
There are two data templates defined as Window
's XAML resources:
TestViewModelDataTemplate
- the data template for the whole application. It is built around TestViewModel
class and it uses PersonDataTemplate
to display visuals corresponding to each person. PersonDataTemplate
- displays First
and Last
names of a single PersonViewModel
item.
PersonDataTemplate
is very simple - just two TextBlocks
for first and last names - one on top of the other:
<:Key="PersonDataTemplate">
<Grid RowDefinitions="Auto, Auto"
Margin="10">
<TextBlock Text="{Binding Path=FirstName, StringFormat='FirstName: {0}'}"/>
<TextBlock Text="{Binding Path=LastName, StringFormat='LastName: {0}'}"
Grid.Row="1"/>
</Grid>
</DataTemplate>
TestViewModelDataTemplate
contains the ItemsPresenter
(for whose sake the sample was built):
<ItemsControl ItemsSource="{Binding Path=People}"
ItemTemplate="{StaticResource PersonDataTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
Its Items
property is bound to People
collection of the TestViewModel
class and its ItemTemplate
property is set to be the PersonDataTemplate
.
Its ItemsPanel
is set to horizontally oriented WrapPanel
just to demonstrate that we can change the way Visual items are arranged within the ItemsControl
(by default, they'd be arranged vertically).
It also contains the button for removing last item. Button
's command is bound to the RemoveLast()
method of the view model and its IsEnabled
property is bound to CanRemoveLast
property of the view model:
<Button Content="Remove Last"
IsEnabled="{Binding Path=CanRemoveLast}"
Command="{Binding Path=RemoveLast}"
Grid.Row="2"
HorizontalAlignment="Right"
Margin="10"/>
Finally, we put the View Model instance and the DataTemplate
together by using ContentPresenter
:
<ContentPresenter Content="{StaticResource TheViewModel}"
ContentTemplate="{StaticResource TestViewModelDataTemplate}"
Margin="10"/>
Conclusion
In this article, we covered most of the Avalonia functionality, leaving out a few topics:
- Styles
- Animations
- Transitions
- Behaviors
These topics will be covered in the next article within this series.
History
- 7th November, 2021: Initial version