Chapter 21: Customizing Gum UI
Learn how to create custom UI components with animations and visual styling in Gum.
In the previous chapter, we implemented a functional UI system for our game using the Gum framework. While the UI is now fully operational, it uses Gum's default styling. This default styling is good for quickly iterating when building the UI, but it does not match the game's visuals. A well designed UI should not only be functional but also complement the game's overall visual style to create a cohesive experience.
In this chapter you will:
- Learn about Gum's visual customization system and component hierarchy.
- Understand how animation chains and visual states work in Gum.
- Create custom styled button and slider components.
- Update the game's texture atlas to include UI graphics.
- Implement responsive visual feedback for player interactions.
- Apply your custom components to the game's UI screens.
Understanding Gum's Customization System
Gum provides a powerful customization system that separates a UI element's functionality from its appearance. This allows you to maintain the built-in behavior of standard controls while completely changing their visual representation.
Container Hierarchy
Every customized UI component in Gum starts with a top-level container that holds all other visual elements. This container is typically of type ContainerRuntime
, which is similar to the Panel
type we used earlier, but specifically designed for building custom visuals.
The container hierarchy follows a parent-child relationship:
- The top-level container manages the overall size and positioning of the component.
- Visual elements like backgrounds, text, and icons are added as children.
- Child elements can be positioned relative to their parent container.
- Child elements can also be nested within other children, creating deeper hierarchies.
This hierarchical structure allows you to build complex UI components from simpler parts, with each part playing a specific role in the overall design.
Size Relationships with Width and WidthUnits
One powerful feature of Gum is how it handles size relationships between parent and child elements. By using different WidthUnits
values, you can create dependencies that flow in different directions:
- RelativeToChildren: A parent container can size itself based on its children.
- PercentageOfParent: A child element can size itself as a percentage of its parent.
- Absolute: An element can have a fixed pixel size.
- RelativeToParent: An element can size itself relative to a specific container.
For example:
- A button might use a text element with
WidthUnits
set toRelativeToChildren
, which means the text will be exactly the size needed to display its content. - The button's container might use
RelativeToChildren
with some additional padding, allowing the button to automatically resize based on its text content.
Although we have not explicitly assigned WidthUnits and HeightUnits in our code, we have indirectly set these values by calling the Visual's Dock
method. Specifically, by passing Dock.Fill
as the parameter, WidthUnits
and HeightUnits
are both set to RelativeToParent
.
Note
These size relationships can create circular dependencies when a child depends on its parent and the parent depends on the child. In such cases, Gum resolves the conflict by making the child depend on the parent, and the parent ignores that particular child when calculating its size.
Visual Elements
Gum provides several visual element types that we can use to build our custom components:
- ContainerRuntime: An invisible container for organizing other elements.
- NineSliceRuntime: A special graphic that can stretch while preserving its corners and edges.
- TextRuntime: An element for displaying text with custom fonts.
- ColoredRectangleRuntime: A simple colored rectangle for backgrounds or fills.
The NineSliceRuntime
is particularly useful for UI elements that need to resize dynamically. It divides a graphic into nine sections (four corners, four edges, and a center), allowing the element to stretch without distorting its borders.
Note
A MonoGame and Gum community member Kaltinril also has a video series discussing Gum. With permission, the following video segment is included to demonstrate the advantages of using a Nineslice when creating UI elements.
Animation Chains
An AnimationChain
is a sequence of animation frames that play in order, typically looping after the last frame. Each frame in the chain defines:
- Which part of a texture to display (using texture coordinates).
- How long to display that frame (using a frame length value).
- Which texture to use for the frame.
Texture coordinates in Gum use normalized values (0.0 to 1.0) rather than pixel coordinates, where:
- 0.0 represents the left or top edge of the texture.
- 1.0 represents the right or bottom edge of the texture.
To convert from pixel coordinates to normalized values, you divide the pixel position by the texture's width or height.
Visual States
Rather than directly modifying properties when UI elements change state (like when a button is focused), Gum uses a state-based system. Each control type has a specific category name that identifies its collection of states:
- Buttons use
Button.ButtonCategoryName
. - Sliders use
Slider.SliderCategoryName
. - Other control types have their own category names.
Within each category, you define named states that correspond to the control's possible conditions:
- "Enabled" (the normal, unfocused state).
- "Focused" (when the control has focus).
- "Highlighted" (when the mouse hovers over the control).
- "Disabled" (when the control cannot be interacted with).
Each state contains an Apply
action that defines what visual changes occur when that state becomes active. For example, when a button becomes focused, its state might change the background color or switch to an animated version.
Input and Focus Handling
Custom UI components can enhance their interactivity by handling specific input events:
- The
KeyDown
event can be used to add custom keyboard navigation. - The
RollOn
event can detect when the mouse moves over the component. - The
Click
event can respond to mouse clicks or gamepad button presses.
Gum distinguishes between highlighting (visual response to mouse hover) and focus (ability to receive keyboard/gamepad input). For a seamless experience across input devices, a common pattern is to automatically focus elements when the mouse hovers over them, ensuring that visual highlighting and input focus remain synchronized.
Now that we understand the key concepts behind Gum's customization system, we can apply them to create custom UI components for our game.
Updating the Game Resources
Before we create our custom components, we need to update the game's resources to include UI graphics and fonts.
Update the Texture Atlas
First need to update the atlas.png texture atlas file for the game. This new version of the texture atlas includes:
- The characters for the font, generated using Bitmap Font Generator (BMFont)
- The sprites for the UI components we will create
Download the new texture atlas below by right-clicking the following image and saving it as atlas.png in the Content/images folder of the game project, overwriting the existing one.
![]() |
---|
Figure 21-1: The texture atlas for the game updated to include the UI sprites |
The slime and bat sprites are no longer in the same position, and we have some new regions to define for our UI sprites. This means we need to update the texture atlas XML configuration file as well. Open the atlas-definition.xml configuration file and update it to the following:
<?xml version="1.0" encoding="utf-8"?>
<TextureAtlas>
<Texture>images/atlas</Texture>
<Regions>
<Region name="slime-1" x="340" y="0" width="20" height="20" />
<Region name="slime-2" x="340" y="20" width="20" height="20" />
<Region name="bat-1" x="340" y="40" width="20" height="20" />
<Region name="bat-2" x="340" y="60" width="20" height="20" />
<Region name="bat-3" x="360" y="0" width="20" height="20" />
<Region name="unfocused-button" x="259" y="80" width="65" height="14" />
<Region name="focused-button-1" x="259" y="94" width="65" height="14" />
<Region name="focused-button-2" x="259" y="109" width="65" height="14" />
<Region name="panel-background" x="324" y="97" width="15" height="15" />
<Region name="slider-off-background" x="341" y="96" width="11" height="10" />
<Region name="slider-middle-background" x="345" y="81" width="3" height="3" />
<Region name="slider-max-background" x="354" y="96" width="11" height="10" />
</Regions>
<Animations>
<Animation name="slime-animation" delay="200">
<Frame region="slime-1" />
<Frame region="slime-2" />
</Animation>
<Animation name="bat-animation" delay="200">
<Frame region="bat-1" />
<Frame region="bat-2" />
<Frame region="bat-1" />
<Frame region="bat-3" />
</Animation>
<Animation name="focused-button-animation" delay="300">
<Frame region="focused-button-1" />
<Frame region="focused-button-2" />
</Animation>
</Animations>
</TextureAtlas>
The same is now true for the tiles in the texture atlas. Since they have been repositioned in the new texture atlas, we need to update the region
attribute for the tilemap XML configuration file. Open the tilemap-definition.xml
configuration file and update it to the following:
<?xml version="1.0" encoding="utf-8"?>
<Tilemap>
<Tileset region="260 0 80 80" tileWidth="20" tileHeight="20">images/atlas</Tileset>
<Tiles>
00 01 02 01 02 01 02 01 02 01 02 01 02 01 02 03
04 05 05 06 05 05 06 05 05 06 05 05 06 05 05 07
08 09 09 09 09 09 09 09 09 09 09 09 09 09 09 11
04 09 09 09 09 09 09 09 10 09 09 09 09 10 09 07
08 09 10 09 09 09 09 09 09 09 09 09 09 09 09 11
04 09 09 09 09 09 09 09 09 09 09 09 09 09 09 07
08 10 09 09 09 09 09 09 09 09 10 09 09 09 09 11
04 09 09 09 09 09 10 09 09 09 09 09 09 09 09 07
12 13 14 13 14 13 14 13 14 13 14 13 14 13 14 15
</Tiles>
</Tilemap>
Adding Bitmap Fonts
While MonoGame natively uses SpriteFont to draw text, Gum uses the AngelCode Bitmap Font (.fnt) font file format. This means we will need to supply Gum with the .fnt file that defines our font.
Note
For this tutorial, a pregenerated .fnt file is supplied below. For more information on creating .fnt files for Gum, see the Create Fonts with BitmapFontGenerator section of the Gum documentation.
Download the .fnt file below by right-clicking the following link and saving it as 04b_30.fnt in the game project's Content/fonts folder:
Next, add this font file to your content project using the MGCB Editor:
- Open the
Content.mgcb
content project file in the MGCB Editor. - Right-click the
fonts
folder and chooseAdd > Existing Item...
. - Navigate to and select the
04b_30.fnt
file you just downloaded. - In the Properties panel, change the
Build Action
toCopy
. The MonoGame Content Pipeline cannot process .fnt files; we just need it to copy it so we can give it to Gum. - Save the changes and close the MGCB Editor.
![]() |
---|
Figure 21-2: The MGCB Editor with the 04b_30.fnt added to the fonts folder and the Build property set to Copy |
Note
When the .fnt font file was generated using the AngelCode Bitmap Font Generator, the graphics from the .png file that it produces was copied over into our existing texture atlas. By doing this, it allows Gum to render the visuals for elements and the text from the same atlas, reducing texture swapping.
The font file references our existing texture atlas using a relative path that points to the atlas image.
The best practice when using this method is to ensure that when you copy the graphics from the generated .png file to your texture atlas, you place the generated character glyph graphics in the top-left of your texture atlas. This means all of the coordinates in the .fnt file will correctly reference the character glyphs without additional manual changes.
Updating the TextureRegion Class
In Chapter 18 we discussed texture coordinates and that graphic devices use a normalized coordinate system between 0.0 and 1.0.
Since Gum also uses this coordinate system, we will update the TextureRegion
class to easily provide these values for any given region.
Open the TextureRegion.cs
file in the MonoGameLibrary project and add the following properties to the TextureRegion
class:
/// <summary>
/// Gets the top normalized texture coordinate of this region.
/// </summary>
public float TopTextureCoordinate => SourceRectangle.Top / (float)Texture.Height;
/// <summary>
/// Gets the bottom normalized texture coordinate of this region.
/// </summary>
public float BottomTextureCoordinate => SourceRectangle.Bottom / (float)Texture.Height;
/// <summary>
/// Gets the left normalized texture coordinate of this region.
/// </summary>
public float LeftTextureCoordinate => SourceRectangle.Left / (float)Texture.Width;
/// <summary>
/// Gets the right normalized texture coordinate of this region.
/// </summary>
public float RightTextureCoordinate => SourceRectangle.Right / (float)Texture.Width;
Creating Custom UI Components
Now that we have all our resources prepared, we can create custom versions of the UI controls we are using in our game. We will start with an animated button that uses our game's visual style, then move on to creating a custom slider.
The AnimatedButton Class
Our first custom component will be an AnimatedButton
that inherits from Gum's base Button
class. This button will use the game's existing texture atlas for its visual appearance and provide animation when focused.
First, in the DungeonSlime project (your main game project), create a new folder named UI
to store our custom UI components. Next, in that UI
folder, create a new file called AnimatedButton.cs
and add the following code to it:
using System;
using Gum.DataTypes;
using Gum.DataTypes.Variables;
using Gum.Graphics.Animation;
using Gum.Managers;
using Microsoft.Xna.Framework.Input;
using MonoGameGum.Forms.Controls;
using MonoGameGum.GueDeriving;
using MonoGameLibrary.Graphics;
namespace DungeonSlime.UI;
/// <summary>
/// A custom button implementation that inherits from Gum's Button class to provide
/// animated visual feedback when focused.
/// </summary>
internal class AnimatedButton : Button
{
/// <summary>
/// Creates a new AnimatedButton instance using graphics from the specified texture atlas.
/// </summary>
/// <param name="atlas">The texture atlas containing button graphics and animations</param>
public AnimatedButton(TextureAtlas atlas)
{
// Create the top-level container that will hold all visual elements
// Width is relative to children with extra padding, height is fixed
ContainerRuntime topLevelContainer = new ContainerRuntime();
topLevelContainer.Height = 14f;
topLevelContainer.HeightUnits = DimensionUnitType.Absolute;
topLevelContainer.Width = 21f;
topLevelContainer.WidthUnits = DimensionUnitType.RelativeToChildren;
// Create the nine-slice background that will display the button graphics
// A nine-slice allows the button to stretch while preserving corner appearance
NineSliceRuntime nineSliceInstance = new NineSliceRuntime();
nineSliceInstance.Height = 0f;
nineSliceInstance.Texture = atlas.Texture;
nineSliceInstance.TextureAddress = TextureAddress.Custom;
nineSliceInstance.Dock(Gum.Wireframe.Dock.Fill);
topLevelContainer.Children.Add(nineSliceInstance);
// Create the text element that will display the button's label
TextRuntime textInstance = new TextRuntime();
// Name is required so it hooks in to the base Button.Text property
textInstance.Name = "TextInstance";
textInstance.Text = "START";
textInstance.Blue = 130;
textInstance.Green = 86;
textInstance.Red = 70;
textInstance.UseCustomFont = true;
textInstance.CustomFontFile = "fonts/04b_30.fnt";
textInstance.FontScale = 0.25f;
textInstance.Anchor(Gum.Wireframe.Anchor.Center);
textInstance.Width = 0;
textInstance.WidthUnits = DimensionUnitType.RelativeToChildren;
topLevelContainer.Children.Add(textInstance);
// Get the texture region for the unfocused button state from the atlas
TextureRegion unfocusedTextureRegion = atlas.GetRegion("unfocused-button");
// Create an animation chain for the unfocused state with a single frame
AnimationChain unfocusedAnimation = new AnimationChain();
unfocusedAnimation.Name = nameof(unfocusedAnimation);
AnimationFrame unfocusedFrame = new AnimationFrame
{
TopCoordinate = unfocusedTextureRegion.TopTextureCoordinate,
BottomCoordinate = unfocusedTextureRegion.BottomTextureCoordinate,
LeftCoordinate = unfocusedTextureRegion.LeftTextureCoordinate,
RightCoordinate = unfocusedTextureRegion.RightTextureCoordinate,
FrameLength = 0.3f,
Texture = unfocusedTextureRegion.Texture
};
unfocusedAnimation.Add(unfocusedFrame);
// Get the multi-frame animation for the focused button state from the atlas
Animation focusedAtlasAnimation = atlas.GetAnimation("focused-button-animation");
// Create an animation chain for the focused state using all frames from the atlas animation
AnimationChain focusedAnimation = new AnimationChain();
focusedAnimation.Name = nameof(focusedAnimation);
foreach (TextureRegion region in focusedAtlasAnimation.Frames)
{
AnimationFrame frame = new AnimationFrame
{
TopCoordinate = region.TopTextureCoordinate,
BottomCoordinate = region.BottomTextureCoordinate,
LeftCoordinate = region.LeftTextureCoordinate,
RightCoordinate = region.RightTextureCoordinate,
FrameLength = (float)focusedAtlasAnimation.Delay.TotalSeconds,
Texture = region.Texture
};
focusedAnimation.Add(frame);
}
// Assign both animation chains to the nine-slice background
nineSliceInstance.AnimationChains = new AnimationChainList
{
unfocusedAnimation,
focusedAnimation
};
// Create a state category for button states
StateSaveCategory category = new StateSaveCategory();
category.Name = Button.ButtonCategoryName;
topLevelContainer.AddCategory(category);
// Create the enabled (default/unfocused) state
StateSave enabledState = new StateSave();
enabledState.Name = FrameworkElement.EnabledStateName;
enabledState.Apply = () =>
{
// When enabled but not focused, use the unfocused animation
nineSliceInstance.CurrentChainName = unfocusedAnimation.Name;
};
category.States.Add(enabledState);
// Create the focused state
StateSave focusedState = new StateSave();
focusedState.Name = FrameworkElement.FocusedStateName;
focusedState.Apply = () =>
{
// When focused, use the focused animation and enable animation playback
nineSliceInstance.CurrentChainName = focusedAnimation.Name;
nineSliceInstance.Animate = true;
};
category.States.Add(focusedState);
// Create the highlighted+focused state (for mouse hover while focused)
// by cloning the focused state since they appear the same
StateSave highlightedFocused = focusedState.Clone();
highlightedFocused.Name = FrameworkElement.HighlightedFocusedStateName;
category.States.Add(highlightedFocused);
// Create the highlighted state (for mouse hover)
// by cloning the enabled state since they appear the same
StateSave highlighted = enabledState.Clone();
highlighted.Name = FrameworkElement.HighlightedStateName;
category.States.Add(highlighted);
// Add event handlers for keyboard input.
KeyDown += HandleKeyDown;
// Add event handler for mouse hover focus.
topLevelContainer.RollOn += HandleRollOn;
// Assign the configured container as this button's visual
Visual = topLevelContainer;
}
/// <summary>
/// Handles keyboard input for navigation between buttons using left/right keys.
/// </summary>
private void HandleKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Keys.Left)
{
// Left arrow navigates to previous control
HandleTab(TabDirection.Up, loop: true);
}
if (e.Key == Keys.Right)
{
// Right arrow navigates to next control
HandleTab(TabDirection.Down, loop: true);
}
}
/// <summary>
/// Automatically focuses the button when the mouse hovers over it.
/// </summary>
private void HandleRollOn(object sender, EventArgs e)
{
IsFocused = true;
}
}
Next, we will examine the key aspects of this new AnimatedButton
implementation:
Top-level Container
Every customized control needs a top-level container to hold all visual elements. For our button, we create a ContainerRuntime
that manages the button's size and contains all other visual elements:
// Width is relative to children with extra padding, height is fixed
ContainerRuntime topLevelContainer = new ContainerRuntime();
topLevelContainer.Height = 14f;
topLevelContainer.HeightUnits = DimensionUnitType.Absolute;
topLevelContainer.Width = 21f;
topLevelContainer.WidthUnits = DimensionUnitType.RelativeToChildren;
The WidthUnits
property set to RelativeToChildren
means the container will automatically size itself based on its child elements, with 21 pixels of additional space. This allows the button to adapt its size depending on the text content.
Nine-slice Background
We use a NineSliceRuntime
for the button's background. A nine-slice is a special graphic that can be stretch while preserving its corners and edges:
// A nine-slice allows the button to stretch while preserving corner appearance
NineSliceRuntime nineSliceInstance = new NineSliceRuntime();
nineSliceInstance.Height = 0f;
nineSliceInstance.Texture = atlas.Texture;
nineSliceInstance.TextureAddress = TextureAddress.Custom;
nineSliceInstance.Dock(Gum.Wireframe.Dock.Fill);
topLevelContainer.Children.Add(nineSliceInstance);
The TextureAddress
property is set to Custom
so we can specify exactly which portion of the atlas texture to use, while Dock(Dock.Fill)
ensure the background fills the entire button area.
Animated Chains
The most distinctive feature of our animated button is its ability to change appearance when focused. We achieve this by creating two animation chains:
- An "unfocused" animation with a single static frame.
- A "focused" animation with two alternating frames that create a visual effect.
Each animation frame specifies the coordinates within our texture atlas to display:
AnimationChain unfocusedAnimation = new AnimationChain();
unfocusedAnimation.Name = nameof(unfocusedAnimation);
AnimationFrame unfocusedFrame = new AnimationFrame
{
TopCoordinate = unfocusedTextureRegion.TopTextureCoordinate,
BottomCoordinate = unfocusedTextureRegion.BottomTextureCoordinate,
LeftCoordinate = unfocusedTextureRegion.LeftTextureCoordinate,
RightCoordinate = unfocusedTextureRegion.RightTextureCoordinate,
FrameLength = 0.3f,
Texture = unfocusedTextureRegion.Texture
};
unfocusedAnimation.Add(unfocusedFrame);
// Get the multi-frame animation for the focused button state from the atlas
Animation focusedAtlasAnimation = atlas.GetAnimation("focused-button-animation");
// Create an animation chain for the focused state using all frames from the atlas animation
AnimationChain focusedAnimation = new AnimationChain();
focusedAnimation.Name = nameof(focusedAnimation);
foreach (TextureRegion region in focusedAtlasAnimation.Frames)
{
AnimationFrame frame = new AnimationFrame
{
TopCoordinate = region.TopTextureCoordinate,
BottomCoordinate = region.BottomTextureCoordinate,
LeftCoordinate = region.LeftTextureCoordinate,
RightCoordinate = region.RightTextureCoordinate,
FrameLength = (float)focusedAtlasAnimation.Delay.TotalSeconds,
Texture = region.Texture
};
focusedAnimation.Add(frame);
}
// Assign both animation chains to the nine-slice background
nineSliceInstance.AnimationChains = new AnimationChainList
{
unfocusedAnimation,
focusedAnimation
};
States and Categories
In Gum, each control type has a specific category name that identifies its state collection. For buttons we use Button.ButtonCategoryName
:
StateSaveCategory category = new StateSaveCategory();
category.Name = Button.ButtonCategoryName;
topLevelContainer.AddCategory(category);
Within this category, we define how the button appears in different states by creating StateSave
objects with specific state names:
StateSave enabledState = new StateSave();
enabledState.Name = FrameworkElement.EnabledStateName;
enabledState.Apply = () =>
{
// When enabled but not focused, use the unfocused animation
nineSliceInstance.CurrentChainName = unfocusedAnimation.Name;
};
category.States.Add(enabledState);
// Create the focused state
StateSave focusedState = new StateSave();
focusedState.Name = FrameworkElement.FocusedStateName;
focusedState.Apply = () =>
{
// When focused, use the focused animation and enable animation playback
nineSliceInstance.CurrentChainName = focusedAnimation.Name;
nineSliceInstance.Animate = true;
};
category.States.Add(focusedState);
// Create the highlighted+focused state (for mouse hover while focused)
// by cloning the focused state since they appear the same
StateSave highlightedFocused = focusedState.Clone();
highlightedFocused.Name = FrameworkElement.HighlightedFocusedStateName;
category.States.Add(highlightedFocused);
// Create the highlighted state (for mouse hover)
// by cloning the enabled state since they appear the same
StateSave highlighted = enabledState.Clone();
highlighted.Name = FrameworkElement.HighlightedStateName;
category.States.Add(highlighted);
Each state's Apply
action defines what visual changes occur when the state becomes active. In our case, we switch between animation chains to create the desired visual effect.
Custom Input Handling
We add custom keyboard navigation to our button by handling the KeyDown
event:
KeyDown += HandleKeyDown;
/// Handles keyboard input for navigation between buttons using left/right keys.
/// </summary>
private void HandleKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Keys.Left)
{
// Left arrow navigates to previous control
HandleTab(TabDirection.Up, loop: true);
}
if (e.Key == Keys.Right)
{
// Right arrow navigates to next control
HandleTab(TabDirection.Down, loop: true);
}
}
This allows players to navigate between buttons using the left and right arrow keys, providing additional control options beyond the default tab navigation.
Focus Management
We also add a RollOn
event handler to ensure the button gets focus when the mouse hovers over it:
topLevelContainer.RollOn += HandleRollOn;
/// Automatically focuses the button when the mouse hovers over it.
/// </summary>
private void HandleRollOn(object sender, EventArgs e)
{
IsFocused = true;
}
}
This creates a more responsive interface by immediately focusing elements that the player interacts with using the mouse.
The OptionsSlider Class
Now we will create a custom OptionsSlider
class to style the volume sliders. This class inherits from Gum's base Slider
class and provides a styled appearance consistent with the game's visual theme.
In the UI
folder of the DungeonSlime project (your main game project), create a new file called OptionsSlider.cs
and add the following code to it:
using System;
using Gum.DataTypes;
using Gum.DataTypes.Variables;
using Gum.Managers;
using Microsoft.Xna.Framework;
using MonoGameGum.Forms.Controls;
using MonoGameGum.GueDeriving;
using MonoGameLibrary.Graphics;
namespace DungeonSlime.UI;
/// <summary>
/// A custom slider control that inherits from Gum's Slider class.
/// </summary>
public class OptionsSlider : Slider
{
// Reference to the text label that displays the slider's title
private TextRuntime _textInstance;
// Reference to the rectangle that visually represents the current value
private ColoredRectangleRuntime _fillRectangle;
/// <summary>
/// Gets or sets the text label for this slider.
/// </summary>
public string Text
{
get => _textInstance.Text;
set => _textInstance.Text = value;
}
/// <summary>
/// Creates a new OptionsSlider instance using graphics from the specified texture atlas.
/// </summary>
/// <param name="atlas">The texture atlas containing slider graphics.</param>
public OptionsSlider(TextureAtlas atlas)
{
// Create the top-level container for all visual elements
ContainerRuntime topLevelContainer = new ContainerRuntime();
topLevelContainer.Height = 55f;
topLevelContainer.Width = 264f;
TextureRegion backgroundRegion = atlas.GetRegion("panel-background");
// Create the background panel that contains everything
NineSliceRuntime background = new NineSliceRuntime();
background.Texture = atlas.Texture;
background.TextureAddress = TextureAddress.Custom;
background.TextureHeight = backgroundRegion.Height;
background.TextureLeft = backgroundRegion.SourceRectangle.Left;
background.TextureTop = backgroundRegion.SourceRectangle.Top;
background.TextureWidth = backgroundRegion.Width;
background.Dock(Gum.Wireframe.Dock.Fill);
topLevelContainer.AddChild(background);
// Create the title text element
_textInstance = new TextRuntime();
_textInstance.CustomFontFile = @"fonts/04b_30.fnt";
_textInstance.UseCustomFont = true;
_textInstance.FontScale = 0.5f;
_textInstance.Text = "Replace Me";
_textInstance.X = 10f;
_textInstance.Y = 10f;
_textInstance.WidthUnits = DimensionUnitType.RelativeToChildren;
topLevelContainer.AddChild(_textInstance);
// Create the container for the slider track and decorative elements
ContainerRuntime innerContainer = new ContainerRuntime();
innerContainer.Height = 13f;
innerContainer.Width = 241f;
innerContainer.X = 10f;
innerContainer.Y = 33f;
topLevelContainer.AddChild(innerContainer);
TextureRegion offBackgroundRegion = atlas.GetRegion("slider-off-background");
// Create the "OFF" side of the slider (left end)
NineSliceRuntime offBackground = new NineSliceRuntime();
offBackground.Dock(Gum.Wireframe.Dock.Left);
offBackground.Texture = atlas.Texture;
offBackground.TextureAddress = TextureAddress.Custom;
offBackground.TextureHeight = offBackgroundRegion.Height;
offBackground.TextureLeft = offBackgroundRegion.SourceRectangle.Left;
offBackground.TextureTop = offBackgroundRegion.SourceRectangle.Top;
offBackground.TextureWidth = offBackgroundRegion.Width;
offBackground.Width = 28f;
offBackground.WidthUnits = DimensionUnitType.Absolute;
offBackground.Dock(Gum.Wireframe.Dock.Left);
innerContainer.AddChild(offBackground);
TextureRegion middleBackgroundRegion = atlas.GetRegion("slider-middle-background");
// Create the middle track portion of the slider
NineSliceRuntime middleBackground = new NineSliceRuntime();
middleBackground.Dock(Gum.Wireframe.Dock.FillVertically);
middleBackground.Texture = middleBackgroundRegion.Texture;
middleBackground.TextureAddress = TextureAddress.Custom;
middleBackground.TextureHeight = middleBackgroundRegion.Height;
middleBackground.TextureLeft = middleBackgroundRegion.SourceRectangle.Left;
middleBackground.TextureTop = middleBackgroundRegion.SourceRectangle.Top;
middleBackground.TextureWidth = middleBackgroundRegion.Width;
middleBackground.Width = 179f;
middleBackground.WidthUnits = DimensionUnitType.Absolute;
middleBackground.Dock(Gum.Wireframe.Dock.Left);
middleBackground.X = 27f;
innerContainer.AddChild(middleBackground);
TextureRegion maxBackgroundRegion = atlas.GetRegion("slider-max-background");
// Create the "MAX" side of the slider (right end)
NineSliceRuntime maxBackground = new NineSliceRuntime();
maxBackground.Texture = maxBackgroundRegion.Texture;
maxBackground.TextureAddress = TextureAddress.Custom;
maxBackground.TextureHeight = maxBackgroundRegion.Height;
maxBackground.TextureLeft = maxBackgroundRegion.SourceRectangle.Left;
maxBackground.TextureTop = maxBackgroundRegion.SourceRectangle.Top;
maxBackground.TextureWidth = maxBackgroundRegion.Width;
maxBackground.Width = 36f;
maxBackground.WidthUnits = DimensionUnitType.Absolute;
maxBackground.Dock(Gum.Wireframe.Dock.Right);
innerContainer.AddChild(maxBackground);
// Create the interactive track that responds to clicks
// The special name "TrackInstance" is required for Slider functionality
ContainerRuntime trackInstance = new ContainerRuntime();
trackInstance.Name = "TrackInstance";
trackInstance.Dock(Gum.Wireframe.Dock.Fill);
trackInstance.Height = -2f;
trackInstance.Width = -2f;
middleBackground.AddChild(trackInstance);
// Create the fill rectangle that visually displays the current value
_fillRectangle = new ColoredRectangleRuntime();
_fillRectangle.Dock(Gum.Wireframe.Dock.Left);
_fillRectangle.Width = 90f; // Default to 90% - will be updated by value changes
_fillRectangle.WidthUnits = DimensionUnitType.PercentageOfParent;
trackInstance.AddChild(_fillRectangle);
// Add "OFF" text to the left end
TextRuntime offText = new TextRuntime();
offText.Red = 70;
offText.Green = 86;
offText.Blue = 130;
offText.CustomFontFile = @"fonts/04b_30.fnt";
offText.FontScale = 0.25f;
offText.UseCustomFont = true;
offText.Text = "OFF";
offText.Anchor(Gum.Wireframe.Anchor.Center);
offBackground.AddChild(offText);
// Add "MAX" text to the right end
TextRuntime maxText = new TextRuntime();
maxText.Red = 70;
maxText.Green = 86;
maxText.Blue = 130;
maxText.CustomFontFile = @"fonts/04b_30.fnt";
maxText.FontScale = 0.25f;
maxText.UseCustomFont = true;
maxText.Text = "MAX";
maxText.Anchor(Gum.Wireframe.Anchor.Center);
maxBackground.AddChild(maxText);
// Define colors for focused and unfocused states
Color focusedColor = Color.White;
Color unfocusedColor = Color.Gray;
// Create slider state category - Slider.SliderCategoryName is the required name
StateSaveCategory sliderCategory = new StateSaveCategory();
sliderCategory.Name = Slider.SliderCategoryName;
topLevelContainer.AddCategory(sliderCategory);
// Create the enabled (default/unfocused) state
StateSave enabled = new StateSave();
enabled.Name = FrameworkElement.EnabledStateName;
enabled.Apply = () =>
{
// When enabled but not focused, use gray coloring for all elements
background.Color = unfocusedColor;
_textInstance.Color = unfocusedColor;
offBackground.Color = unfocusedColor;
middleBackground.Color = unfocusedColor;
maxBackground.Color = unfocusedColor;
_fillRectangle.Color = unfocusedColor;
};
sliderCategory.States.Add(enabled);
// Create the focused state
StateSave focused = new StateSave();
focused.Name = FrameworkElement.FocusedStateName;
focused.Apply = () =>
{
// When focused, use white coloring for all elements
background.Color = focusedColor;
_textInstance.Color = focusedColor;
offBackground.Color = focusedColor;
middleBackground.Color = focusedColor;
maxBackground.Color = focusedColor;
_fillRectangle.Color = focusedColor;
};
sliderCategory.States.Add(focused);
// Create the highlighted+focused state by cloning the focused state
StateSave highlightedFocused = focused.Clone();
highlightedFocused.Name = FrameworkElement.HighlightedFocusedStateName;
sliderCategory.States.Add(highlightedFocused);
// Create the highlighted state by cloning the enabled state
StateSave highlighted = enabled.Clone();
highlighted.Name = FrameworkElement.HighlightedStateName;
sliderCategory.States.Add(highlighted);
// Assign the configured container as this slider's visual
Visual = topLevelContainer;
// Enable click-to-point functionality for the slider
// This allows users to click anywhere on the track to jump to that value
IsMoveToPointEnabled = true;
// Add event handlers
Visual.RollOn += HandleRollOn;
ValueChanged += HandleValueChanged;
ValueChangedByUi += HandleValueChangedByUi;
}
/// <summary>
/// Automatically focuses the slider when the user interacts with it
/// </summary>
private void HandleValueChangedByUi(object sender, EventArgs e)
{
IsFocused = true;
}
/// <summary>
/// Automatically focuses the slider when the mouse hovers over it
/// </summary>
private void HandleRollOn(object sender, EventArgs e)
{
IsFocused = true;
}
/// <summary>
/// Updates the fill rectangle width to visually represent the current value
/// </summary>
private void HandleValueChanged(object sender, EventArgs e)
{
// Calculate the ratio of the current value within its range
double ratio = (Value - Minimum) / (Maximum - Minimum);
// Update the fill rectangle width as a percentage
// _fillRectangle uses percentage width units, so we multiply by 100
_fillRectangle.Width = 100 * (float)ratio;
}
}
The OptionsSlider
is more complex than then AnimatedButton
because it contains more visual elements. Below are the key aspects of this implementation:
Slider Components
Walking through the OptionsSlider
implementation, it consists of several components
- A background container with a label for the slider.
- An inner container that holds the slider track.
- "OFF" and "MAX" section at each end of the slider.
- A track where the thumb moves.
- A fill rectangle that shows the current value visually.
Each of these elements is styled to match the game's visual theme using sprites from our atlas.
Custom Text Property
We add a custom Text
property to set the slider's label:
/// Gets or sets the text label for this slider.
/// </summary>
public string Text
{
get => _textInstance.Text;
set => _textInstance.Text = value;
}
This allows us to easily customize the label for each slider instance we create.
Visual Feedback
The slider uses color changes to provide visual feedback:
StateSave enabled = new StateSave();
enabled.Name = FrameworkElement.EnabledStateName;
enabled.Apply = () =>
{
// When enabled but not focused, use gray coloring for all elements
background.Color = unfocusedColor;
_textInstance.Color = unfocusedColor;
offBackground.Color = unfocusedColor;
middleBackground.Color = unfocusedColor;
maxBackground.Color = unfocusedColor;
_fillRectangle.Color = unfocusedColor;
};
sliderCategory.States.Add(enabled);
StateSave focused = new StateSave();
focused.Name = FrameworkElement.FocusedStateName;
focused.Apply = () =>
{
// When focused, use white coloring for all elements
background.Color = focusedColor;
_textInstance.Color = focusedColor;
offBackground.Color = focusedColor;
middleBackground.Color = focusedColor;
maxBackground.Color = focusedColor;
_fillRectangle.Color = focusedColor;
};
sliderCategory.States.Add(focused);
When the slider is focused, all its elements change from gray to white, making it clear to the player which UI element currently has focus.
Fill Visualization
One of the most important aspects of a slider is the visual representation of its value. We achieve this by updating the width of the _fillRectangle
element:
/// Updates the fill rectangle width to visually represent the current value
/// </summary>
private void HandleValueChanged(object sender, EventArgs e)
{
// Calculate the ratio of the current value within its range
double ratio = (Value - Minimum) / (Maximum - Minimum);
// Update the fill rectangle width as a percentage
// _fillRectangle uses percentage width units, so we multiply by 100
_fillRectangle.Width = 100 * (float)ratio;
}
}
This method converts the slider's current value to a percentage and applies it to the fill rectangle's width, creating a visual indicator of the current setting.
Updating the Scenes to Use Custom Controls
Now that we have created our custom controls, we need to update our game scenes to use them instead of the default Gum controls.
Updating the TitleScene
First, open the TitleScene.cs
file in the game project and add the following using declaration to the top of the TitleScene
class:
using System;
using DungeonSlime.UI;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using MonoGameGum;
using MonoGameGum.Forms.Controls;
using MonoGameGum.GueDeriving;
using MonoGameLibrary;
using MonoGameLibrary.Graphics;
using MonoGameLibrary.Scenes;
Next, update both the _optionsButton
and the _optionsBackButton
fields to be of our new AnimatedButton
type, and add a new field to store a reference to the texture atlas in.
// Existing fields...
// The options button used to open the options menu.
private AnimatedButton _optionsButton;
// The back button used to exit the options menu back to the title menu.
private AnimatedButton _optionsBackButton;
// Reference to the texture atlas that we can pass to UI elements when they
// are created.
private TextureAtlas _atlas;
Next, in the LoadContent
method, we need to update it so that it loads the texture atlas from the XML configuration file and stores it in the new _atlas
field:
public override void LoadContent()
{
// Load the font for the standard text.
_font = Core.Content.Load<SpriteFont>("fonts/04B_30");
// Load the font for the title text
_font5x = Content.Load<SpriteFont>("fonts/04B_30_5x");
// Load the background pattern texture.
_backgroundPattern = Content.Load<Texture2D>("images/background-pattern");
// Load the sound effect to play when ui actions occur.
_uiSoundEffect = Core.Content.Load<SoundEffect>("audio/ui");
// Load the texture atlas from the xml configuration file.
_atlas = TextureAtlas.FromFile(Core.Content, "images/atlas-definition.xml");
}
Next, update the CreateTitlePanel
method so that instead of using the default Gum Button
Forms controls it now uses our custom AnimatedButton
control and remove the explicit setting of the Visual.Width
property since this is managed by the AnimatedButton
now:
private void CreateTitlePanel()
{
// Create a container to hold all of our buttons
_titleScreenButtonsPanel = new Panel();
_titleScreenButtonsPanel.Dock(Gum.Wireframe.Dock.Fill);
_titleScreenButtonsPanel.AddToRoot();
AnimatedButton startButton = new AnimatedButton(_atlas);
startButton.Anchor(Gum.Wireframe.Anchor.BottomLeft);
startButton.Visual.X = 50;
startButton.Visual.Y = -12;
startButton.Text = "Start";
startButton.Click += HandleStartClicked;
_titleScreenButtonsPanel.AddChild(startButton);
_optionsButton = new AnimatedButton(_atlas);
_optionsButton.Anchor(Gum.Wireframe.Anchor.BottomRight);
_optionsButton.Visual.X = -50;
_optionsButton.Visual.Y = -12;
_optionsButton.Text = "Options";
_optionsButton.Click += HandleOptionsClicked;
_titleScreenButtonsPanel.AddChild(_optionsButton);
startButton.IsFocused = true;
}
Finally, update the CreateOptionsPanel
method so that:
- It uses a
TextRuntime
to display the text "OPTIONS" using the bitmap font - Instead of using the default Gum
Button
andSlider
Forms controls, it now uses our customAnimatedButton
andOptionsSlider
controls. - Both the
musicSlider
andsfxSlider
have been givenName
andText
properties.
private void CreateOptionsPanel()
{
_optionsPanel = new Panel();
_optionsPanel.Dock(Gum.Wireframe.Dock.Fill);
_optionsPanel.IsVisible = false;
_optionsPanel.AddToRoot();
TextRuntime optionsText = new TextRuntime();
optionsText.X = 10;
optionsText.Y = 10;
optionsText.Text = "OPTIONS";
optionsText.UseCustomFont = true;
optionsText.FontScale = 0.5f;
optionsText.CustomFontFile = @"fonts/04b_30.fnt";
_optionsPanel.AddChild(optionsText);
OptionsSlider musicSlider = new OptionsSlider(_atlas);
musicSlider.Name = "MusicSlider";
musicSlider.Text = "MUSIC";
musicSlider.Anchor(Gum.Wireframe.Anchor.Top);
musicSlider.Visual.Y = 30f;
musicSlider.Minimum = 0;
musicSlider.Maximum = 1;
musicSlider.Value = Core.Audio.SongVolume;
musicSlider.SmallChange = .1;
musicSlider.LargeChange = .2;
musicSlider.ValueChanged += HandleMusicSliderValueChanged;
musicSlider.ValueChangeCompleted += HandleMusicSliderValueChangeCompleted;
_optionsPanel.AddChild(musicSlider);
OptionsSlider sfxSlider = new OptionsSlider(_atlas);
sfxSlider.Name = "SfxSlider";
sfxSlider.Text = "SFX";
sfxSlider.Anchor(Gum.Wireframe.Anchor.Top);
sfxSlider.Visual.Y = 93;
sfxSlider.Minimum = 0;
sfxSlider.Maximum = 1;
sfxSlider.Value = Core.Audio.SoundEffectVolume;
sfxSlider.SmallChange = .1;
sfxSlider.LargeChange = .2;
sfxSlider.ValueChanged += HandleSfxSliderChanged;
sfxSlider.ValueChangeCompleted += HandleSfxSliderChangeCompleted;
_optionsPanel.AddChild(sfxSlider);
_optionsBackButton = new AnimatedButton(_atlas);
_optionsBackButton.Text = "BACK";
_optionsBackButton.Anchor(Gum.Wireframe.Anchor.BottomRight);
_optionsBackButton.X = -28f;
_optionsBackButton.Y = -10f;
_optionsBackButton.Click += HandleOptionsButtonBack;
_optionsPanel.AddChild(_optionsBackButton);
}
Updating the GameScene
Next, open the GameScene.cs
file in the game project and add the following using declaration to the top of the GameScene
class:
using System;
using DungeonSlime.UI;
using Gum.DataTypes;
using Gum.Managers;
using Gum.Wireframe;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using MonoGameGum;
using MonoGameGum.Forms.Controls;
using MonoGameGum.GueDeriving;
using MonoGameLibrary;
using MonoGameLibrary.Graphics;
using MonoGameLibrary.Input;
using MonoGameLibrary.Scenes;
Next, update the _resumeButton
field to be of our new AnimatedButton
type and add a field to store a reference to the texture atlas in.
// Existing fields...
// A reference to the resume button UI element so we can focus it
// when the game is paused.
private AnimatedButton _resumeButton;
// The UI sound effect to play when a UI event is triggered.
private SoundEffect _uiSoundEffect;
// Reference to the texture atlas that we can pass to UI elements when they
// are created.
private TextureAtlas _atlas;
Next, in the LoadContent
method, we need to update it so that it stores the texture atlas once loaded in the new _atlas
field.
public override void LoadContent()
{
// Create the texture atlas from the XML configuration file
_atlas = TextureAtlas.FromFile(Core.Content, "images/atlas-definition.xml");
// Create the slime animated sprite from the atlas.
_slime = _atlas.CreateAnimatedSprite("slime-animation");
_slime.Scale = new Vector2(4.0f, 4.0f);
// Create the bat animated sprite from the atlas.
_bat = _atlas.CreateAnimatedSprite("bat-animation");
_bat.Scale = new Vector2(4.0f, 4.0f);
// Create the tilemap from the XML configuration file.
_tilemap = Tilemap.FromFile(Content, "images/tilemap-definition.xml");
_tilemap.Scale = new Vector2(4.0f, 4.0f);
// Load the bounce sound effect
_bounceSoundEffect = Content.Load<SoundEffect>("audio/bounce");
// Load the collect sound effect
_collectSoundEffect = Content.Load<SoundEffect>("audio/collect");
// Load the font
_font = Core.Content.Load<SpriteFont>("fonts/04B_30");
// Load the sound effect to play when ui actions occur.
_uiSoundEffect = Core.Content.Load<SoundEffect>("audio/ui");
}
Finally, update the CreatePausePanel
method so that
- Instead of using a
ColoredRectangleRuntime
for the background of the pause panel, it now uses aNineSliceRuntime
that uses the sprite from the texture atlas. - The
textInstance
is updated so that it uses the custom bitmap font file. - The
_resumeButton
andquiteButton
are updated to use our customAnimatedButton
control instead of the default GumButton
Forms control.
private void CreatePausePanel()
{
_pausePanel = new Panel();
_pausePanel.Anchor(Anchor.Center);
_pausePanel.Visual.WidthUnits = DimensionUnitType.Absolute;
_pausePanel.Visual.HeightUnits = DimensionUnitType.Absolute;
_pausePanel.Visual.Height = 70;
_pausePanel.Visual.Width = 264;
_pausePanel.IsVisible = false;
_pausePanel.AddToRoot();
TextureRegion backgroundRegion = _atlas.GetRegion("panel-background");
NineSliceRuntime background = new NineSliceRuntime();
background.Dock(Dock.Fill);
background.Texture = backgroundRegion.Texture;
background.TextureAddress = TextureAddress.Custom;
background.TextureHeight = backgroundRegion.Height;
background.TextureLeft = backgroundRegion.SourceRectangle.Left;
background.TextureTop = backgroundRegion.SourceRectangle.Top;
background.TextureWidth = backgroundRegion.Width;
_pausePanel.AddChild(background);
TextRuntime textInstance = new TextRuntime();
textInstance.Text = "PAUSED";
textInstance.CustomFontFile = @"fonts/04b_30.fnt";
textInstance.UseCustomFont = true;
textInstance.FontScale = 0.5f;
textInstance.X = 10f;
textInstance.Y = 10f;
_pausePanel.AddChild(textInstance);
_resumeButton = new AnimatedButton(_atlas);
_resumeButton.Text = "RESUME";
_resumeButton.Anchor(Anchor.BottomLeft);
_resumeButton.Visual.X = 9f;
_resumeButton.Visual.Y = -9f;
_resumeButton.Click += HandleResumeButtonClicked;
_pausePanel.AddChild(_resumeButton);
AnimatedButton quitButton = new AnimatedButton(_atlas);
quitButton.Text = "QUIT";
quitButton.Anchor(Anchor.BottomRight);
quitButton.Visual.X = -9f;
quitButton.Visual.Y = -9f;
quitButton.Click += HandleQuitButtonClicked;
_pausePanel.AddChild(quitButton);
}
Testing the Styled UI
When you run the game now, you will see a dramatic improvement in the visual appearance of the UI:
- The buttons now use our custom animated background that pulses when focused.
- The sliders have a cleaner, mores stylized appearance with the OFF and MAX labels.
- All text uses our custom bitmap font.
- Visual feedback clearly indicates which element has focus.
Figure 21-3: The game using Gum now with custom styled UI components |
The entire UI now has a cohesive style that matches the rest of the game.
Conclusion
In this chapter, you learned how to transform basic UI components into custom, styled elements that match the game's visual theme. You explored several key aspects of UI customization:
- How container hierarchies and size relationships work in Gum.
- Creating animation chains for visual feedback.
- Using the state system to respond to user interactions.
- Building complex custom controls by extending base classes.
- Integrating custom fonts and graphics from a texture atlas.
By creating reusable custom controls, you have not only improved the look of your game, but you have also developed components that can be used in future projects. This approach of separating functionality from appearance allows you to maintain consistent behavior while completely changing the visual style to match different games.
The principles you have learned in this chapter extend beyond the specific components we created. You can apply the same techniques to create other custom UI elements like checkboxes, radio buttons, scroll panels, and more. By understanding how to build on Gum's foundation, you have the tools to create any UI component your game might need.
Test Your Knowledge
What are the two main approaches to customizing visuals in Gum, and when would you use each one?
The two main approaches are:
- Direct property assignment: Setting properties directly in code (like
button.Visual.Width = 100
). This approach is best for initial setup of UI elements and static properties that do not change during gameplay. - States (StateSave objects): Defining different visual states that are applied automatically in response to interactions. This approach is best for dynamic changes that happen during gameplay, like highlighting a button when it is focused or changing colors when a slider is adjusted.
- Direct property assignment: Setting properties directly in code (like
What is the purpose of using a top-level container in a custom Gum control?
A top-level container in a custom Gum control serves several purposes:
- It provides a single parent element that holds all visual components of the control.
- It establishes the coordinate system for positioning child elements.
- It can manage the overall size of the control (often using
RelativeToChildren
sizing). - It serves as the attachment point for states and categories.
- It creates a clear separation between the control's visuals and its functionality.
How do animation chains work in Gum, and what are the key components needed to create one?
Animation chains in Gum work by displaying a sequence of frames in order, typically looping after the last frame. The key components needed to create an animation chain are:
- An
AnimationChain
object to hold the sequence of frames - Multiple
AnimationFrame
objects, each with:- Texture coordinates (left, right, top, bottom) defining which part of the texture to display
- A frame length value determining how long to display the frame
- A reference to the texture where the frame appears
- A method to add the animation to a visual element (like assigning to a NineSliceRuntime's CurrentChainName)
The animation system uses normalized texture coordinates (0.0 to 1.0) rather than pixel coordinates.
- An
What is the relationship between Gum's state system and Forms controls, and why is it important?
Gum's state system links with Forms controls through specifically named categories and states:
- Each Forms control type has a reserved category name (e.g., Button.ButtonCategoryName)
- Within that category, the control looks for states with specific names (Enabled, Focused, Highlighted, etc.)
- When the control's state changes (like gaining focus), it automatically applies the corresponding visual state
This relationship is important because it:
- Separates the control's functionality from its appearance
- Enables consistent behavior while allowing complete visual customization
- Provides automatic visual feedback in response to user interactions without requiring manual state management
- Makes it easier to create controls that work with mouse, keyboard, and gamepad input.