23. Custom drawing and custom controls

Goal

Why this matters

Prerequisites

1. Choosing an approach

Scenario Draw (override Render) Template (ControlTemplate)
Pixel-perfect graphics, charts [x]
Animations driven by drawing primitives [x]
Standard widgets composed of existing controls [x]
Consumer needs to restyle via XAML [x]
Complex interaction per element (buttons in control) [x]

Hybrid: templated control containing a custom-drawn child for performance-critical surface.

2. Invalidation basics

3. DrawingContext essentials

DrawingContext primitives:

Example pattern:

public override void Render(DrawingContext ctx)
{
    base.Render(ctx);
    using (ctx.PushClip(new Rect(Bounds.Size)))
    {
        ctx.DrawRectangle(Brushes.Black, null, Bounds);
        ctx.DrawText(_formattedText, new Point(10, 10));
    }
}

4. Template lifecycle, presenters, and template results

Example:

protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
    base.OnApplyTemplate(e);
    _toggleRoot?.PointerPressed -= OnToggle;
    _toggleRoot = e.NameScope.Find<Border>("PART_ToggleRoot");
    _toggleRoot?.PointerPressed += OnToggle;
}

For library-ready controls publish a ControlTheme default template so consumers can restyle without copying large XAML fragments.

5. Example: Sparkline (custom draw)

public sealed class Sparkline : Control
{
    public static readonly StyledProperty<IReadOnlyList<double>?> ValuesProperty =
        AvaloniaProperty.Register<Sparkline, IReadOnlyList<double>?>(nameof(Values));

    public static readonly StyledProperty<IBrush> StrokeProperty =
        AvaloniaProperty.Register<Sparkline, IBrush>(nameof(Stroke), Brushes.DeepSkyBlue);

    public static readonly StyledProperty<double> StrokeThicknessProperty =
        AvaloniaProperty.Register<Sparkline, double>(nameof(StrokeThickness), 2.0);

    static Sparkline()
    {
        AffectsRender<Sparkline>(ValuesProperty, StrokeProperty, StrokeThicknessProperty);
    }

    public IReadOnlyList<double>? Values
    {
        get => GetValue(ValuesProperty);
        set => SetValue(ValuesProperty, value);
    }

    public IBrush Stroke
    {
        get => GetValue(StrokeProperty);
        set => SetValue(StrokeProperty, value);
    }

    public double StrokeThickness
    {
        get => GetValue(StrokeThicknessProperty);
        set => SetValue(StrokeThicknessProperty, value);
    }

    public override void Render(DrawingContext ctx)
    {
        base.Render(ctx);
        var values = Values;
        var bounds = Bounds;
        if (values is null || values.Count < 2 || bounds.Width <= 0 || bounds.Height <= 0)
            return;

        double min = values.Min();
        double max = values.Max();
        double range = Math.Max(1e-9, max - min);

        using var geometry = new StreamGeometry();
        using (var gctx = geometry.Open())
        {
            for (int i = 0; i < values.Count; i++)
            {
                double t = i / (double)(values.Count - 1);
                double x = bounds.X + t * bounds.Width;
                double yNorm = (values[i] - min) / range;
                double y = bounds.Y + (1 - yNorm) * bounds.Height;
                if (i == 0)
                    gctx.BeginFigure(new Point(x, y), isFilled: false);
                else
                    gctx.LineTo(new Point(x, y));
            }
            gctx.EndFigure(false);
        }

        var pen = new Pen(Stroke, StrokeThickness);
        ctx.DrawGeometry(null, pen, geometry);
    }
}

Usage:

<local:Sparkline Width="160" Height="36" Values="3,7,4,8,12" StrokeThickness="2"/>

Performance tips

6. Templated control example: Badge

Create Badge : TemplatedControl with properties (Content, Background, Foreground, CornerRadius, MaxWidth, etc.). Default style in Styles.axaml:

<ControlTheme TargetType="local:Badge">
  <Setter Property="Template">
    <ControlTemplate TargetType="local:Badge">
      <Border x:Name="PART_Border"
              Background="{TemplateBinding Background}"
              CornerRadius="{TemplateBinding CornerRadius}"
              Padding="6,0"
              MinHeight="16" MinWidth="20"
              HorizontalAlignment="Left"
              VerticalAlignment="Top">
        <ContentPresenter x:Name="PART_Content"
                          Content="{TemplateBinding Content}"
                          HorizontalAlignment="Center"
                          VerticalAlignment="Center"
                          Foreground="{TemplateBinding Foreground}"/>
      </Border>
    </ControlTemplate>
  </Setter>
  <Setter Property="Background" Value="#E53935"/>
  <Setter Property="Foreground" Value="White"/>
  <Setter Property="CornerRadius" Value="8"/>
  <Setter Property="FontSize" Value="12"/>
  <Setter Property="HorizontalAlignment" Value="Left"/>
</ControlTheme>

In code, capture named parts once the template applies:

public sealed class Badge : TemplatedControl
{
    public static readonly StyledProperty<object?> ContentProperty =
        AvaloniaProperty.Register<Badge, object?>(nameof(Content));

    Border? _border;

    public object? Content
    {
        get => GetValue(ContentProperty);
        set => SetValue(ContentProperty, value);
    }

    protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
    {
        base.OnApplyTemplate(e);
        _border = e.NameScope.Find<Border>("PART_Border");
    }
}

Expose additional state through StyledPropertys so themes and animations can target them.

7. Visual states and control themes

8. Accessibility & input

9. Measure/arrange

Custom controls should override MeasureOverride/ArrangeOverride when size depends on content/drawing.

protected override Size MeasureOverride(Size availableSize)
{
    var values = Values;
    if (values is null || values.Count == 0)
        return Size.Empty;
    return new Size(Math.Min(availableSize.Width, 120), Math.Min(availableSize.Height, 36));
}

TemplatedControl handles measurement via its template (border + content). For custom-drawn controls, define desired size heuristics.

10. Rendering to bitmaps / exporting

Use RenderTargetBitmap for saving custom visuals:

var rtb = new RenderTargetBitmap(new PixelSize(200, 100), new Vector(96, 96));
await rtb.RenderAsync(sparkline);
await using var stream = File.OpenWrite("spark.png");
await rtb.SaveAsync(stream);

Use RenderOptions to adjust interpolation for exported graphics if needed.

11. Combining drawing & template (hybrid)

Example: ChartControl template contains toolbar (Buttons, ComboBox) and a custom ChartCanvas child that handles drawing/selection.

12. Troubleshooting & best practices

13. Practice exercises

  1. Build a templated notification badge that swaps between "pill" and "dot" visuals by toggling PseudoClasses within OnApplyTemplate.
  2. Embed a custom drawn sparkline into that badge (composed via RenderTargetBitmap or direct drawing) and expose it as a named part in the template.
  3. Implement OnCreateAutomationPeer so assistive tech can report badge count and severity; verify with the accessibility tree in DevTools.
  4. Use DevTools Logical Tree to confirm your presenter hierarchy (content vs drawn part) matches expectations and retains bindings when themes change.

Look under the hood (source bookmarks)

Check yourself

What's next