Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8713493
feat: add vertical orientation support for RangeSelector control
Illustar0 Dec 3, 2025
b7d3028
fix: add missing sample
Illustar0 Dec 3, 2025
c701fee
fix: correct vertical normalized position calculation logic
Illustar0 Dec 3, 2025
dbdd1ea
refactor: simplify orientation check using ternary expression
Illustar0 Dec 3, 2025
db727ca
fix: prevent multiple subscriptions to Loaded event
Illustar0 Dec 4, 2025
fbd0f46
feat: implement vertical tooltip placement for RangeSelector control
Illustar0 Dec 4, 2025
a853889
fix: use DesiredSize.Height for tooltip measurement
Illustar0 Dec 4, 2025
a8b61fc
fix: prevent tooltip from showing when vertical placement is not set
Illustar0 Dec 4, 2025
b540174
fix: correct keyboard navigation for RTL contexts
Illustar0 Jan 9, 2026
3c9e8fe
feat: add orientation toggle and fix vertical mode layout issues
Illustar0 Jan 9, 2026
05c3b66
refactor: remove separate vertical RangeSelector sample
Illustar0 Jan 9, 2026
282b30f
fix: set vertical RangeSelector sample Minimum to 0
Illustar0 Jan 10, 2026
9074b65
refactor: introduce UVPoint helper to eliminate orientation-dependent…
Illustar0 Jan 10, 2026
b403a0f
feat: add UVCoord helper struct for orientation-aware coordinates
Illustar0 Jan 11, 2026
3983082
refactor: migrate from UVPoint to UVCoord for coordinate handling
Illustar0 Jan 11, 2026
391838e
refactor: remove deprecated UVPoint helper
Illustar0 Jan 11, 2026
30d83e1
docs: add comments explaining vertical drag logic
Illustar0 Jan 11, 2026
8036bca
fix: replace null suppression with null checks in DragWidth
Illustar0 Jan 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions components/RangeSelector/samples/RangeSelector.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,22 @@ A `RangeSelector` is pretty similar to a regular `Slider`, and shares some of it

> [!Sample RangeSelectorSample]

> [!NOTE]
> Use 'VerticalAlignment="Stretch"' When 'Orientation="Vertical"'

Like this:

```xaml
<controls:RangeSelector x:Name="rangeSelector"
VerticalAlignment="Stretch"
Maximum="100"
Minimum="0"
Orientation="Vertical"
RangeEnd="100"
RangeStart="0"
StepFrequency="1" />
```

> [!NOTE]
> If you are using a RangeSelector within a ScrollViewer you'll need to add some codes. This is because by default, the ScrollViewer will block the thumbs of the RangeSelector to capture the pointer.

Expand Down
2 changes: 2 additions & 0 deletions components/RangeSelector/samples/RangeSelectorSample.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
MinHeight="86"
MaxWidth="560"
HorizontalAlignment="Stretch">
<!-- Use 'VerticalAlignment="Stretch"' When 'Orientation="Vertical"' -->
<controls:RangeSelector x:Name="rangeSelector"
VerticalAlignment="Center"
IsEnabled="{x:Bind Enable, Mode=OneWay}"
Maximum="{x:Bind Maximum, Mode=OneWay}"
Minimum="{x:Bind Minimum, Mode=OneWay}"
Orientation="{x:Bind OrientationMode, Mode=OneWay}"
RangeEnd="100"
RangeStart="0"
StepFrequency="{x:Bind StepFrequency, Mode=OneWay}" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ namespace RangeSelectorExperiment.Samples;
[ToolkitSampleNumericOption("Minimum", 0, 0, 100, 1, false, Title = "Minimum")]
[ToolkitSampleNumericOption("Maximum", 100, 0, 100, 1, false, Title = "Maximum")]
[ToolkitSampleNumericOption("StepFrequency", 1, 0, 10, 1, false, Title = "StepFrequency")]
[ToolkitSampleMultiChoiceOption("OrientationMode", "Horizontal", "Vertical", Title = "Orientation")]
[ToolkitSampleBoolOption("Enable", true, Title = "IsEnabled")]

[ToolkitSample(id: nameof(RangeSelectorSample), "RangeSelector", description: $"A sample for showing how to create and use a {nameof(RangeSelector)} control.")]
Expand Down
130 changes: 130 additions & 0 deletions components/RangeSelector/src/RangeSelector.Helpers.UVCoord.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

namespace CommunityToolkit.WinUI.Controls;

/// <summary>
/// A struct representing a coordinate in UV adjusted space.
/// </summary>
[DebuggerDisplay("({U}u,{V}v)")]
public struct UVCoord
{
/// <summary>
/// Initializes a new instance of the <see cref="UVCoord"/> struct.
/// </summary>
public UVCoord(Orientation orientation)
{
Orientation = orientation;
}

/// <summary>
/// Initializes a new instance of the <see cref="UVCoord"/> struct.
/// </summary>
public UVCoord(double x, double y, Orientation orientation)
{
X = x;
Y = y;
Orientation = orientation;
}

/// <summary>
/// Initializes a new instance of the <see cref="UVCoord"/> struct.
/// </summary>
public UVCoord(Point point, Orientation orientation) : this(point.X, point.Y, orientation)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="UVCoord"/> struct.
/// </summary>
public UVCoord(Size size, Orientation orientation) : this(size.Width, size.Height, orientation)
{
}

/// <summary>
/// Gets or sets the X coordinate.
/// </summary>
public double X { readonly get; set; }

/// <summary>
/// Gets or sets the Y coordinate.
/// </summary>
public double Y { readonly get; set; }

/// <summary>
/// Gets or sets the orientation for translation between the XY and UV coordinate systems.
/// </summary>
public Orientation Orientation { get; set; }

/// <summary>
/// Gets or sets the U coordinate.
/// </summary>
public double U
{
readonly get => Orientation is Orientation.Horizontal ? X : Y;
set
{
if (Orientation is Orientation.Horizontal)
{
X = value;
}
else
{
Y = value;
}
}
}

/// <summary>
/// Gets or sets the V coordinate.
/// </summary>
public double V
{
readonly get => Orientation is Orientation.Vertical ? X : Y;
set
{
if (Orientation is Orientation.Vertical)
{
X = value;
}
else
{
Y = value;
}
}
}

/// <summary>
/// Implicitly casts a <see cref="UVCoord"/> to a <see cref="Point"/>.
/// </summary>
public static implicit operator Point(UVCoord uv) => new(uv.X, uv.Y);

/// <summary>
/// Implicitly casts a <see cref="UVCoord"/> to a <see cref="Size"/>.
/// </summary>
public static implicit operator Size(UVCoord uv) => new(uv.X, uv.Y);

public static UVCoord operator +(UVCoord addend1, UVCoord addend2)
{
if (addend1.Orientation != addend2.Orientation)
{
throw new InvalidOperationException($"Cannot add {nameof(UVCoord)} with mismatched {nameof(Orientation)}.");
}

var xSum = addend1.X + addend2.X;
var ySum = addend1.Y + addend2.Y;
var orientation = addend1.Orientation;
return new UVCoord(xSum, ySum, orientation);
}

public static bool operator ==(UVCoord coord1, UVCoord coord2)
{
return coord1.U == coord2.U && coord1.V == coord2.V;
}

public static bool operator !=(UVCoord measure1, UVCoord measure2)
{
return !(measure1 == measure2);
}
}
96 changes: 76 additions & 20 deletions components/RangeSelector/src/RangeSelector.Input.Drag.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ public partial class RangeSelector : Control
{
private void MinThumb_DragDelta(object sender, DragDeltaEventArgs e)
{
_absolutePosition += e.HorizontalChange;
var uvChange = new UVCoord(e.HorizontalChange, e.VerticalChange, Orientation);
_absolutePosition += uvChange.U;

RangeStart = DragThumb(_minThumb, 0, DragWidth(), _absolutePosition);
var maxThumbPos = GetCanvasPos(_maxThumb).U;
RangeStart = Orientation == Orientation.Horizontal
? DragThumb(_minThumb, 0, maxThumbPos, _absolutePosition)
: DragThumb(_minThumb, maxThumbPos, DragWidth(), _absolutePosition);

if (_toolTipText != null)
{
Expand All @@ -23,9 +27,15 @@ private void MinThumb_DragDelta(object sender, DragDeltaEventArgs e)

private void MaxThumb_DragDelta(object sender, DragDeltaEventArgs e)
{
_absolutePosition += e.HorizontalChange;

RangeEnd = DragThumb(_maxThumb, 0, DragWidth(), _absolutePosition);
var uvChange = new UVCoord(e.HorizontalChange, e.VerticalChange, Orientation);
_absolutePosition += uvChange.U;
// Adjust the position of the max thumb and update RangeEnd.
// Note that max thumb has a lower U coordinate than min in vertical orientation,
// so the valid range changes from between [0, minThumbPos] to [minThumbPos, DragWidth()]
var minThumbPos = GetCanvasPos(_minThumb).U;
RangeEnd = Orientation == Orientation.Horizontal
? DragThumb(_maxThumb, minThumbPos, DragWidth(), _absolutePosition)
: DragThumb(_maxThumb, 0, minThumbPos, _absolutePosition);

if (_toolTipText != null)
{
Expand Down Expand Up @@ -67,48 +77,94 @@ private void Thumb_DragCompleted(object sender, DragCompletedEventArgs e)

private double DragWidth()
{
return _containerCanvas!.ActualWidth - _maxThumb!.Width;
if (_containerCanvas == null || _maxThumb == null)
{
return 0;
}
return new UVCoord(_containerCanvas.ActualWidth, _containerCanvas.ActualHeight, Orientation).U
- new UVCoord(_maxThumb.Width, _maxThumb.Height, Orientation).U;
}

private double DragThumb(Thumb? thumb, double min, double max, double nextPos)
{
nextPos = Math.Max(min, nextPos);
nextPos = Math.Min(max, nextPos);

Canvas.SetLeft(thumb, nextPos);
// Position the thumb
var thumbPos = new UVCoord(Orientation) { U = nextPos };
Canvas.SetLeft(thumb, thumbPos.X);
Canvas.SetTop(thumb, thumbPos.Y);

// Position the tooltip
if (_toolTip != null && thumb != null)
{
var thumbCenter = nextPos + (thumb.Width / 2);
var thumbSize = new UVCoord(thumb.Width, thumb.Height, Orientation).U;
var thumbCenter = nextPos + (thumbSize / 2);
_toolTip.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
var ttWidth = _toolTip.ActualWidth / 2;
var ttHalfSize = new UVCoord(_toolTip.DesiredSize, Orientation).U / 2;

var toolTipPos = new UVCoord(Orientation) { U = thumbCenter - ttHalfSize };
Canvas.SetLeft(_toolTip, toolTipPos.X);
Canvas.SetTop(_toolTip, toolTipPos.Y);

Canvas.SetLeft(_toolTip, thumbCenter - ttWidth);
if (Orientation == Orientation.Vertical)
{
UpdateToolTipPositionForVertical();
}
}

return Minimum + ((nextPos / DragWidth()) * (Maximum - Minimum));
// Calculate the range value
// Horizontal: left (0) = Minimum, right (DragWidth) = Maximum
// Vertical: top (0) = Maximum, bottom (DragWidth) = Minimum (inverted)
var ratio = nextPos / DragWidth();
var range = Maximum - Minimum;

return Orientation == Orientation.Horizontal
? Minimum + (ratio * range)
: Maximum - (ratio * range);
}

private void Thumb_DragStarted(Thumb thumb)
{
var useMin = thumb == _minThumb;
var otherThumb = useMin ? _maxThumb : _minThumb;

_absolutePosition = Canvas.GetLeft(thumb);
_absolutePosition = GetCanvasPos(thumb).U;
Canvas.SetZIndex(thumb, 10);
Canvas.SetZIndex(otherThumb, 0);
_oldValue = RangeStart;

if (_toolTip != null)
{
_toolTip.Visibility = Visibility.Visible;
var thumbCenter = _absolutePosition + (thumb.Width / 2);
_toolTip.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
var ttWidth = _toolTip.ActualWidth / 2;
Canvas.SetLeft(_toolTip, thumbCenter - ttWidth);

if (_toolTipText != null)
UpdateToolTipText(this, _toolTipText, useMin ? RangeStart : RangeEnd);
if (Orientation == Orientation.Vertical && VerticalToolTipPlacement == VerticalToolTipPlacement.None)
{
_toolTip.Visibility = Visibility.Collapsed;
}
else
{
_toolTip.Visibility = Visibility.Visible;

// Update tooltip text first so Measure gets accurate size
if (_toolTipText != null)
{
UpdateToolTipText(this, _toolTipText, useMin ? RangeStart : RangeEnd);
}

_toolTip.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));

var thumbSize = new UVCoord(thumb.Width, thumb.Height, Orientation).U;
var thumbCenter = _absolutePosition + (thumbSize / 2);
var ttHalfSize = new UVCoord(_toolTip.DesiredSize, Orientation).U / 2;

var toolTipPos = new UVCoord(Orientation) { U = thumbCenter - ttHalfSize };
Canvas.SetLeft(_toolTip, toolTipPos.X);
Canvas.SetTop(_toolTip, toolTipPos.Y);

if (Orientation == Orientation.Vertical)
{
UpdateToolTipPositionForVertical();
}
}
}

VisualStateManager.GoToState(this, useMin ? MinPressedState : MaxPressedState, true);
Expand Down
Loading