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.