Today, we’ll look at the process of creating custom controls using a slider as an example.
This article is part of a series about creating a simple media player using WinUI 3 framework. Other articles in this series can be found under the MusicApp tag.
In MusicApp’s async architecture, the default slider had a key limitation: it reported every position change, but we only needed user-initiated events. This required a custom solution.
I decided to write my own slider from scratch. After all, why not?
Since this is a highly specialized control with a predefined use case, we don’t need to replicate every feature of the original slider. Here’s what our custom slider must handle:
-
Set a max value (e.g., track duration or max volume level)
-
Adjustable step increments for mouse/keyboard input
-
A command to trigger when the user changes the slider’s position
-
A converter to transform the value into tooltip text
Everything else? Unnecessary complexity we can ditch.
Since we already know the value range upfront, we’re using int as the core type—no need to overcomplicate it with floating-point math.
Let’s start by creating the backbone of our control—dependency properties. Here’s the Value property implementation:
// Registering the Value dependency property
public static readonly DependencyProperty ValueProperty =
DependencyProperty.Register(
nameof(Value), // Property name
typeof(int), // Property type (int as we decided)
typeof(AppSlider), // Owner control type
new PropertyMetadata( // Default metadata:
0, // - Default value
OnValuePropertyChanged) // - Change callback
);
// Property change handler
private static void OnValuePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
// Safely cast and check the new value
if (d is AppSlider slider && e.NewValue is int newValue)
{
// Clamp the value to our allowed range
if (newValue < 0)
{
slider.Value = 0; // Minimum boundary
}
else if (newValue > slider.MaxValue)
{
slider.Value = slider.MaxValue; // Maximum boundary
}
else
{
// Valid value case:
slider.UpdateThumbPosition(); // Update UI
slider.UpdateToolTip(); // Refresh tooltip
}
}
}
All other properties follow the same proven pattern: a dependency property paired with its change handler for validation and UI updates.
Since our slider is strictly horizontal, we define three key visual parts using [TemplatePart]
attributes: the track background (PART_TRACK
), the filled portion (PART_DECREASE
), and the draggable thumb (PART_THUMB
). This keeps the template customizable while ensuring core functionality through these required elements:
[TemplatePart(Name = "PART_DECREASE", Type = typeof(Rectangle))]
[TemplatePart(Name = "PART_TRACK", Type = typeof(Rectangle))]
[TemplatePart(Name = "PART_THUMB", Type = typeof(Thumb))]
public class AppSlider : Control {}
This OnApplyTemplate
method is where we connect the logical control with its visual template parts. It handles three critical tasks: (1) safely unregistering existing event handlers to prevent leaks, (2) fetching the template parts (track, thumb, and filled area), and (3) setting up new event handlers for user interactions and size changes. The method ensures our control reacts properly to both template changes and user input.
protected override void OnApplyTemplate()
{
// 1. Clean up previous template part subscriptions
if (track != null)
{
track.SizeChanged -= Track_SizeChanged; // Unsubscribe from track resizing
}
if (thumb != null)
{
// Remove all thumb drag/pointer event handlers
thumb.DragStarted -= Thumb_DragStarted;
thumb.DragCompleted -= Thumb_DragCompleted;
thumb.DragDelta -= Thumb_DragDelta;
}
base.OnApplyTemplate(); // Let base class apply the template first
// 2. Retrieve required visual parts from the template
decrease = GetTemplateChild("PART_DECREASE") as Rectangle; // Filled portion
track = GetTemplateChild("PART_TRACK") as Rectangle; // Background track
thumb = GetTemplateChild("PART_THUMB") as Thumb; // Draggable handle
UpdateThumbPosition(); // Position thumb based on current Value
// 3. Set up new event subscriptions
if (track != null)
{
track.SizeChanged += Track_SizeChanged; // Handle track resize
}
if (thumb != null)
{
// Drag events for user interaction
thumb.DragStarted += Thumb_DragStarted;
thumb.DragCompleted += Thumb_DragCompleted;
thumb.DragDelta += Thumb_DragDelta;
// Pointer events for hover effects
thumb.PointerEntered += Thumb_PointerEntered;
thumb.PointerExited += Thumb_PointerExited;
}
UpdateToolTip(); // Initialize tooltip display
}
The final step involves implementing the event handlers we subscribed to earlier. These methods handle mouse/touch interactions by: (1) managing visual states (like hover and pressed effects) through VisualStateManager
, and (2) translating pointer positions into slider values. This brings our custom slider to life with responsive visuals and precise input handling.
// Changes visual state when pointer enters the control
protected override void OnPointerEntered(PointerRoutedEventArgs e)
{
VisualStateManager.GoToState(this, "PointerOver", true); // Activates "hover" state
base.OnPointerEntered(e); // Preserves default behavior
}
// Reverts to normal state when pointer leaves
protected override void OnPointerExited(PointerRoutedEventArgs e)
{
SetNormalMode(); // Custom method to reset visual state
base.OnPointerExited(e);
}
// Handles click/tap interactions
protected override void OnPointerPressed(PointerRoutedEventArgs e)
{
VisualStateManager.GoToState(this, "Pressed", true); // Activates "pressed" state
if (track != null && thumb != null)
{
// Convert pointer position to slider value:
var pos = e.GetCurrentPoint(this).Position.X; // Get X-coordinate
var relativePos = pos / (track.ActualWidth - thumb.ActualWidth); // Normalize (0-1)
SetValue(MaxValue * relativePos); // Scale to max value
}
}
These methods implement the core drag interaction logic for the slider’s thumb, covering three key phases: drag start, movement, and completion:
private void Thumb_DragCompleted(object sender, DragCompletedEventArgs e)
{
// Store final thumb position based on filled area width
thumbPosition = decrease?.ActualWidth ?? 0;
// Hide tooltip when dragging ends (if thumb isn't hovered)
if (thumb != null && !isThumbUnderPoint && toolTip != null)
{
toolTip.IsOpen = false;
ToolTipService.SetToolTip(thumb, null); // Clear tooltip binding
}
}
private void Thumb_DragStarted(object sender, DragStartedEventArgs e)
{
// Initialize position tracking
thumbPosition = decrease?.ActualWidth ?? 0;
// Show tooltip when dragging begins
if (thumb != null && toolTip != null)
{
ToolTipService.SetToolTip(thumb, toolTip); // Rebind tooltip
toolTip.IsOpen = true;
}
}
private void Thumb_DragDelta(object sender, DragDeltaEventArgs e)
{
// Ignore if thumb isn't actually dragging (safety check)
if (sender is Thumb t && !t.IsDragging) return;
if (decrease != null && track != null && thumb != null)
{
// Update thumb position based on drag delta
thumbPosition += e.HorizontalChange;
// Convert pixel offset to slider value (0-MaxValue)
SetValue(MaxValue * (thumbPosition / (track.ActualWidth - thumb.ActualWidth)));
}
}
The control’s visual template is defined in separate resource files (like Generic.xaml
), keeping presentation separate from logic. This enables easy skinning, runtime theme changes, and maintainability while requiring DefaultStyleKey
to connect the template to your control class:
DefaultStyleKey = typeof(AppSlider);
That’s it! For more details, check out the full implementation on GitHub: AppSlider.cs, generic.xaml.