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.SetPropertyraisesPropertyChanged, which is how bindings and styles notice a change.[Category]and[DefaultValue]document it for the editor. - Layout.
MeasureContentreports the natural size (HealthBaris 160 × 18 unless the page gives it a size), andArrangeContentlays 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'sPART_Fillwith 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.
HealthBarusesHighlightedfor "critical" and keeps the flags the input system sets, such asHovered.
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_Fillis the part the control looks for. A template without one still applies, but the bar shows no fill. State="Highlighted"is the stateHealthBarsets below a quarter; the style changes onlyFillBrush.- The file names the type with a
clr-namespaceprefix, 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
- Custom themes: recolor the built-in controls and add your own templates.
- Templates and visual states: the concepts behind steps 1 and 3.
- Markup reference: namespaces and short names.