Blazor + Avalonia = ❤️

⏱️ TL;DR

With my experimental fork of BlazorBindings.Maui, you can use Blazor syntax for Avalonia controls.

💻 Avalonia

In the last months I tinkered with Avalonia, the real cross platform .NET UI framework. Think of it as WPF, but fast, cross platform and with CSS support.

The way they approach cross platform is simple and efficient: Just draw everything and don’t use native controls.
This is achieved by using Skia under the hoods, the graphics technology developed by Google and used in Chrome.

But one thing still puts me off: XAML.

🪟 XAML

Yes, XAML is quite powerful. But it is also a very, VERY verbose way to describe and do things.

  • Want to hide a control by a bool variable?
    Write a converter.

    • Want to invert the bool first? Write a separate converter (or use another converter property ConverterParameter)
  • Want to remove a control by a bool?
    Sorry, not supported, unless you do some DataTrigger, ControlTemplate,… – also known as "jumping-through-some-hoops".
  • Styling? Yes, of course!
    Automatically mixing styles like with CSS? Um, no.

🤩 Blazor

Blazor is developed by Microsoft to let you easily develop single page applications (SPAs) using .NET and HTML. It is using the Razor syntax which is quite easy to write and understand. It also supports data-binding (of course, it’s 2023).

All in all, UIs written in Blazor are far more succinct and more straight forward.

🪄 Puppeteer

Blazor is actually a puppeteer. Blazor can even puppeteer over the internet (more in a second)!

For it to work, it needs to know how to translate the Blazor UI structure into actual "native" UI control structure.

In its natural habitat, it is HTML with its DOM in the browser. Blazor can run in the browser by using WASM for executing .NET code. There it manipulates the DOM as desired. This is Blazor WebAssembly.

As already teased, Blazor can even manipulate the HTML elements over the internet by running on a server and just sending DOM-manipulation commands over the internet to the client’s browser – magic! 🤯 This is Blazor Server.

So this already shows the flexibility Blazor provides. And this flexibility is what this is all about…

🤔 So What’s the Deal?

So why am I telling you all this?

As already hinted, there is a special thing about Blazor, with makes it an very flexible UI Framework:
Bindings.

Microsoft already did the HTML bindings for us and we can enjoy Blazor for HTML. But it is not limited to just HTML. It can be any UI framework.

👀 Blazor Bindings for Xamarin.Forms/MAUI

The folks from the ASP.NET Core team like Steve Sanderson and Eilon Lipton took it to the test and tried to use Blazor on top of Xamarin.Forms. It was successfull, but stayed at the proof-of-concept level.

Until a skilled developer called Dreamescaper forked this repository and tried to finish what they started. 3 years later (and a switch to Xamarin.Forms successor MAUI) he released v1.0 of Blazor Bindings for MAUI.
This fork is so good that his work was even merged back into the Microsoft repository!

The important bit here is that MAUI has also a XAML dialect like Avalonia has a XAML dialect. This means that besides some differences the basic inner workings of both are pretty similar.

  • Bindings
  • Data templates
  • Control templates

This prompted me to start an experiemnt:
Can I use his work to enable Blazor on Avalonia?

🚀 Blazor Bindings for Avalonia

I forked the repository and took a look around.

He developed an renderer and element manager which takes care of the rendering and UI structure management. He also considered that not all Blazor elements may be actual things in the native UI tree, so he introduced "non-physical" elements. This is pretty nifty,

He also wrote a generator console application which allows you to generate Blazor bindings by convention. As every generator has it’s limits, it also allows to exclude some properties to do manual hand-written handling. It also has some helpers for templates and so on which allows for easier generalization on this part.

A very nice thing is that in this project the core building blocks are already extracted into a BlazorBindings.Core project. So more than MAUI was already considered. 👍

⌨️ Copy’n’Refactor

I copied the MAUI project + it’s generator project and started refactoring.

The first big thing was of course changing the namespaces to point to the native controls. The next thing was already trickier: finding the equivalent functionality in Avalonia to reproduce the same results.

Especially control templates took me some time to get my head around, because most of the time you only have Blazor’s RenderFragments in your hand if you want to template something. But after endless dead-ends and finally reading the coded thouroughly, I found a solution also for this (and yes, there were already helpers for this – but with a somewhat ambiguous naming).

What works?

  • Binding
  • C# Expressions (calculations, if/else, for/while/…)
  • Data templates
  • Control templates
  • And: Hot Reload!

📽️ Show Time!

I prepared a simple test application to showcase the features (and have something to debug 😉).

What do we have here?

  • A counter which gets increased every time you click on a button
  • A text block which changes depending on the counter
  • A list box which shows a list of elements "a", "b", "c"
  • A slider whose value is used
    • to display the value
    • to control the width and height of an ellipse

We are going to compare the idiomatic XAML approach with the Blazor approach. Let’s see how this turns out.

🤯 Using XAML

First, let’s look at how this would look in a regular Avalonia XAMl project.

Main window

<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:vm="using:AvaloniaApplication2.ViewModels"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
        x:Class="AvaloniaApplication2.Views.MainWindow"
        x:DataType="vm:MainWindowViewModel"
        Icon="/Assets/avalonia-logo.ico"
        Title="AvaloniaApplication2">
    <Window.Resources>
        <vm:NumberToDescriptionConverter x:Key="NumberToDescriptionConverter"></vm:NumberToDescriptionConverter>
        <vm:NumberMultiplyerConverter x:Key="NumberMultiplyerConverter"></vm:NumberMultiplyerConverter>
    </Window.Resources>

    <StackPanel Background="Aqua" HorizontalAlignment="Center"
                VerticalAlignment="Center">

        <TextBlock Text="Hello World"></TextBlock>
        <!-- IsVisible actually just hides and not removes the button which is not the same -->
        <Button
            Command="{Binding CounterClickedCommand}"
            IsVisible="{Binding !!buttonVisible}"
            >Click me</Button>

        <TextBlock
            Text="{Binding counter, Converter={StaticResource NumberToDescriptionConverter}}"></TextBlock>

        <CheckBox IsChecked="{Binding buttonVisible}">Button visible</CheckBox>

        <ListBox ItemsSource="{Binding Items}">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <StackPanel>
                        <TextBlock Text="Content:"></TextBlock>
                        <TextBlock Text="{Binding}"></TextBlock>
                    </StackPanel>
                </DataTemplate>
            </ListBox.ItemTemplate>
            <ListBox.ItemsPanel>
                <ItemsPanelTemplate>
                    <StackPanel Orientation="Horizontal"></StackPanel>
                </ItemsPanelTemplate>
            </ListBox.ItemsPanel>
        </ListBox>

        <StackPanel Orientation="Horizontal" Margin="0,20,0,0">
            <TextBlock Text="Ellipse size: "></TextBlock>
            <TextBlock Text="{Binding slider}"></TextBlock>
        </StackPanel>
        <Slider Value="{Binding slider}"></Slider>
        <Ellipse Width="{Binding slider}" Height="{Binding slider, Converter={StaticResource NumberMultiplyerConverter}, ConverterParameter=0.5}" Fill="Red"></Ellipse>
    </StackPanel>
</Window>

View model

public class MainWindowViewModel : ViewModelBase
{
    private double _slider = 50;
    private int _counter = 0;
    private bool _buttonVisible = true;

    public MainWindowViewModel()
    {
        CounterClickedCommand = ReactiveCommand.Create(() =>
        {
            counter++;
        });

        Items = new ObservableCollection<string>
        {
            "a",
            "b",
            "c"
        };
    }

    public ICommand CounterClickedCommand { get; }

    public ObservableCollection<string> Items { get; }

    public double slider
    {
        get => _slider;
        set => this.RaiseAndSetIfChanged(ref _slider, value);
    }

    public int counter
    {
        get => _counter;
        set => this.RaiseAndSetIfChanged(ref _counter, value);
    }

    public bool buttonVisible
    {
        get => _buttonVisible;
        set => this.RaiseAndSetIfChanged(ref _buttonVisible, value);
    }
}

Converter: Number to description multiplyer (button text)

public class NumberToDescriptionConverter : IValueConverter
{
    public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
    {
        if (value is int i)
        {
            return i switch
            {
                0 => "Click me",
                1 => $"Clicked 1 time",
                _ => $"Clicked {i} times"
            };
        }

        throw new NotSupportedException();
    }

    public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
    {
        throw new NotSupportedException();
    }
}

Converter: NumberMulitplyerConverter for the elipsis

public class NumberMultiplyerConverter : IValueConverter
{
    public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
    {
        if (!double.TryParse((string?)parameter, CultureInfo.InvariantCulture, out var factor))
        {
            factor = 1.0;
        }

        if (value is double d)
        {
            return d * factor;
        }
        if (value is int i)
        {
            return i * factor;
        }

        throw new NotSupportedException();
    }

    public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
    {
        throw new NotSupportedException();
    }
}

Well, that’s a mouth full. 😦

We have to:

  • make all our properties bindable (INotifyPropertyChanged)
  • write a custom converter to convert a number into a nice text
  • write a custom converter to multiply a bound number (!)
  • register our converters in the resources
  • use Binding
    • use the converters from the resources where required
    • ensure the right StringFormat is used

🤯

This is much code for some small features.

Let’s see what Avalonia with Blazor Bindings requires from you.

😎 Using Blazor Bindings

Let’s look at the code.

@using BlazorBindings.AvaloniaBindings.Elements
@using System.Collections.ObjectModel;
@using BlazorBindings.AvaloniaBindings.Elements.Shapes
@using Microsoft.AspNetCore.Components.Rendering;
@using global::Avalonia.Layout
@using global::Avalonia
@using global::Avalonia.Media;
@using global::Avalonia.Media.Imaging;

<Window Topmost="true" Width="600" Height="500">
    <StackPanel HorizontalAlignment="HorizontalAlignment.Center"
                VerticalAlignment="VerticalAlignment.Center">
        <TextBlock Text="Hello World"></TextBlock>
        @if (buttonVisible == true)
        {
            <Button OnClick="OnCounterClicked">Click me</Button>
        }
        <TextBlock Text="@ButtonText"></TextBlock>

        <CheckBox @bind-IsChecked="buttonVisible">Button visible</CheckBox>

        <ListBox ItemsSource="Items">
            <ItemTemplate>
                <StackPanel>
                    <TextBlock Text="Content:"></TextBlock>
                    <TextBlock Text="@context"></TextBlock>
                </StackPanel>
            </ItemTemplate>
            <ItemsPanel>
                <StackPanel Orientation="Orientation.Horizontal"></StackPanel>
            </ItemsPanel>
        </ListBox>
        <StackPanel Orientation="Orientation.Horizontal" Margin="new Thickness(0,20,0,0)">
            <TextBlock Text="Ellipse size: "></TextBlock>
            <TextBlock Text="@slider.ToString()"></TextBlock>
        </StackPanel>
        <Slider @bind-Value="slider"></Slider>

        <Ellipse Width="slider" Height="slider/2" Fill="Brushes.Red"></Ellipse>
    </StackPanel>
</Window>

@code {
    double slider = 50;
    int count = 0;
    bool? buttonVisible { get; set; } = true;
    ObservableCollection<string> Items { get; set; } = new ObservableCollection<string>(new[]
    {
        "a",
        "b",
        "c"
    });

    void ToggleButton()
    {
        buttonVisible = !buttonVisible;
    }

    string ButtonText => count switch
    {
        0 => "Click me",
        1 => $"Clicked 1 time",
        _ => $"Clicked {count} times"
    };

    void OnCounterClicked()
    {
        count++;
    }
}

This is the whole code! And still it provides the exact same functionality as before!

What are the differences?

Pros

  • No Binding but direct variable use
  • No INotifyPropertyChanged necessary*
  • No commands but direct method references
  • No custom converters necessary but inline expressions (it’s C# after all)
  • Real removal of the button from the UI tree
  • Hot Reload out of the box (not possible with vanilla Avalonia right now)!

Cons

  • No short-hands (eg. Margin="new Thickness(0, 20, 0, 20))
    👉 Fixable
  • Bindings must be generated/written

Other than that … I can barely see other cons.

*) For more advanced use cases it still may be necessary to use the INotifyPropertyChanged interface while still keeping it’s nice syntax.

🛣️ Future and Verdict

I will try to bind all exposed Avalonia controls and fixing some open TODOs. All major roadblocks are out of the way (control templates! 😤), so I think moving forward is just adding, fixing, tuning. Maybe enabling proper dependency injection could be tricky (I have not tested it yet).

From what I leaned so far, I don’t expect any major time sinks. And for perspective: I estimate from the time I forked to what I presented today it took only 12h-20h. So stay tuned for further enhancements.
EDIT: Well, there are some topics which may cause headaches like multi-window support and styling. Well, let’s hope it’s goes well. 😉

I don’t know if this project will get some traction or not, but I hope that it may become an official alternative to its XAML syntax.

To me it is clear, that Blazor brings so much ease to UI development with Avalonia! Succinct syntax, easier binding, much less need for Converters (if any), Hot Reload support – all big benefits for themselves.

Feel free to look and play with it. Unfortunately, you have to clone and build it yourself, but it should be straight forward with the Hello World sample.

If you also think so, please show your support by giving my fork a GitHub star and join the discussion in Avalonia’s repository!

Cookie Consent with Real Cookie Banner