Skip to content

Instantly share code, notes, and snippets.

@MarcAlx
Last active February 15, 2025 19:29
Show Gist options
  • Save MarcAlx/4e54edf67dc4a95d373f3d99e4a9d523 to your computer and use it in GitHub Desktop.
Save MarcAlx/4e54edf67dc4a95d373f3d99e4a9d523 to your computer and use it in GitHub Desktop.
MAUI RangeSlider
using System;
#if XAMARIN
using Xamarin.Forms;
using Xamarin.Forms.Shapes;
#else
using Microsoft.Maui.Controls.Shapes;
#endif
namespace My.NameSpace
{
#if XAMARIN
//please use https://learn.microsoft.com/en-us/xamarin/community-toolkit/views/rangeslider
#else
/// <summary>
/// A slide to select two values between 0.0 and 1.0
///
/// For Xamarin please use: https://learn.microsoft.com/en-us/xamarin/community-toolkit/views/rangeslider
/// </summary>
public class RangeSlider : ContentView
{
#region const
private readonly static int TRACK_HEIGHT = 4;
private readonly static int THUMB_WIDTH = 20;
private readonly static int THUMB_RADIUS = THUMB_WIDTH/2;
private readonly static double DEFAULT_MIN_VALUE = 0.25;
private readonly static double DEFAULT_MAX_VALUE = 0.75;
private readonly static Color DEFAULT_RANGE_COLOR = Colors.LightBlue;
private readonly static Color DEFAULT_OUT_OF_RANGE_COLOR = Colors.LightGray;
private readonly static Color DEFAULT_THUMB_COLOR = Colors.White;
#endregion
/// <summary>
/// Store column in varaible to ease width adjustements
/// </summary>
#region track columns
private ColumnDefinition LeftColumn = new ColumnDefinition { Width = GridLength.Star };
private ColumnDefinition CenterColumn = new ColumnDefinition { Width = GridLength.Star };
private ColumnDefinition RightColumn = new ColumnDefinition { Width = GridLength.Star };
#endregion
#region UI
/// <summary>
/// To colorize everything outsid of range (low)
/// </summary>
private Grid LeftTrack = new Grid {
HeightRequest = TRACK_HEIGHT,
HorizontalOptions = LayoutOptions.FillAndExpand,
VerticalOptions = LayoutOptions.Center,
BackgroundColor = DEFAULT_OUT_OF_RANGE_COLOR
};
/// <summary>
/// To colorize everything in range
/// </summary>
private Grid CenterTrack = new Grid {
HeightRequest = TRACK_HEIGHT,
HorizontalOptions = LayoutOptions.FillAndExpand,
VerticalOptions = LayoutOptions.Center,
BackgroundColor = DEFAULT_RANGE_COLOR
};
/// <summary>
/// To colorize everything outsid of range (up)
/// </summary>
private Grid RightTrack = new Grid {
HeightRequest = TRACK_HEIGHT,
HorizontalOptions = LayoutOptions.FillAndExpand,
VerticalOptions = LayoutOptions.Center,
BackgroundColor = DEFAULT_OUT_OF_RANGE_COLOR
};
/// <summary>
/// Thumb for min value
/// </summary>
private Frame MinThumb = new Frame
{
CornerRadius = 10,
BackgroundColor = DEFAULT_THUMB_COLOR,
VerticalOptions = LayoutOptions.Center,
HorizontalOptions = LayoutOptions.Center,
BorderColor = DEFAULT_RANGE_COLOR,
WidthRequest = THUMB_WIDTH,
HeightRequest = THUMB_WIDTH,
};
/// <summary>
/// Thumb for max value
/// </summary>
private Frame MaxThumb = new Frame
{
CornerRadius = 10,
BackgroundColor = DEFAULT_THUMB_COLOR,
VerticalOptions = LayoutOptions.Center,
HorizontalOptions = LayoutOptions.Center,
BorderColor = DEFAULT_RANGE_COLOR,
WidthRequest = THUMB_WIDTH,
HeightRequest = THUMB_WIDTH,
};
//To fix an android issue, user effectively slide an helper instead of thumb,
//needed as on android updating position of a paning object fires unaccurate values,
//If you don't understand change the color of the following helper.
#region slide helper
private BoxView MinThumbSlideHelper = new BoxView
{
WidthRequest = THUMB_WIDTH*2,
HeightRequest = THUMB_WIDTH*2,
VerticalOptions = LayoutOptions.Center,
HorizontalOptions = LayoutOptions.Center,
Color = Colors.Transparent
};
private BoxView MaxThumbSlideHelper = new BoxView
{
WidthRequest = THUMB_WIDTH*2,
HeightRequest = THUMB_WIDTH*2,
VerticalOptions = LayoutOptions.Center,
HorizontalOptions = LayoutOptions.Center,
Color = Colors.Transparent
};
#endregion
#endregion
#region bindable properties
#region MinValue
/// <summary>
/// Identifies the <see cref="MinValueProperty"/> bindable property.
/// </summary>
public static readonly BindableProperty MinValueProperty =
BindableProperty.Create(nameof(MinValue),
typeof(double),
typeof(RangeSlider),
DEFAULT_MIN_VALUE,
BindingMode.TwoWay);
/// <summary>
/// MinValue between 0 and 1
/// </summary>
/// <seealso cref="MinValueProperty"/>
public double MinValue
{
get => (double)GetValue(MinValueProperty);
set
{
this.TranslateThumbRel(this.MinThumb, this.MinValue, value);
SetValue(MinValueProperty, value);
}
}
#endregion
#region MaxValue
/// <summary>
/// Identifies the <see cref="MaxValueProperty"/> bindable property.
/// </summary>
public static readonly BindableProperty MaxValueProperty =
BindableProperty.Create(nameof(MaxValue),
typeof(double),
typeof(RangeSlider),
DEFAULT_MAX_VALUE,
BindingMode.TwoWay);
/// <summary>
/// MaxValue between 0 and 1
/// </summary>
/// <seealso cref="MaxValueProperty"/>
public double MaxValue
{
get => (double)GetValue(MaxValueProperty);
set {
this.TranslateThumbRel(this.MaxThumb, this.MaxValue, value);
SetValue(MaxValueProperty, value);
}
}
#endregion
#region RangeColor
/// <summary>
/// Identifies the <see cref="RangeColorProperty"/> bindable property.
/// </summary>
public static readonly BindableProperty RangeColorProperty =
BindableProperty.Create(nameof(RangeColor),
typeof(Color),
typeof(RangeSlider),
DEFAULT_RANGE_COLOR,
BindingMode.TwoWay,
propertyChanged:(bindable,oldValue, newValue) =>
{
if(bindable is RangeSlider slider && newValue is Color color)
{
slider.CenterTrack.BackgroundColor = color;
slider.MinThumb.BorderColor = color;
slider.MaxThumb.BorderColor = color;
}
});
/// <summary>
/// Color of range
/// </summary>
/// <seealso cref="RangeColorProperty"/>
public Color RangeColor
{
get => (Color)GetValue(RangeColorProperty);
set => SetValue(RangeColorProperty, value);
}
#endregion
#region ThumbsColor
/// <summary>
/// Identifies the <see cref="ThumbsColorProperty"/> bindable property.
/// </summary>
public static readonly BindableProperty ThumbsColorProperty =
BindableProperty.Create(nameof(ThumbsColor),
typeof(Color),
typeof(RangeSlider),
DEFAULT_THUMB_COLOR,
BindingMode.TwoWay,
propertyChanged: (bindable, oldValue, newValue) =>
{
if (bindable is RangeSlider slider && newValue is Color color)
{
slider.MinThumb.BackgroundColor = color;
slider.MaxThumb.BackgroundColor = color;
}
});
/// <summary>
/// Color of thumb
/// </summary>
/// <seealso cref="ThumbsColorProperty"/>
public Color ThumbsColor
{
get => (Color)GetValue(ThumbsColorProperty);
set => SetValue(ThumbsColorProperty, value);
}
#endregion
#region OutOfRangeColor
/// <summary>
/// Identifies the <see cref="OutOfRangeColorProperty"/> bindable property.
/// </summary>
public static readonly BindableProperty OutOfRangeColorProperty =
BindableProperty.Create(nameof(OutOfRangeColor),
typeof(Color),
typeof(RangeSlider),
DEFAULT_OUT_OF_RANGE_COLOR,
BindingMode.TwoWay,
propertyChanged: (bindable, oldValue, newValue) =>
{
if (bindable is RangeSlider slider && newValue is Color color)
{
slider.LeftTrack.BackgroundColor = color;
slider.RightTrack.BackgroundColor = color;
}
});
/// <summary>
/// Color for area outside of range
/// </summary>
/// <seealso cref="OutOfRangeColorProperty"/>
public Color OutOfRangeColor
{
get => (Color)GetValue(OutOfRangeColorProperty);
set => SetValue(OutOfRangeColorProperty, value);
}
#endregion
#endregion
#region engine properties
/// <summary>
/// Max thumb real position
/// </summary>
private double EffectiveMinThumbX
{
get
{
return this.MinThumb.X + this.MinThumb.TranslationX;
}
}
/// <summary>
/// Min thumb real position
/// </summary>
private double EffectiveMaxThumbX
{
get
{
return this.MaxThumb.X + this.MaxThumb.TranslationX;
}
}
#endregion
#region utils
/// <summary>
/// to ease gridlenth init from strings
/// </summary>
private GridLengthTypeConverter converter = new GridLengthTypeConverter();
#endregion
#region timer
/// <summary>
/// timer to ensure every slide reach complete behavior, needed as sometimes pan handler doesn't fire completed/cancel status... (observed on android)
/// </summary>
private System.Timers.Timer SlideCompleteTimer;
/// <summary>
/// start timer
/// </summary>
private void ArmTimer()
{
this.SlideCompleteTimer.Start();
}
/// <summary>
/// stop timer if needed and start it
/// </summary>
private void ReArmTimer()
{
this.CancelTimer();
this.ArmTimer();
}
/// <summary>
/// cancel timer
/// </summary>
private void CancelTimer()
{
if (this.SlideCompleteTimer?.Enabled ?? false)
{
this.SlideCompleteTimer?.Stop();
}
}
#endregion
public RangeSlider()
{
Grid.SetColumn(this.LeftTrack, 0);
Grid.SetColumn(this.CenterTrack, 1);
Grid.SetColumn(this.RightTrack, 2);
Grid.SetColumnSpan(this.MinThumb, 3);
Grid.SetColumnSpan(this.MaxThumb, 3);
Grid.SetColumnSpan(this.MinThumbSlideHelper, 3);
Grid.SetColumnSpan(this.MaxThumbSlideHelper, 3);
this.Content = new Grid
{
ColumnDefinitions =
{
this.LeftColumn,
this.CenterColumn,
this.RightColumn
},
Children =
{
this.LeftTrack,
this.CenterTrack,
this.RightTrack,
this.MinThumb,
this.MaxThumb,
this.MinThumbSlideHelper,
this.MaxThumbSlideHelper
}
};
this.SlideCompleteTimer = new System.Timers.Timer(250);
this.SlideCompleteTimer.AutoReset = false;
this.SlideCompleteTimer.Elapsed += (sender, args) =>
{
//when this called is reached, this means pan completion has not run properly -> ensure helper match current UI state
MainThread.InvokeOnMainThreadAsync(() =>
{
this.MinThumbSlideHelper.TranslationX = this.MinThumb.TranslationX;
this.MaxThumbSlideHelper.TranslationX = this.MaxThumb.TranslationX;
this.UpdateTracks();
});
};
//handle thumb moves via pan gesture
double startPanXCoord = 0;
EventHandler<PanUpdatedEventArgs> panUpdatedHandler = (sender, args) =>
{
if (sender is View thumb)
{
var target = thumb;
if(thumb == this.MinThumbSlideHelper)
{
target = this.MinThumb;
}
else if (thumb == this.MaxThumbSlideHelper)
{
target = this.MaxThumb;
}
if (args.StatusType == GestureStatus.Started)
{
startPanXCoord = target.TranslationX;
this.ArmTimer();
}
else if (args.StatusType == GestureStatus.Running)
{
this.ReArmTimer();
this.TranslateThumbAbs(target, startPanXCoord + args.TotalX);
this.UpdateMinMaxValues();
}
else if (args.StatusType == GestureStatus.Completed )
{
this.CancelTimer();
thumb.TranslationX = target.TranslationX;
}
else if(args.StatusType == GestureStatus.Canceled)
{
this.CancelTimer();
thumb.TranslationX = target.TranslationX;
}
}
};
var minPanGesture = new PanGestureRecognizer();
minPanGesture.PanUpdated += panUpdatedHandler;
var maxPanGesture = new PanGestureRecognizer();
maxPanGesture.PanUpdated += panUpdatedHandler;
this.MinThumbSlideHelper.GestureRecognizers.Add(minPanGesture);
this.MaxThumbSlideHelper.GestureRecognizers.Add(maxPanGesture);
//set min values
var minReady = false;
var maxReady = false;
this.MinThumb.SizeChanged += (sender, args) =>
{
if (maxReady && !minReady)
{
this.TranslateThumbRel(this.MinThumb, 0.5, DEFAULT_MIN_VALUE);
this.MinThumbSlideHelper.TranslationX = this.MinThumb.TranslationX;
this.TranslateThumbRel(this.MaxThumb, 0.5, DEFAULT_MAX_VALUE);
this.MaxThumbSlideHelper.TranslationX = this.MaxThumb.TranslationX;
}
minReady = true;
};
this.MaxThumb.SizeChanged += (sender, args) =>
{
if (minReady && !maxReady)
{
this.TranslateThumbRel(this.MinThumb, 0.5, DEFAULT_MIN_VALUE);
this.MinThumbSlideHelper.TranslationX = this.MinThumb.TranslationX;
this.TranslateThumbRel(this.MaxThumb, 0.5, DEFAULT_MAX_VALUE);
this.MaxThumbSlideHelper.TranslationX = this.MaxThumb.TranslationX;
}
maxReady = true;
};
}
/// <summary>
/// Update Min and Max values
/// </summary>
private void UpdateMinMaxValues()
{
this.SetValue(MinValueProperty, (this.EffectiveMinThumbX + THUMB_RADIUS) / this.Width);
this.SetValue(MaxValueProperty, (this.EffectiveMaxThumbX + THUMB_RADIUS) / this.Width);
}
/// <summary>
/// Translate thumb using relatives values (old and new) (between 0 and 1)
/// </summary>
private void TranslateThumbRel(View thumb,double oldValue, double newValue)
{
var relativeDelta = newValue - Math.Max(0, Math.Min(1, oldValue));
var absoluteDelta = relativeDelta * this.Width;
this.TranslateThumbAbs(thumb, absoluteDelta);
}
/// <summary>
/// Translate thumb using an absolute X value (in pixels)
/// </summary>
private void TranslateThumbAbs(View thumb,double deltaX)
{
var wishedX = thumb.X + deltaX;
var otherThumbX = 0.0;
var minBoundX = -THUMB_RADIUS;
var maxBoundX = this.Width - THUMB_RADIUS;
var newX = 0.0;
if (thumb == this.MinThumb)
{
otherThumbX = -THUMB_WIDTH + this.EffectiveMaxThumbX;//THUMB_WIDTH to avoid overlapping
newX = Math.Max(minBoundX, Math.Min(Math.Min(wishedX, otherThumbX), maxBoundX));
}
else if (thumb == this.MaxThumb)
{
otherThumbX = THUMB_WIDTH + this.EffectiveMinThumbX;//THUMB_WIDTH to avoid overlapping
newX = Math.Min(maxBoundX, Math.Max(Math.Max(wishedX, otherThumbX), minBoundX));
}
thumb.TranslationX = newX - thumb.X;
this.UpdateTracks();
}
/// <summary>
/// Updage background tracks according to values
/// </summary>
private void UpdateTracks()
{
var leftSpace = (int)Math.Round(this.MinValue * 100,0);
var centerSpace = (int)Math.Round((this.MaxValue - this.MinValue) * 100);
var rightSpace = (int)Math.Round((1 - this.MaxValue) * 100);
this.LeftColumn.Width = (GridLength)converter.ConvertFromInvariantString(String.Format("{0}*",Math.Abs(leftSpace)));
this.CenterColumn.Width = (GridLength)converter.ConvertFromInvariantString(String.Format("{0}*", Math.Abs(centerSpace)));
this.RightColumn.Width = (GridLength)converter.ConvertFromInvariantString(String.Format("{0}*", Math.Abs(rightSpace)));
}
}
#endif
}
<!--use asis-->
<RangeSlider RangeColor="Red"
ThumbsColor="White"
OutOfRangeColor="Gray"
MinValue="0.25"
MaxValue="0.75"/>
@MarcAlx
Copy link
Author

MarcAlx commented Feb 4, 2025

Hi,

As I don't have an Android device right know to re-check .net 9, I can't promise you a fix anytime soon.

Can you elaborate on the problem you are facing?

@abdul-wasey
Copy link

abdul-wasey commented Feb 15, 2025

Hi MarxAlx,
I have moved to another open source library
Thank you for your kind response.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment