Skip to content

About

Provides helpers for declarative ui in C#

Resources

Stars

506 stars

Watchers

13 watching

Forks

Repository files navigation

Disclaimer

Important: This repository is community driven, not officially supported by avalonia team and is not part of the official Avalonia project—it's only a proof of concept demonstrating how markup can be written purely in C#. For real-world projects use Avalonia's supported XAML approach.

Avalonia.Markup.Declarative

Write Avalonia UI with C# like a Boss 🗿

Avalonia.Markup.Declarative is a C#-first authoring layer over Avalonia controls. The current API is compiled-binding-first and source-generator-driven, with public patterns intentionally aligned with Avalonia's DataContext, binding, style, and selector model.

real projects that using Avalonia.Markup.Declarative

https://github.com/gritsenko/pix2d - Pix2d graphic editor for indie developers

feel free to add your project

Installation

Add the Avalonia.Markup.Declarative NuGet package to your project

Project Template

You can easily create a new project from the command line using the official template:

dotnet new install Declarative.Avalonia.Templates
dotnet new avalonia-declarative -n MyApp

Declarative Component pattern (Single file component/SFC)

Use a self-contained declarative component when the view and its reactive state belong to the same feature. Add CommunityToolkit.Mvvm to the app project and keep the component-local state in a nested ObservableObject.

using Avalonia.Data;
using CommunityToolkit.Mvvm.ComponentModel;

public class CounterComponent() : ViewBase<CounterComponent.State>(new State())
{
    public sealed partial class State : ObservableObject
    {
        [ObservableProperty]
        [NotifyPropertyChangedFor(nameof(CounterLabel))]
        public partial decimal? Counter { get; set; } = 0;

        [ObservableProperty]
        public partial string StatusText { get; set; } = "Hello world";

        public string CounterLabel => $"Counter: {Counter}";
    }

    protected override object Build(State state) =>
        new StackPanel()
            .Children(
                new TextBlock()
                    .Text(state, x => x.StatusText),
                new TextBlock()
                    .Text(state, x => x.CounterLabel),
                new NumericUpDown()
                    .Value(state, x => x.Counter, BindingMode.TwoWay),
                new Button()
                    .Content("Increment")
                    .OnClick(_ => state.Counter++)
            );
}

To compose constructor-injected views, prefer ViewFactory.Create<T>(). If you use DI, register UseComponentControlFactory(...) on AppBuilder.

MVVM Pattern implementation

Use ViewBase<TViewModel> when you want a classic Avalonia or WPF-style view model. Generated setters expose compiled-binding overloads, so the binding syntax stays close to native Avalonia.

using Avalonia.Data;

public class MainView() : ViewBase<MainViewModel>(new MainViewModel())
{
    protected override object Build(MainViewModel vm) =>
        new StackPanel()
            .Children(
                new TextBox()
                    .Text(vm, x => x.Message, BindingMode.TwoWay),
                new TextBlock()
                    .Text(vm, x => x.Message),
                new Button()
                    .Content("Reset")
                    .OnClick(_ => vm.Message = string.Empty)
            );
}

Equivalent XAML for the same view:

<UserControl
    xmlns="https://github.com/avaloniaui"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:vm="using:MyApp.ViewModels"
    x:Class="MyApp.MainView"
    x:DataType="vm:MainViewModel">
    <StackPanel>
        <TextBox Text="{CompiledBinding Message, Mode=TwoWay}" />
        <TextBlock Text="{CompiledBinding Message}" />
        <Button Content="Reset"
                Click="ResetClick" />
    </StackPanel>
</UserControl>

If you assign DataContext from the outside, the same generated setters also support DataContext-relative compiled bindings such as new TextBlock().Text<MainViewModel>(x => x.Message);.

Generated compiled-binding setters also apply automatic conversion for common primitive and nullable mismatches, so bindings like new Slider().Value(vm, x => x.Counter, BindingMode.TwoWay) work when Counter is int, and new CheckBox().IsChecked(vm, x => x.Enabled) work when Enabled is bool. Prefer plain member access such as x => x.Counter: a numeric-conversion cast like x => (double)x.Counter is both unnecessary (the auto-converter handles it) and unsupported — Avalonia's expression parser rejects value-converting Convert nodes. Type casts that navigate to a member of a derived type, such as x => ((DerivedType)x).Property, are supported. For lossy numeric TwoWay conversions, convert-back truncates toward zero.

Passing a ready-made binding

When you need the full binding feature set — reflection bindings (Binding), a pre-built compiled binding, a TemplateBinding, a MultiBinding, or a relative-source/element-name binding — every generated property, attached-property and style setter also exposes an overload that accepts a BindingBase directly:

using Avalonia.Data;

new TextBlock()
    .Text(new Binding("ReflectionProperty"))                                // DataContext-relative reflection binding
    .Foreground(new Binding("Theme.Accent") { Source = appState });         // explicit source

new TextBlock()
    .Text(CompiledBinding.Create<MyViewModel, string>(x => x.Title, source: vm));

// attached properties and styles get the same overload
new Border().Grid_Row(new Binding(nameof(vm.Row)) { Source = vm });
new Style<TextBlock>().Text(new Binding(nameof(vm.Name)));

This is the escape hatch for anything the strongly-typed x => x.Member expression overloads don't cover (custom converters passed on the binding, RelativeSource, ElementName, string format, multi-value bindings, etc.). The same thing is available on any AvaloniaProperty via control.BindValue(TextBlock.TextProperty, binding).

Hot reload support

  • ViewBase supports .NET 6.0+ hot reload.

  • Keeping declarative views in an assembly without XAML can still produce the smoothest hot reload experience.

  • Current Avalonia and .NET toolchains are much better at mixing AXAML and C# markup in the same application, so the limitation is much smaller than it used to be.

  • To enable AMD hot reload integration explicitly:

    AppBuilder.Configure<Application>()
      .UseHotReload()
      .SetupWithLifetime(lifetime);

AI agent tooling (MCP)

The optional, dev-only Declarative.Avalonia.AgentTools package runs an in-process MCP (Model Context Protocol) server on loopback in debug builds, so an AI agent iterating on your UI can see and drive the running app — closing the edit → hot-reload → verify loop without a human relaying what the window looks like.

using Declarative.Avalonia.AgentTools; // under #if DEBUG

AppBuilder.Configure<App>()
    .UsePlatformDetect()
#if DEBUG
    .UseAgentInspector() // loopback MCP server on 127.0.0.1:5599
#endif
    .SetupWithLifetime(lifetime);

It exposes screenshots (with before/after pixel-diffing), the visual tree with bounds and a single absolute client-DIP coordinate frame (abs/center) shared by every tool, per-control layout reports, an automated layout audit, property / property-source / view-model inspection, pixel↔control hit-testing (with a "how to drive this control" hint), and recent build/binding/runtime errors. An opt-in tier (EnableInteraction) also drives the app — real synthesized pointer/keyboard input (tap, drag, pointer_*, that work even on custom controls with no automation peer), click, type, select, resize, switch theme, open a closed popup — plus an escape hatch (with structured, actionable errors) to set a view-model property or run a command directly to reach awkward states.

Keep the call under #if DEBUG: the package pulls in a web stack and a remote-control surface and must not ship in Release; it binds to loopback only. See docs/agent-tools.md for the full guide.

Enable the MCP in your agent

The inspector is a streamable-HTTP MCP server on http://127.0.0.1:5599, so every agent points at the same URL. Run the app under dotnet watch so the agent's edits hot-reload into the process it inspects.

Claude Code — claude mcp add --transport http avalonia-agent-inspector http://127.0.0.1:5599, or a project .mcp.json:

{ "mcpServers": { "avalonia-agent-inspector": { "type": "http", "url": "http://127.0.0.1:5599" } } }

opencode — under mcp in opencode.json (remote servers use type: "remote"):

{ "mcp": { "avalonia-agent-inspector": { "type": "remote", "url": "http://127.0.0.1:5599", "enabled": true } } }

Codex — an [mcp_servers.*] table in ~/.codex/config.toml (a url makes it streamable-HTTP; enable the RMCP client once with [features] → experimental_use_rmcp_client = true):

[mcp_servers.avalonia-agent-inspector]
url = "http://127.0.0.1:5599"

See docs/agent-tools.md for headers, project-scoped variants, and the full tool reference.

Source generation for your own and external controls

The package ships with the source generator that produces markup extensions for:

  • referenced Avalonia framework assemblies
  • controls declared in your own project
  • opted-in third-party assemblies

If you downloaded source code or cloned this repo, add the source generator project as an analyzer reference:

    <ItemGroup>
        <ProjectReference Include="..\..\Avalonia.Markup.Declarative.SourceGenerator\Avalonia.Markup.Declarative.SourceGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
    </ItemGroup>

Make sure that the path to the source generator project is correct relative to your project.

Note: If you are using this library as a NuGet package, the source generator is included automatically.

External libraries support

Framework extensions are generated automatically for the supported Avalonia assemblies that your project references. They are internal by default. Libraries that need their generated extensions to be consumed by another project can opt into public extensions:

using Avalonia.Markup.Declarative;

[assembly: GenerateMarkupExtensionsForAvalonia(true)]

Only one library in an application's dependency graph should publish public extensions for Avalonia controls. Consumers should rely on the extensions from that library rather than opt into public Avalonia extensions themselves. The generator skips local duplicates when it finds one referenced provider and reports warning AMDGEN001 with the assembly names and an example control when multiple referenced libraries provide them. If you own the libraries, opt into public generation in just one of them. To generate extensions for a third-party library, add an assembly attribute that points to any type from that assembly:

using Avalonia.Markup.Declarative;
using ReactiveUI.Avalonia;

[assembly: GenerateMarkupExtensionsForAssembly(typeof(RoutedViewHost))]

No standalone tool installation or manual avalonia-amd-gen step is required anymore.

About

Provides helpers for declarative ui in C#

Resources

Stars

506 stars

Watchers

13 watching

Forks

Releases

Packages

Used by

Contributors

Languages