Skip to main content
Custom controls allow you to create reusable UI components with specialized behavior and appearance. Avalonia provides several approaches for creating custom controls.

Types of Custom Controls

1. User Control

Combines existing controls into a reusable component. Best for composition-based controls.

2. Templated Control

Extends a base control with a customizable template. Best for controls with complex styling needs.

3. Custom Control

Builds a control from scratch by inheriting from Control. Best for controls with specialized rendering or behavior.

Creating a User Control

User controls are the simplest way to create reusable UI components.

Example: LoginForm User Control

LoginForm.axaml
<UserControl xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             x:Class="MyApp.Controls.LoginForm">
  <StackPanel Spacing="10" Width="300">
    <TextBlock Text="Login" FontSize="20" FontWeight="Bold" />
    
    <TextBlock Text="Username:" />
    <TextBox Name="UsernameTextBox" />
    
    <TextBlock Text="Password:" />
    <TextBox Name="PasswordTextBox" PasswordChar="●" />
    
    <Button Name="LoginButton" Content="Login" HorizontalAlignment="Stretch" />
  </StackPanel>
</UserControl>

Using the User Control

<Window xmlns:controls="using:MyApp.Controls">
  <controls:LoginForm x:Name="LoginForm" />
</Window>

Creating a Templated Control

Templated controls provide more flexibility with styling and themes.

Example: Custom Button with Icon

IconButton.cs
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Media;

namespace MyApp.Controls
{
    public class IconButton : Button
    {
        public static readonly StyledProperty<Geometry?> IconDataProperty =
            AvaloniaProperty.Register<IconButton, Geometry?>(nameof(IconData));

        public static readonly StyledProperty<double> IconSizeProperty =
            AvaloniaProperty.Register<IconButton, double>(nameof(IconSize), 16);

        static IconButton()
        {
            // Set default style key
            DefaultStyleKeyProperty.OverrideMetadata(
                typeof(IconButton), 
                new StyleKeyMetadata(typeof(IconButton)));
        }

        public Geometry? IconData
        {
            get => GetValue(IconDataProperty);
            set => SetValue(IconDataProperty, value);
        }

        public double IconSize
        {
            get => GetValue(IconSizeProperty);
            set => SetValue(IconSizeProperty, value);
        }
    }
}

Using the Templated Control

<Window xmlns:controls="using:MyApp.Controls">
  <controls:IconButton 
    Content="Save"
    IconData="M19 12v7H5v-7H3v7c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-7h-2z"
    IconSize="20" />
</Window>

Creating a Custom Rendered Control

For controls that need custom rendering, override the Render method.

Example: Color Picker

using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using System;

public class ColorPicker : Control
{
    public static readonly StyledProperty<Color> SelectedColorProperty =
        AvaloniaProperty.Register<ColorPicker, Color>(nameof(SelectedColor));

    public event EventHandler<Color>? ColorChanged;

    public Color SelectedColor
    {
        get => GetValue(SelectedColorProperty);
        set => SetValue(SelectedColorProperty, value);
    }

    protected override void OnPointerPressed(PointerPressedEventArgs e)
    {
        base.OnPointerPressed(e);
        
        var point = e.GetPosition(this);
        var color = GetColorAtPoint(point);
        
        SelectedColor = color;
        ColorChanged?.Invoke(this, color);
    }

    public override void Render(DrawingContext context)
    {
        var bounds = new Rect(0, 0, Bounds.Width, Bounds.Height);
        
        // Draw gradient
        var gradientBrush = new LinearGradientBrush
        {
            StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
            EndPoint = new RelativePoint(1, 0, RelativeUnit.Relative)
        };
        
        gradientBrush.GradientStops.Add(new GradientStop(Colors.Red, 0));
        gradientBrush.GradientStops.Add(new GradientStop(Colors.Yellow, 0.17));
        gradientBrush.GradientStops.Add(new GradientStop(Colors.Green, 0.33));
        gradientBrush.GradientStops.Add(new GradientStop(Colors.Cyan, 0.5));
        gradientBrush.GradientStops.Add(new GradientStop(Colors.Blue, 0.67));
        gradientBrush.GradientStops.Add(new GradientStop(Colors.Magenta, 0.83));
        gradientBrush.GradientStops.Add(new GradientStop(Colors.Red, 1));
        
        context.DrawRectangle(gradientBrush, null, bounds);
        
        // Draw selection indicator
        var selectedPoint = GetPointForColor(SelectedColor);
        var pen = new Pen(Brushes.White, 2);
        context.DrawEllipse(null, pen, selectedPoint, 5, 5);
    }

    private Color GetColorAtPoint(Point point)
    {
        // Calculate color based on position
        var hue = (point.X / Bounds.Width) * 360;
        return Color.FromHsv(hue, 1, 1);
    }

    private Point GetPointForColor(Color color)
    {
        // Calculate position based on color
        var hsv = color.ToHsv();
        var x = (hsv.H / 360) * Bounds.Width;
        return new Point(x, Bounds.Height / 2);
    }
}

Attached Properties

Create custom attached properties for specialized behavior.
using Avalonia;
using Avalonia.Controls;

public class GridHelpers
{
    public static readonly AttachedProperty<bool> AutoSizeProperty =
        AvaloniaProperty.RegisterAttached<GridHelpers, Control, bool>("AutoSize");

    public static bool GetAutoSize(Control control)
    {
        return control.GetValue(AutoSizeProperty);
    }

    public static void SetAutoSize(Control control, bool value)
    {
        control.SetValue(AutoSizeProperty, value);
    }

    static GridHelpers()
    {
        AutoSizeProperty.Changed.AddClassHandler<Control>(OnAutoSizeChanged);
    }

    private static void OnAutoSizeChanged(Control control, AvaloniaPropertyChangedEventArgs e)
    {
        if ((bool)e.NewValue!)
        {
            // Apply auto-sizing logic
        }
    }
}
Usage:
<Button local:GridHelpers.AutoSize="True" />

Control Lifecycle

Important methods to override:
public class MyControl : Control
{
    // Called when control is attached to visual tree
    protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
    {
        base.OnAttachedToVisualTree(e);
        // Initialize resources, start animations
    }

    // Called when control is detached from visual tree
    protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
    {
        base.OnDetachedFromVisualTree(e);
        // Clean up resources, stop animations
    }

    // Called when property changes
    protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
    {
        base.OnPropertyChanged(change);
        
        if (change.Property == MyProperty)
        {
            // React to property change
        }
    }

    // Called for measuring
    protected override Size MeasureOverride(Size availableSize)
    {
        // Calculate desired size
        return new Size(100, 50);
    }

    // Called for layout
    protected override Size ArrangeOverride(Size finalSize)
    {
        // Arrange child elements
        return finalSize;
    }
}

Styled Properties

Define properties that can be styled:
public class MyControl : Control
{
    public static readonly StyledProperty<string> TitleProperty =
        AvaloniaProperty.Register<MyControl, string>(nameof(Title), "Default Title");

    public string Title
    {
        get => GetValue(TitleProperty);
        set => SetValue(TitleProperty, value);
    }
}

Direct Properties

For properties that don’t need styling:
public class MyControl : Control
{
    private bool _isActive;
    
    public static readonly DirectProperty<MyControl, bool> IsActiveProperty =
        AvaloniaProperty.RegisterDirect<MyControl, bool>(
            nameof(IsActive),
            o => o.IsActive,
            (o, v) => o.IsActive = v);

    public bool IsActive
    {
        get => _isActive;
        set => SetAndRaise(IsActiveProperty, ref _isActive, value);
    }
}

Best Practices

  1. Choose the right approach
    • User Control: For simple composition
    • Templated Control: For styleable controls
    • Custom Control: For specialized rendering
  2. Follow naming conventions
    • Properties end with “Property” (e.g., TitleProperty)
    • Use meaningful names for events and methods
  3. Handle property changes
    • Validate property values
    • Update UI when properties change
    • Use coercion for property constraints
  4. Optimize performance
    • Cache expensive calculations
    • Invalidate layout only when needed
    • Dispose resources properly
  5. Support styling
    • Use TemplateBinding in templates
    • Define style selectors
    • Support pseudo-classes
  6. Document your control
    • Add XML comments
    • Provide usage examples
    • Document properties and events

Testing Custom Controls

using Avalonia.Headless.XUnit;
using Xunit;

public class MyControlTests
{
    [AvaloniaFact]
    public void Should_Set_Property()
    {
        var control = new MyControl();
        control.Title = "Test";
        
        Assert.Equal("Test", control.Title);
    }

    [AvaloniaFact]
    public void Should_Raise_Event()
    {
        var control = new MyControl();
        var eventRaised = false;
        
        control.SomeEvent += (s, e) => eventRaised = true;
        control.TriggerEvent();
        
        Assert.True(eventRaised);
    }
}

See Also

Build docs developers (and LLMs) love