Introduction
The initial problem was that I wanted to use a slider to make a value selection from a non-linear scale and wanted it to round that value to the nearest integer. Values would need to be something like: 1, 5, 10, 50, 100, 500. I was able to round the value of a normal slider but it would only give me values in a linear scale. Googling around I couldn't find any solution for a slider with a discrete set of values so I started coding.
I came up with a UserControl as a much better solution than I needed in the first place and decided to share it. It can mix numeric values, strings and images as "labels" for each category on the slider, working both horizontally and vertically.
In the example above I have strings, ints, doubles, and images as slider
labels. Any improvements and suggestions would be welcome, of course. I made it with VS 2010 for Silverlight 4 and
.NET Framework 4.
Basics
The basic idea behind this slider is to use a normal slider, round its value and use it as an index to a collection of items, returning the referenced item as the value of the
CategorySlider
. Some needs arose while coding. I needed to pass to the slider in the UserControl some basic setup values such as
Orientation
and
IsDirectionReversed
. Also needed to know, as returning values, not only the selected object, but it's index as well.
I needed a
ValueChanged
event and a few more things I will explain further.
The UserControl
This code is all there is to it.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Shapes;
using System.Windows.Data;
using System.Windows.Media.Imaging;
namespace Tools
{
public partial class CategorySlider : UserControl
{
public event ValueChangedEventArgs.ValueChangedHandler ValueChanged;
#region Public DependencyProperties
private static DependencyProperty OrientationProperty =
DependencyProperty.Register("Orientation", typeof(Orientation),
typeof(CategorySlider), new PropertyMetadata(Orientation.Horizontal));
public Orientation Orientation
{
get { return (Orientation)GetValue(OrientationProperty); }
set { SetValue(OrientationProperty, value); }
}
private static DependencyProperty IsDirectionReversedProperty =
DependencyProperty.Register("IsDirectionReversed", typeof(bool),
typeof(CategorySlider), new PropertyMetadata(false));
public bool IsDirectionReversed
{
get { return (bool)GetValue(IsDirectionReversedProperty); }
set { SetValue(IsDirectionReversedProperty, value); }
}
private static DependencyProperty ShowLabelsProperty =
DependencyProperty.Register("ShowLabels", typeof(bool),
typeof(CategorySlider), new PropertyMetadata(true));
public bool ShowLabels
{
get { return (bool)GetValue(ShowLabelsProperty); }
set { SetValue(ShowLabelsProperty, value); }
}
private static DependencyProperty ListObjectsProperty =
DependencyProperty.Register("ListObjects", typeof(List<object>),
typeof(CategorySlider), new PropertyMetadata(new List<object>(),
new PropertyChangedCallback(ListObjectsChange)));
public List<object> ListObjects
{
get { return (List<object>)GetValue(ListObjectsProperty); }
set { SetValue(ListObjectsProperty, value); }
}
private static DependencyProperty CategoryValueProperty =
DependencyProperty.Register("CategoryValue", typeof(object),
typeof(CategorySlider), new PropertyMetadata(new object()));
public object CategoryValue
{
get { return (object)GetValue(CategoryValueProperty); }
set { SetValue(CategoryValueProperty, value); }
}
private static DependencyProperty CategoryIndexProperty =
DependencyProperty.Register("CategoryIndex", typeof(int),
typeof(CategorySlider), new PropertyMetadata((int)0));
public int CategoryIndex
{
get { return (int)GetValue(CategoryIndexProperty); }
set { SetValue(CategoryIndexProperty, value); }
}
#endregion Public DependencyProperties
#region Private DependencyProperties
private static DependencyProperty SliderMaxValueProperty =
DependencyProperty.Register("SliderMaxValue", typeof(int),
typeof(CategorySlider), new PropertyMetadata((int)0));
private int SliderMaxValue
{
get { return (int)GetValue(SliderMaxValueProperty); }
set { SetValue(SliderMaxValueProperty, value); }
}
#endregion Private DependencyProperties
public CategorySlider()
{
InitializeComponent();
}
private void Slider_ValueChanged(object sender,
System.Windows.RoutedPropertyChangedEventArgs<double> e)
{
((Slider)sender).Value = Math.Round(e.NewValue);
if (this.ListObjects.Count > 0)
this.CategoryValue = this.ListObjects.ElementAt((int)((Slider)sender).Value);
if (this.ValueChanged != null && Math.Round(e.OldValue) != Math.Round(e.NewValue))
this.ValueChanged(this, new ValueChangedEventArgs(this.CategoryIndex, this.CategoryValue));
}
#region DependencyProperties Callback Methods
private static void ListObjectsChange(DependencyObject d,
DependencyPropertyChangedEventArgs e) { (d as CategorySlider).ListObjectsChange(e); }
private void ListObjectsChange(DependencyPropertyChangedEventArgs e)
{
if (((List<object>)e.NewValue).Count > 0)
{
this.SliderMaxValue = ((List<object>)e.NewValue).Count - 1;
GridLength size = new GridLength(0.5 / (SliderMaxValue - 1), GridUnitType.Star);
this.HorizontalLabelsTemplate.ColumnDefinitions.First().Width = size;
this.HorizontalLabelsTemplate.ColumnDefinitions.Last().Width = size;
this.VerticalLabelsTemplate.RowDefinitions.First().Height = size;
this.VerticalLabelsTemplate.RowDefinitions.Last().Height = size;
this.InsertLabel(this.Orientation == Orientation.Horizontal ?
this.HorizontalLabelsTemplate : this.VerticalLabelsTemplate,
this.IsDirectionReversed ? ((List<object>)e.NewValue).Last() :
((List<object>)e.NewValue).First(),
this.Orientation == Orientation.Horizontal ? 0 : 1,
HorizontalAlignment.Left, VerticalAlignment.Bottom,
2);
if (((List<object>)e.NewValue).Count > 1)
{
this.InsertLabel(this.Orientation == Orientation.Horizontal ?
this.HorizontalLabelsTemplate : this.VerticalLabelsTemplate,
this.IsDirectionReversed ? ((List<object>)e.NewValue).First() :
((List<object>)e.NewValue).Last(),
this.Orientation == Orientation.Horizontal ? 1 : 0,
HorizontalAlignment.Right, VerticalAlignment.Top,
2);
if (((List<object>)e.NewValue).Count > 2)
{
List<object> tmpList = ((List<object>)e.NewValue).Where(
c => c != ((List<object>)e.NewValue).First() &&
c != ((List<object>)e.NewValue).Last()).ToList();
if (this.Orientation == Orientation.Vertical && !this.IsDirectionReversed ||
this.Orientation == Orientation.Horizontal && this.IsDirectionReversed)
tmpList.Reverse();
foreach (object item in tmpList)
{
if (this.Orientation == Orientation.Horizontal)
{
ColumnDefinition coluna = new ColumnDefinition();
coluna.Width = new GridLength(1, GridUnitType.Star);
MiddleHItemsLabels.ColumnDefinitions.Add(coluna);
}
else
{
RowDefinition linha = new RowDefinition();
linha.Height = new GridLength(1, GridUnitType.Star);
MiddleVItemsLabels.RowDefinitions.Add(linha);
}
this.InsertLabel(this.Orientation == Orientation.Horizontal ?
this.MiddleHItemsLabels : this.MiddleVItemsLabels,
item,
tmpList.IndexOf(item),
HorizontalAlignment.Center, VerticalAlignment.Center,
1);
}
}
}
CategoryValue = ((List<object>)e.NewValue).ElementAt((int)(slider1.Value));
slider1.IsHitTestVisible = true;
}
else
slider1.IsHitTestVisible = false;
}
private void InsertLabel(Grid parent, object item, int pos,
HorizontalAlignment alignIfHor, VerticalAlignment alignIfVer, int span)
{
Image newImage1 = new Image();
TextBlock newLabel = new TextBlock();
if (item.GetType() == typeof(Image))
newImage1 = (Image)item;
else
newLabel.Text = item.ToString();
if (this.Orientation == Orientation.Horizontal)
{
if (item.GetType() == typeof(Image))
{
newImage1.HorizontalAlignment = alignIfHor;
newImage1.VerticalAlignment = VerticalAlignment.Bottom;
newImage1.Width = Double.NaN;
newImage1.SetValue(Grid.ColumnProperty, pos);
newImage1.SetValue(Grid.ColumnSpanProperty, span);
}
else
{
newLabel.HorizontalAlignment = alignIfHor;
newLabel.VerticalAlignment = VerticalAlignment.Bottom;
newLabel.Width = Double.NaN;
newLabel.SetValue(Grid.ColumnProperty, pos);
newLabel.SetValue(Grid.ColumnSpanProperty, span);
}
}
else
{
if (item.GetType() == typeof(Image))
{
newImage1.HorizontalAlignment = HorizontalAlignment.Right;
newImage1.VerticalAlignment = alignIfVer;
newImage1.Height = Double.NaN;
newImage1.SetValue(Grid.RowProperty, pos);
newImage1.SetValue(Grid.RowSpanProperty, span);
}
else
{
newLabel.HorizontalAlignment = HorizontalAlignment.Right;
newLabel.VerticalAlignment = alignIfVer;
newLabel.Height = Double.NaN;
newLabel.SetValue(Grid.RowProperty, pos);
newLabel.SetValue(Grid.RowSpanProperty, span);
}
}
if (item.GetType() == typeof(Image))
parent.Children.Add(newImage1);
else
parent.Children.Add(newLabel);
}
#endregion DependencyProperties Callback Methods
}
#region Converters
public class ReverseVisibility : IValueConverter
{
public object Convert(object value, Type targetType,
object parameter, System.Globalization.CultureInfo culture)
{
return (Visibility)value == Visibility.Collapsed ?
Visibility.Visible : Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType,
object parameter, System.Globalization.CultureInfo culture)
{
throw new NotImplementedException();
}
}
public class OrientationToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter,
System.Globalization.CultureInfo culture)
{
if (parameter == null || value == null) return Visibility.Collapsed;
return ((CategorySlider)value).ShowLabels && (((
(CategorySlider)value).Orientation == Orientation.Horizontal &&
(string)parameter == "H") || (((CategorySlider)value).Orientation ==
Orientation.Vertical && (string)parameter == "V")) ?
Visibility.Visible : Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType,
object parameter, System.Globalization.CultureInfo culture)
{
throw new NotImplementedException();
}
}
#endregion Converters
public class ValueChangedEventArgs : EventArgs
{
public delegate void ValueChangedHandler(object sender, ValueChangedEventArgs e);
public int CategoryIndex { get; set; }
public object CategoryValue { get; set; }
public ValueChangedEventArgs(int categoryIndex, object categoryValue)
{
CategoryIndex = categoryIndex;
CategoryValue = categoryValue;
}
}
}
<UserControl x:Class="Portal_Tools.CategorySlider"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:Portal_Tools"
x:Name="userControl"
mc:Ignorable="d"
d:DesignHeight="30" d:DesignWidth="400">
<UserControl.Resources>
<local:OrientationToVisibilityConverter x:Key="OrientationToVisibilityConverter"/>
</UserControl.Resources>
<Grid x:Name="LayoutRoot">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition/>
</Grid.RowDefinitions>
<Grid x:Name="HorizontalLabelsTemplate" Grid.Column="1"
VerticalAlignment="Bottom"
Visibility="{Binding ConverterParameter=H, Converter={StaticResource
OrientationToVisibilityConverter}, ElementName=userControl, Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Grid x:Name="MiddleHItemsLabels"
Grid.Column="1" Margin="5,0"/>
</Grid>
<Grid x:Name="VerticalLabelsTemplate" Grid.Row="1"
HorizontalAlignment="Right" Visibility="{Binding ConverterParameter=V,
Converter={StaticResource OrientationToVisibilityConverter},
ElementName=userControl, Mode=OneWay}" >
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<Grid x:Name="MiddleVItemsLabels" Grid.Row="1" Margin="0,5"/>
</Grid>
<Slider x:Name="slider1"
Maximum="{Binding SliderMaxValue, ElementName=userControl}"
ValueChanged="Slider_ValueChanged" SmallChange="1"
Grid.Row="1" Grid.Column="1"
Orientation="{Binding Orientation, ElementName=userControl}"
Cursor="{Binding Cursor, ElementName=userControl}"
IsDirectionReversed="{Binding IsDirectionReversed, ElementName=userControl}"
Value="{Binding CategoryIndex, ElementName=userControl, Mode=TwoWay}"/>
</Grid>
</UserControl>
Points of Interest
Some explanations on how it works.
Public DependencyProperties
Orientation
and IsDirectionReversed
- Used to pass those settings
to the Slider, to show/hide horizontal and vertical grids and determine the order of adding the labels to the label grids.
ShowLabels
- Although we can have categories or discrete values, there is an option not to show the labels.
ListObjects
- This is where we deliver the list of objects to be used as labels to the categories. They can be any type of objects but the ones that made sense to me were string, numeric values (int, double, ...) and images.
CategoryValue
- This DP has the object selected by the
CategorySlider
. It is obtained from the list above using the slider value as the index. This object is changed only when the rounded slider value changes.
CategoryIndex
- The rounded slider value.
DependencyProperties CallBacks
ListObjectsChange()
- This is where all the magic happens. When the ListObjects changes it sets the grids column widths / row heights depending on the ListObjects count, sets up first and last labels (see XAML Structure below), sets up the MiddleLabels grids and appends the labels.
Private DependencyProperties
SliderMaxValue
- Used only internally. Where the slider
Maximumis
bounded to. Depends on the ListObjects count.
Events
CategorySlider.ValueChanged
- If triggered, fires back the event when the rounded value of the slider also changes (catched by the
Slider.ValueChanged
event).
- Class
ValueChangedEventArgs
- Used by the
CategorySlider.ValueChanged
event to return to the caller the selected object (ValueChangedEventArgs.CategoryValue
) and selected index (ValueChangedEventArgs.CategoryIndex
).
Hooked Events
Slider.ValueChanged
- This is where the changes on the slider value are treated (rounded) and tested for effective changes after rounding. If value effectively changes the
CategoryValue
and CategoryIndex
are updated.
Converters
ReverseVisibility
- This is a generic converter I use across projects that binds to the
Visibility
property of a control and reverses it. It's great when we need mutually exclusive visibility between two or more controls. This converter is no longer used but I left it in the code. It's very useful and can be used anywhere.
OrientationToVisibilityConverter
- The name says it all. Basically it turns os or off the visibility of the HorizontalLabelsTemplate
grid depending on the Orientation value.
XAML structure
As label count in unknown at the beginning and first/last label formatting are different from the middle labels (first label is left/top aligned, ...), I created a two level grid. The first grid
has three columns/rows (horizontal or vertical CategorySlider). One for the first label, one for the last label and one for the middle labels grid. Columns/rows and labels on the middle grids are added in the code behind in the foreach cycle on the ListObjectsChange()
method. All this fits into a upper level
Grid
that contains also a normal slider.
Testing
The download at the top includes a MainPage for testing. It's just a
Grid
with a vertical and an horizontal CategorySlider
. I added some controls to demonstrate how to get the CategorySlider value and index either by binding or by getting the property's values making use of the
ValueChanged
event.
<UserControl
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:Tools="clr-namespace:Tools"
x:Class="TesteCategorySlider.MainPage"
mc:Ignorable="d"
d:DesignHeight="300" d:DesignWidth="400">
<Grid x:Name="LayoutRoot" Margin="0,0,20,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Grid Width="200">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition/>
</Grid.RowDefinitions>
<TextBlock x:Name="EventV" HorizontalAlignment="Left"
TextWrapping="Wrap" Text="TextBlock" d:LayoutOverrides="Height"/>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="indexV" TextWrapping="Wrap"
Text="{Binding CategoryIndex, ElementName=categorySliderV}"
d:LayoutOverrides="Width" VerticalAlignment="Top"
Padding="3,0" Foreground="#FF008114"/>
<TextBlock x:Name="objectV"
Text="{Binding CategoryValue, ElementName=categorySliderV}"
VerticalAlignment="Top" Grid.Column="1"
Foreground="#FF003EFF" Padding="3,0,0,0" />
</Grid>
<Tools:CategorySlider x:Name="categorySliderV" Foreground="#FF0010FF"
FontWeight="Bold" FontSize="13.333" Orientation="Vertical"
HorizontalAlignment="Center" Grid.Row="2"
VerticalContentAlignment="Stretch" ShowLabels="False"/>
</Grid>
<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition Height="Auto"/>
<RowDefinition/>
</Grid.RowDefinitions>
<TextBlock x:Name="EventH" TextWrapping="Wrap"
Text="TextBlock" VerticalAlignment="Bottom" HorizontalAlignment="Center"/>
<Grid VerticalAlignment="Bottom" HorizontalAlignment="Center"
Grid.Row="1" Margin="0,0,0,20">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="indexH" TextWrapping="Wrap"
Text="{Binding CategoryIndex, ElementName=categorySliderH}"
d:LayoutOverrides="Height" Padding="3,0"
Foreground="#FF008114"/>
<TextBlock x:Name="objectH"
Text="{Binding CategoryValue, ElementName=categorySliderH}"
VerticalAlignment="Bottom" Grid.Column="1"
Padding="3,0,0,0" Foreground="#FF003EFF" />
</Grid>
<Tools:CategorySlider x:Name="categorySliderH"
Foreground="#FF0010FF" FontWeight="Bold"
FontSize="13.333" VerticalAlignment="Top" Grid.Row="2"/>
</Grid>
</Grid>
</UserControl>
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Shapes;
using System.Windows.Media.Imaging;
namespace TesteCategorySlider
{
public partial class MainPage : UserControl
{
public MainPage()
{
InitializeComponent();
categorySliderV.ListObjects = new List<object>() { "Op. One", 2, 3.35,
"Blue", "Moon", new Image() { Source = new BitmapImage(
new Uri("/TesteCategorySlider;component/ZoomIn.png", UriKind.Relative)),
Width = 30, Height = 30 }, new Image() { Source = new BitmapImage(new Uri(
"/TesteCategorySlider;component/ZoomOut.png", UriKind.Relative)),
Width = 30, Height = 30 }, 9.1, 100, "Last" };
categorySliderH.ListObjects = new List<object>() { "Op. One", 2, 3.35,
"Blue", "Moon", new Image() { Source = new BitmapImage(new Uri(
"/TesteCategorySlider;component/ZoomIn.png", UriKind.Relative)), Width = 30,
Height = 30 }, new Image() { Source = new BitmapImage(new Uri(
"/TesteCategorySlider;component/ZoomOut.png", UriKind.Relative)),
Width = 30, Height = 30 }, 9.1, 100, "Last" };
categorySliderV.ValueChanged +=
new Tools.ValueChangedEventArgs.ValueChangedHandler(categorySliderV_ValueChanged);
categorySliderH.ValueChanged +=
new Tools.ValueChangedEventArgs.ValueChangedHandler(categorySliderH_ValueChanged);
EventV.Text = categorySliderV.CategoryIndex.ToString();
EventH.Text = categorySliderH.CategoryIndex.ToString();
}
void categorySliderH_ValueChanged(object sender, Tools.ValueChangedEventArgs e)
{
EventH.Text = e.CategoryIndex.ToString();
}
void categorySliderV_ValueChanged(object sender, Tools.ValueChangedEventArgs e)
{
EventV.Text = e.CategoryIndex.ToString();
}
}
}
Conclusion
It works for the purpose I made it for. Surely there might be some issues that I haven't tested yet (I'm thinking of styling as an example ...). Enjoy it and make it better! I'd like to know how You liked this article.
Please don't forget to vote!
History
Some suggestions really make sense.
Here they are:
2013 May 17 - Possibility to not show the labels
- Additional DependencyProperty -
ShowLabels
.
- Dropped converter
ReverseVisibility
(left it in the code for it might be useful).
- Changed converter
OrientationToVisibilityConverter
.
- Changed XAML accordingly.
- Zip is updated.