Table of Contents

Custom controls

This guide builds HealthBar, a control for a game's HUD: it shows how much health is left, has its own typed properties, can be used from markup like any built-in control, and turns red below a quarter. Along the way you'll see what a control needs to behave like the built-in ones: properties markup and bindings can reach, layout, a replaceable template, and states a theme can style.

You need a game with IcyUI set up (Getting started) and a feel for templates and visual states.

Tip

Many "custom controls" don't need code. A ProgressBar with a style and a template already makes a good health bar. Write a control when it has its own behaviour or properties your game code works with.

1. Derive from Control

Derive from Control for something with a background, a border and a template, or from UIElement for something you draw and lay out yourself. HealthBar is a Control that holds a fill inside its border:

/// <summary>
/// A bar that shows how much health is left. Below a quarter it switches to a "critical" look, which the theme styles.
/// </summary>
public class HealthBar : Control
{
    private const float CriticalRatio = 0.25f;

    private readonly Border defaultFill;
    private Border fill;
    private float maximum = 100;
    private float health = 100;

    public HealthBar()
    {
        defaultFill = fill = new Border
        {
            HorizontalAlignment = HorizontalAlignment.Left,
            VerticalAlignment = VerticalAlignment.Stretch,
            Background = new SolidColorBrush(Color.MediumSeaGreen),
        };
        ((Border)Chrome).Child = fill;
    }

    /// <summary>The brush of the filled part. A template binds its fill to this property.</summary>
    [Category("Appearance")]
    [RegisterReference]
    public IBrush FillBrush
    {
        get => fill.Background;
        set => fill.Background = value;
    }

    /// <summary>The health that fills the bar.</summary>
    [Category("Behavior")]
    [DefaultValue(100f)]
    [RegisterReference]
    [AffectsArrange]
    public float Maximum
    {
        get => maximum;
        set
        {
            if (SetProperty(ref maximum, value))
            {
                Value = health;
                UpdateCriticalState();
                InvalidateArrange();
            }
        }
    }

    /// <summary>The current health, kept between 0 and <see cref="Maximum"/>.</summary>
    [Category("Behavior")]
    [DefaultValue(100f)]
    [RegisterReference]
    [AffectsArrange]
    public float Value
    {
        get => health;
        set
        {
            if (SetProperty(ref health, float.Clamp(value, 0, Maximum)))
            {
                UpdateCriticalState();
                InvalidateArrange();
            }
        }
    }

    protected override Size MeasureContent() => new(160, 18);

    protected override void ArrangeContent()
    {
        float ratio = Maximum > 0 ? Value / Maximum : 0;
        fill.Width = ContentBounds.Width * ratio;
        base.ArrangeContent();
    }

    // A template provides the fill as a part named PART_Fill; without one, the built-in fill stays.
    protected override void OnApplyTemplate()
    {
        base.OnApplyTemplate();

        if (Template != null && GetTemplateChild<Border>("PART_Fill") is { } part)
        {
            fill = part;
            return;
        }

        fill = defaultFill;
        if (Template == null)
            ((Border)Chrome).Child = fill;
    }

    // Highlighted is the state a theme styles: a custom control can only use the states IcyUI defines.
    private void UpdateCriticalState()
    {
        bool critical = Maximum > 0 && Value / Maximum < CriticalRatio;
        ControlState = critical ? ControlState | ControlState.Highlighted : ControlState & ~ControlState.Highlighted;
    }
}

Four things make it a good citizen:

  • Properties. [RegisterReference] makes a property reachable by markup attributes, styles, bindings and the design tools; no registration call is needed. SetProperty raises PropertyChanged, which is how bindings and styles notice a change. [Category] and [DefaultValue] document it for the editor.
  • Layout. MeasureContent reports the natural size (HealthBar is 160 × 18 unless the page gives it a size), and ArrangeContent lays out the parts once the final size is known. [AffectsArrange] (or [AffectsMeasure]) tells the property system a change needs a new layout pass; InvalidateArrange() does it from code.
  • A replaceable template. A theme or a page can give the control a Template (see step 3). The control keeps a reference to the part it moves, the fill, so it overrides OnApplyTemplate() to find the template's PART_Fill with GetTemplateChild, or to go back to its own fill when there's no template.
  • States. A theme styles a control through its ControlState. The flags are fixed (ControlState): a custom control picks the one that means what it needs. HealthBar uses Highlighted for "critical" and keeps the flags the input system sets, such as Hovered.

2. Use it from markup

Markup finds a type through a clr-namespace prefix, with the assembly name of your game:

<!-- A HUD with the custom HealthBar. The data context has a Health number. -->
<StackPanel xmlns:game="clr-namespace:MyGame.Controls;assembly=MyGame"
            HorizontalAlignment="Left" VerticalAlignment="Top" Margin="16" Width="200">
  <TextBlock Margin="0,0,0,4">Health</TextBlock>
  <game:HealthBar x:Name="PlayerHealth" Maximum="100" Value="{Binding Health}" />
</StackPanel>

Value="{Binding Health}" binds the bar to the HUD's view model, as for any other control. To drop the prefix, register a short name once at startup with RegisterShortName, and write <HealthBar/>. The clr-namespace form always works; short names only remove the prefix.

3. Give it a default look in your theme

A control has no look in the built-in theme, only the plain fill from its constructor. Your game's theme gives it a template and the states. The theme is a file you layer over the built-in one with ExtendDefaultTheme; see Custom themes:

<!-- Themes/MyGame.xml: the default look of HealthBar, with a red fill below a quarter (the Highlighted state). -->
<ResourceDictionary xmlns:game="clr-namespace:MyGame.Controls;assembly=MyGame">
  <ControlTemplate x:Key="HealthBarTemplate" TargetType="game:HealthBar">
    <Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}">
      <Border x:Name="PART_Fill" HorizontalAlignment="Left" VerticalAlignment="Stretch" Background="{TemplateBinding FillBrush}"/>
    </Border>
  </ControlTemplate>

  <Style TargetType="game:HealthBar" Template="{StaticResource HealthBarTemplate}"
         Background="#FF2A2A32" BorderBrush="#FF56566A" BorderThickness="1" FillBrush="#FF3CB371">
    <Style.StateGroups>
      <VisualStateGroup Name="HealthStates">
        <VisualState Name="Critical" State="Highlighted" FillBrush="#FFD83C3C"/>
      </VisualStateGroup>
    </Style.StateGroups>
  </Style>
</ResourceDictionary>
  • The template's PART_Fill is the part the control looks for. A template without one still applies, but the bar shows no fill.
  • State="Highlighted" is the state HealthBar sets below a quarter; the style changes only FillBrush.
  • The file names the type with a clr-namespace prefix, not a short name, because a theme file is loaded while the configuration is built, before your startup code can register the short name.

Load the theme and the page when the game starts:

using Icy.Configuration;
using Icy.Stride.Configuration;
using Stride.Engine;

/// <summary>Add this script to an entity in your scene: it sets up IcyUI with the game's theme and the HealthBar.</summary>
public class HudStartup : StartupScript
{
    public override void Start()
    {
        // The theme file names HealthBar with a clr-namespace prefix, so it loads while the configuration is built.
        StrideIcyHost ui = Game.UseIcyUI(builder => builder.ConfigureTheme().ExtendDefaultTheme("Themes/MyGame.xml"));

        // Optional: lets pages write <HealthBar/> without an xmlns prefix.
        ui.Configuration.Types.Markup.RegisterShortName<HealthBar>();

        ui.MainCanvas.LoadMarkup("Hud.xml", new HudViewModel());
    }
}

Set Health on the view model from your game code and the bar follows; the fill goes red below 25.

What you built

A control with typed properties that markup, styles and bindings can use, that lays itself out, works with or without a template, and exposes a state the theme styles, without a line of drawing code.

Note

A custom control can't add new visual state flags yet: use the closest existing one and say what it means in your theme, as HealthBar does with Highlighted.

Next