Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / desktop / XAML

Multiplatform Avalonia .NET Framework Programming Advanced Concepts in Easy Samples

5.00/5 (23 votes)
21 Dec 2023CPOL26 min read 39.4K  
This article covers important concepts of Avalonia/WPF needed for programming and software design
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:

  1. Multiplatform UI Coding with AvaloniaUI in Easy Samples. Part 1 - AvaloniaUI Building Blocks
  2. Basics of XAML in Easy Samples for Multiplatform Avalonia .NET Framework
  3. 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:

  1. Routed Events
  2. Avalonia Commands
  3. Avalonia User Controls
  4. Avalonia Control Template and Custom Controls
  5. 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:

  1. can be defined outside of the class that fires them and 'Attached' to the objects.
  2. 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:

  1. Direct - This means that the event can only be handled on the same visual tree node that fires it.
  2. 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.
  3. 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:

Image 1

Image 2

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:

XAML
<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:

Image 3

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:

C#
public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();

        ...
        
        // add event handler for the Window
        this.AddHandler
        (
            Control.PointerPressedEvent,
            HandleClickEvent,
            RoutingStrategies.Bubble | RoutingStrategies.Tunnel
            //,true // uncomment if you want to test that the event still propagates event 
                    // after being handled
        );

        Grid rootPanel = this.FindControl<Grid>("TheRootPanel");

        // add event handler for the Grid
        rootPanel.AddHandler
        (
            Control.PointerPressedEvent, 
            HandleClickEvent,
            RoutingStrategies.Bubble | RoutingStrategies.Tunnel);

        Border border = this.FindControl<Border>("TheBorder");

        // add event handler for the Blue Border in the middle
        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}");

        // uncomment if you want to test handling the event
        //if (e.Route == RoutingStrategies.Bubble && senderControl.Name == "TheBorder")
        //{
        //    e.Handled = true;
        //}
    }
}  

We assign the handlers to the Window, Grid and Border by using AddHandler method. Let us take a closer look at one of them:

C#
// add event handler for the Window
this.AddHandler
(
    Control.PointerPressedEvent,                        // routed event
    HandleClickEvent,                                   // event handler
    RoutingStrategies.Bubble | RoutingStrategies.Tunnel // routing strategy filter
);  

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:

C#
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:

C#
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:

C#
// uncomment if you want to test handling the event
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:

C#
// add event handler for the Window
this.AddHandler
(
    Control.PointerPressedEvent, //routed event
    HandleClickEvent, // event handler
    RoutingStrategies.Bubble | RoutingStrategies.Tunnel // routing strategy filter
    ,true // uncomment if you want to test that the event still propagates event 
          // after being handled
);  

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:

Image 4

After that, press on the blue border within the application, an entry for the event will show the main window:

Image 5

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:

Image 6

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:

C#
using Avalonia.Interactivity;

namespace NP.Demos.CustomRoutedEventSample
{
    public static class StaticRoutedEvents
    {
        /// <summary>
        /// create the MyCustomRoutedEvent
        /// </summary>
        public static readonly RoutedEvent<RoutedEventArgs> MyCustomRoutedEvent =
            RoutedEvent.Register<object, RoutedEventArgs>
            (
                "MyCustomRouted", 
                RoutingStrategies.Tunnel //| RoutingStrategies.Bubble
            );
    }
} 

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.:

C#
// add event handler for the Window
this.AddHandler
(
    StaticRoutedEvents.MyCustomRoutedEvent, //routed event
    HandleCustomEvent, // event handler
    RoutingStrategies.Bubble | RoutingStrategies.Tunnel // routing strategy filter
);  

We also add some code to raise the MyCustomRoutedEvent when mouse is pressed on the blue border:

C#
  // we add the handler to pointer pressed event in order
  // to raise MyCustomRoutedEvent from it.
  border.PointerPressed += Border_PointerPressed;
}

/// PointerPressed handler that raises MyCustomRoutedEvent
private void Border_PointerPressed(object? sender, PointerPressedEventArgs e)
{
    Control control = (Control)sender!;

    // Raising MyCustomRoutedEvent
    control.RaiseEvent(new RoutedEventArgs(StaticRoutedEvents.MyCustomRoutedEvent));
}  

The code line for raising the event specifically is:

C#
// Raising MyCustomRoutedEvent
control.RaiseEvent(new RoutedEventArgs(StaticRoutedEvents.MyCustomRoutedEvent));

Here is the (almost) full code-behind from MainWindow.axaml.cs file:

C#
public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();

        ...
        
        // add event handler for the Window
        this.AddHandler
        (
            StaticRoutedEvents.MyCustomRoutedEvent, //routed event
            HandleClickEvent, // event handler
            RoutingStrategies.Bubble | RoutingStrategies.Tunnel // routing strategy filter
        );

        Grid rootPanel = this.FindControl<Grid>("TheRootPanel");

        // add event handler for the Grid
        rootPanel.AddHandler
        (
            StaticRoutedEvents.MyCustomRoutedEvent, 
            HandleCustomEvent,
            RoutingStrategies.Bubble | RoutingStrategies.Tunnel);

        Border border = this.FindControl<Border>("TheBorder");

        // add event handler for the Blue Border in the middle
        border.AddHandler(
            StaticRoutedEvents.MyCustomRoutedEvent,
            HandleCustomEvent,
            RoutingStrategies.Bubble | RoutingStrategies.Tunnel
            );

        // we add the handler to pointer pressed event in order
        // to raise MyCustomRoutedEvent from it.
        border.PointerPressed += Border_PointerPressed;
    }

    /// PointerPressed handler that raises MyCustomRoutedEvent
    private void Border_PointerPressed(object? sender, PointerPressedEventArgs e)
    {
        Control control = (Control)sender!;

        // Raising MyCustomRoutedEvent
        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:

Image 7

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:

C#
public class ViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;

    /// <summary>
    /// fires INotifyPropertyChanged.PropertyChanged event
    /// </summary>
    private void OnPropertyChanged(string propName)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propName));
    }

    #region Status Property
    private bool _status;
    /// <summary>
    /// Status notifiable property
    /// </summary>
    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;
    /// <summary>
    /// Controls whether Toggle Status button is enabled or not
    /// </summary>
    public bool CanToggleStatus
    {
        get
        {
            return this._canToggleStatus;
        }
        set
        {
            if (this._canToggleStatus == value)
            {
                return;
            }

            this._canToggleStatus = value;
            this.OnPropertyChanged(nameof(CanToggleStatus));
        }
    }
    #endregion CanToggleStatus Property

    /// <summary>
    /// Toggles the status
    /// </summary>
    public void ToggleStatus()
    {
        Status = !Status;
    }

    /// <summary>
    /// Set the Status to whatever 'status' is passed
    /// </summary>
    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.

C#
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:

XAML
<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:

XAML
<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):

XAML
<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:

XAML
<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":

XAML
<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:

Image 8

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.

Image 9

Run the sample, here is the window that pops up:

Image 10

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:

Image 11

MainWindow.axaml file has only one non-trivial element: MyUserControl:

XAML
<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:

XAML
<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:

C#
public partial class MyUserControl : UserControl
{
    private TextBox _textBox;
    private TextBlock _savedTextBlock;
    private Button _cancelButton;
    private Button _saveButton;

    // saved value is retrieved from and saved to
    // the _savedTextBlock
    private string? SavedValue
    {
        get => _savedTextBlock.Text;
        set => _savedTextBlock.Text = value;
    }

    // NewValue is retrieved from and saved to
    // the _textBox
    private string? NewValue
    {
        get => _textBox.Text;
        set => _textBox.Text = value;
    }

    public MyUserControl()
    {
        InitializeComponent();

        // set _cancelButton and its Click event handler
        _cancelButton = this.FindControl<Button>("CancelButton");
        _cancelButton.Click += OnCancelButtonClick;

        // set _saveButton and its Click event handler
        _saveButton = this.FindControl<Button>("SaveButton");
        _saveButton.Click += OnSaveButtonClick;

        // set the TextBlock that contains the Saved text
        _savedTextBlock = this.FindControl<TextBlock>("SavedTextBlock");

        // set the TextBox that contains the new text
        _textBox = this.FindControl<TextBox>("TheTextBox");

        // initial New and Saved values should be the same
        NewValue = SavedValue;

        // every time the text changes, we should check if
        // Save and Cancel buttons should be enabled or not
        _textBox.GetObservable(TextBox.TextProperty).Subscribe(OnTextChanged);
    }

    // On Cancel, the TextBox value should become the same as SavedValue
    private void OnCancelButtonClick(object? sender, RoutedEventArgs e)
    {
        NewValue = SavedValue;
    }

    // On Save, the Saved Value should become the same as the TextBox Value
    private void OnSaveButtonClick(object? sender, RoutedEventArgs e)
    {
        SavedValue = NewValue;

        // also we should reset the IsEnabled states of the buttons
        OnTextChanged(null);
    }

    private void OnTextChanged(string? obj)
    {
        bool canSave = NewValue != SavedValue;

        // _cancelButton as _saveButton are enabled if TextBox'es value
        // is not the same as saved value and disabled otherwise.
        _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.:

C#
// set _cancelButton and its Click event handler
_cancelButton = this.FindControl<Button>("CancelButton");  

Then the button's Click event is assigned a handler, e.g.:

C#
_cancelButton.Click += OnCancelButtonClick;  

All the interesting processing is done inside the Click event handlers and inside the TextBox'es Text observable subscription:

C#
// every time the text changes, we should check if
// Save and Cancel buttons should be enabled or not
_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:

Image 12

Here is its code:

C#
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

    // CanSave is set to true when SavedValue is not the same as NewView
    // false otherwise
    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:

C#
public MyCustomControl()
{
    this.GetObservable(NewValueProperty).Subscribe(SetCanSave);
    this.GetObservable(SavedValueProperty).Subscribe(SetCanSave);
}  

and by setting it within the callback SetCanSave(...) method:

C#
// CanSave is set to true when SavedValue is not the same as NewView
// false otherwise
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 Buttons' commands: void Save() and void Cancel():

C#
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:

XAML
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:

XAML
<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:

XAML
<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:

XAML
<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:

XAML
<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:

XAML
<TextBox x:Name="TheTextBox"
         Text="{Binding Path=NewValue, Mode=TwoWay, 
         RelativeSource={RelativeSource TemplatedParent}}"
         MinWidth="150"/>  

"SavedTextBlock" TextBlock is bound to SavedValue:

XAML
<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:

XAML
<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:

Image 13

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:

XAML
<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:

  1. 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.
  2. 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).

Image 14

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).

Image 15

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.

Image 16

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:

C#
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

        // CanSave is set to true when SavedValue is not the same as NewView
        // false otherwise
        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:

C#
// CanSave is set to true when SavedValue is not the same as NewView
// false otherwise
public bool CanSave => NewValue != SavedValue;  

There are also two public methods for saving and cancelling:

C#
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:

XAML
<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:

XAML
<Window ...>
    <Window.Resources>
        <local:ViewModel x:Key="TheViewModel"/>
        ...
    </Window.Resources>
...
</Window>  

And here is how we define the DataTemplate:

XAML
<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:

XAML
<TextBox x:Name="TheTextBox"
         Text="{Binding Path=NewValue, Mode=TwoWay}"
         MinWidth="150"/>  

We bind the SavedTextBlock's Text property to SavedValue:

XAML
<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:

XAML
<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:

Image 17

Try making the window more narrow, the name will wrap down, e.g.:

Image 18

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:

Image 19

Keep pressing the button, until there are no items left - the button "Remove Last" will become disabled:

Image 20

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:

C#
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:

C#
public class TestViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;

    // fires notification if a property changes
    private void OnPropertyChanged(string propName)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propName));
    }

    // collection of PersonViewModel objects
    public ObservableCollection<PersonViewModel> People { get; } =
        new ObservableCollection<PersonViewModel>();

    // number of people
    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"));
    }

    // whenever collection changes, fire notification for possible updates
    // of NumberOfPeople and CanRemoveLast properties.
    private void People_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
    {
        OnPropertyChanged(nameof(NumberOfPeople));
        OnPropertyChanged(nameof(CanRemoveLast));
    }

    // can remove last item only if collection has some items in it
    public bool CanRemoveLast => NumberOfPeople > 0;

    // remove last item of the collection
    public void RemoveLast()
    {
        People.RemoveAt(NumberOfPeople - 1);
    }
}  

It is populated with the names of three in its constructor:

C#
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:

C#
// number of people
public int NumberOfPeople => People.Count;

...

// can remove last item only if collection has some items in it
public bool CanRemoveLast => NumberOfPeople > 0;  

Every time the collection People changes, we notify the binding that these two properties might have been updated:

C#
public TestViewModel()
{
    People.CollectionChanged += People_CollectionChanged;
    ...
}

// whenever collection changes, fire notification for possible updates
// of NumberOfPeople and CanRemoveLast properties.
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:

C#
// remove last item of the collection
public void RemoveLast()
{
    People.RemoveAt(NumberOfPeople - 1);
}  

MainWindow.axaml file contains all the XAML code for displaying the application:

XAML
<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:

XAML
<local:TestViewModel x:Key="TheViewModel"/>  

There are two data templates defined as Window's XAML resources:

  1. 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.
  2. 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:

XAML
<: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):

XAML
<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:

XAML
<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:

XAML
<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:

  1. Styles
  2. Animations
  3. Transitions
  4. Behaviors

These topics will be covered in the next article within this series.

History

  • 7th November, 2021: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)