WPF MultiRangeSlider Control
MultiRangeSlider control to specify not intersecting ranges
Introduction
From time to time it is necessary to specify not intersecting ranges. The last case in my practice is control to define different settings for displaying map for different zoom levels. You can solve this by using DatGrid with two columns: From and To. But in this case you have to keep track of changing values to ensure that the ranges are not intersected. Herewith you have to signal when user input wrong data, for example by coloring grid cell in red, or by silent discarding changes. This generates complicated logic and confuses user. Much more simple for developer and for user is specifying not intersecting ranges by multirange control which is physically does not allow to set wrong values:
Unfortunately, there is no such element in standard visual studio controls. There are many articles about controls to specifying single range (with two thumbs). Common approaches - put one slider on another (same as first) and track the values.
I summarize this approach to expand it to unlimited number of sliders and to add interaction with user, to let him possibility to add or remove new range in runtime.
Main idea
Main idea is that each inner slider is connected with two neighboring and operates in concert with them.
Inner slider contains four properties:
LeftValue
– left value of range, associated with slider (which is equal toValue
property of common Slider, i.e. position of thumb on slider axis).RightValue
– right value of range, associated with slider.MinimumValue
– the minimal boundary toLeftValue
, which is equal toRightValue
of previous slider.MaximumValue
– the maximum boundary toRightValue
, which is equal toLeftValue
of next slider.
When I move
the slider to the right, I modify the RightValue
of previous slider (so the
previous range grows), the MaximumValue
of previous slider (so I can move the
previous slider thumb onward), the LeftValue
of this slider itself and the
MinimumValue
of next slider.
As can be
seen from the picture, to describe N ranges you need N+1 slider (slider
thumbs), because the last slider thumb defines the Right value of last range
(RightValue
of penult slider).
Naturally,
for first and last sliders you have to consider that theirs left and right
boundaries is Minimum
and Maximum
of slider respectively.
MinimumValue
and MaximumValue
properties are needed in order to sliders not overlap one to
another, but there is a catch.
First thought
– to check that slider value (LeftValue
or Value
of common slider) inside the
boundaries.
if (value > MinimumValue && value < MaximumValue)
return true;
return false;
But the value of slider is center of slider thumb:
My way to solve this problem – set different scales for different sliders, so that the values of sliders just fall into the border of thumb.
The scale of the slider will be depending on the visual size of thumb, and you will have to change the scale while resizing the control, but it is the easiest way.
Summing up, the process of generation inner sliders in my control has three steps:
1. Creation of sliders, based on specified settings with binding to specified ranges.
private void CreateSliders()
{
foreach (var item in ItemsSource)
Items.Add(CreateSlider(item));
InitSliders();
}
2. Initialization of sliders with the creation of binding to the values of neighboring sliders and creation of last slider (that is not bound to any range object).
private void InitSliders()
{
Items.First().IsFirst = true;
for(int i = 0; i < Items.Count; i++)
{
InitSliderMinimum(i > 0? Items[i-1] : null, Items[i]);
InitSliderMaximum(Items[i], i < Items.Count - 1 ? Items[i + 1] : null);
}
Items.Add(CreateLastSliderFromItem(Items.Last()));
ArrangeSliders();
}
private void InitSliderMaximum(WitMultiRangeSliderItem slider,
WitMultiRangeSliderItem nextSlider)
{
slider.SetBinding(WitMultiRangeSliderItem.MaximumValueProperty, nextSlider == null ?
GetBinding(slider, x => x.RightValue) :
GetBinding(nextSlider, x => x.LeftValue));
}
private void InitSliderMinimum(WitMultiRangeSliderItem previousSlider,
WitMultiRangeSliderItem slider)
{
if (previousSlider == null)
slider.MinimumValue = Minimum;
else
slider.SetBinding(WitMultiRangeSliderItem.MinimumValueProperty,
GetBinding(previousSlider, x => x.LeftValue));
}
private WitMultiRangeSliderItem CreateLastSliderFromItem(WitMultiRangeSliderItem lastItem)
{
var slider = new WitMultiRangeSliderItem
{
Item = null,
IsLast = true,
MaximumValue = Maximum
};
slider.SetBinding(WitMultiRangeSliderItem.LeftValueProperty,
GetBinding(lastItem, x => x.RightValue));
slider.SetBinding(WitMultiRangeSliderItem.MinimumValueProperty,
GetBinding(lastItem, x => x.LeftValue));
return slider;
}
3. Rescaling sliders axes
private void ArrangeSliders()
{
var nValues = Items.Count - 1;
for (int i = 0; i < nValues; i++)
{
Items[i].Maximum = Maximum + ThumbValue * (nValues - i);
Items[i].Minimum = Minimum - ThumbValue * i;
}
Items.Last().Minimum = Minimum - ThumbValue * nValues;
}
private double ThumbValue
{
get { return ActualWidth > 0? m_thumbWidth * (Maximum - Minimum)/ActualWidth : 0; }
}
Usage
My solution
contains two classes: WitMultiRangeSliderItem
and WitMultiRangeSlider
. Frist
class – WitMultiRangeSliderItem
,
represents common slider and inherits Slider
class.
Second – WitMultiRangeSlider
, is
container which manage collection of WitMultiRangeSliderItem
.
Usage of
these controls is quiet simple and there are two ways to use WitMultiRangeSlider
control:
bound and unbound.
Bound way
You can
bind ItemsSource
property of WitMultiRangeSlider
to collection of your objects, which represents ranges. Additionally you have
to specify bindings to left value of range (LeftValue
property of WitMultiRangeSlider
),
right value of range (RightValue
property of WitMultiRangeSlider
) in your object
and Minimum
/Maximum
values for ranges:
<InWit:WitMultiRangeSlider ItemsSource="{Binding RangeItems}"
SelectedItem="{Binding SelectedRange, Mode=TwoWay}"
LeftValueBinding="{Binding From, Mode=TwoWay}"
RightValueBinding="{Binding To, Mode=TwoWay}"
Minimum="0.0" Maximum="22.0"/>
You can specify binding for selected item (your object from ItemsSource collection).
Also you can set TickFrequency
and IsSnapToTickEnabled
, these values
will be transferred to inner sliders (WitMultiRangeSliderItem
).
WitMultiRangeSlider
contains MultiRangeSliderBarClicked
event. It passed position where user clicked.
So you can implement behavior when user clicking on slider bar, automatically adding
new slider with click position as from value.
Unbound way
When you
bind ItemsSource
to collection of your object control
automatically creates WitMultiRangeSliderItem
elements and sets their bindings. You can
manually add WitMultiRangeSliderItem
elements to WitMultiRangeSlider
control by using Items property:
<InWit:WitMultiRangeSlider Minimum="0.0"Maximum="2200.0">
<InWit:WitMultiRangeSlider.Items>
<InWit:WitMultiRangeSliderItem
LeftValue="{Binding UnboundRange1.From, Mode=TwoWay}"
RightValue="{Binding UnboundRange1.To, Mode=TwoWay}" />
<InWit:WitMultiRangeSliderItem
LeftValue="{Binding UnboundRange2.From, Mode=TwoWay}"
RightValue="{Binding UnboundRange2.To, Mode=TwoWay}"/>
<InWit:WitMultiRangeSliderItem
LeftValue="{Binding UnboundRange3.From, Mode=TwoWay}"
RightValue="{Binding UnboundRange3.To, Mode=TwoWay}"/>
<InWit:WitMultiRangeSliderItem LeftValue="{Binding UnboundRange4.From, Mode=TwoWay}"
RightValue="{Binding UnboundRange4.To, Mode=TwoWay}"/>
</InWit:WitMultiRangeSlider.Items>
</InWit:WitMultiRangeSlider>
Or even without any bindings:
<InWit:WitMultiRangeSlider Minimum="0.0" Maximum="2200.0">
<InWit:WitMultiRangeSlider.Items>
<InWit:WitMultiRangeSliderItem LeftValue="500" RightValue="700"/>
<InWit:WitMultiRangeSliderItem LeftValue="700" RightValue="1200"/>
<InWit:WitMultiRangeSliderItem LeftValue="1200" RightValue="1600"/>
</InWit:WitMultiRangeSlider.Items>
</InWit:WitMultiRangeSlider>
In this case you have to use ValueChanged
event of WitMultiRangeSliderItem
(of Slider
) to track range changes.
Note, you can only use ItemsSource
or Items, and not simultaneously.
Simple example of implementation
The task – to create a control for managing the set of nonintersecting ranges with ability to add new ranges and modify user data.
You have some class to represent range with user data:
public class RangeItem : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged = delegate { };
private int m_from;
private int m_to;
private string m_name;
public int From
{
get { return m_from; }
set
{
m_from = value;
this.FirePropertyChanged();
}
}
public int To
{
get { return m_to; }
set
{
m_to = value;
this.FirePropertyChanged();
}
}
public string Name
{
get { return m_name; }
set
{
m_name = value;
this.FirePropertyChanged();
}
}
}
This class contains following fields:
From
– left bound of rangeTo
– right bound of rangeName
– user data
You should create view model for set of ranges with command to add new range:
public class RangesViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged = delegate { };
private readonly ObservableContentCollection<RangeItem> m_rangeItems;
private RangeItem m_selectedRange;
private readonly Command m_insertRangeCmd;
public RangesViewModel()
{
m_rangeItems = new ObservableContentCollection<RangeItem>
{
new RangeItem {From = 0, To = 13, Name = "BoundRange0"},
new RangeItem {From = 13, To = 17, Name = "BoundRange1"},
};
m_insertRangeCmd = new DelegateCommand(x => InsertRange((int)(double)x));
}
private void InsertRange(int level)
{
if (level > m_rangeItems.Last().To)
InsertRightRange(level);
else if (level < m_rangeItems.First().From)
InsertLeftRange(level);
else
{
var previousRange = m_rangeItems.First(x => x.To >= level);
var newRange = new RangeItem
{
From = level,
To = previousRange.To,
Name = string.Format("BoundRange{0}", m_rangeItems.Count)
};
m_rangeItems.Insert(m_rangeItems.IndexOf(previousRange) + 1, newRange);
previousRange.To = level;
}
}
private void InsertRightRange(int level)
{
var rightRange = new RangeItem
{
From = m_rangeItems.Last().To,
To = level,
Name = string.Format("BoundRange{0}", m_rangeItems.Count)
};
m_rangeItems.Add(rightRange);
}
private void InsertLeftRange(int level)
{
var leftRange = new RangeItem
{
From = level,
To = m_rangeItems.First().From,
Name = string.Format("BoundRange{0}", m_rangeItems.Count)
};
m_rangeItems.Insert(0, leftRange);
}
public ObservableContentCollection<RangeItem> RangeItems
{
get { return m_rangeItems; }
}
public RangeItem SelectedRange
{
get { return m_selectedRange; }
set
{
m_selectedRange = value;
this.FirePropertyChanged();
}
}
public Command InsertRangeCmd
{
get { return m_insertRangeCmd; }
}
}
As MultiRangeSliderBarClicked
event of WitMultiRangeSlider
pass a slider value where user clicked as
parameter, you should create new range with passed parameter as From
value
of new range and next slider From
value as To
value of new range. So you should
split existing range at the clicked point.
Now you can
bind RangeItems
from RangesViewModel
to ItemsSource
of WitMultiRangeSlider
to manage ranges and InsertRangeCmd
to
MultiRangeSliderBarClicked
event of WitMultiRangeSlider
to handle double click on WitMultiRangeSlider
.
Also you can bind RangeItems
from RangesViewModel
to ItemsSource
of common DataGrid
to modify user data (Name property):
<InWit:WitMultiRangeSlider ItemsSource="{Binding RangeItems}"
SelectedItem="{Binding SelectedRange, Mode=TwoWay}"
LeftValueBinding="{Binding From, Mode=TwoWay}"
RightValueBinding="{Binding To, Mode=TwoWay}"
Minimum="0.0" Maximum="22.0">
<i:Interaction.Triggers>
<i:EventTrigger EventName="MultiRangeSliderBarClicked">
<U:InvokeCommandActionWithParam Command="{Binding InsertRangeCmd}"
CommandParameter="{Binding RelativeSource={RelativeSource Self},
Path=InvokeParameter, Converter={StaticResource EventArgsToDouble}}"/>
</i:EventTrigger>
</i:Interaction.Triggers>
</InWit:WitMultiRangeSlider>
<DataGrid ItemsSource="{Binding RangeItems}"
SelectedItem="{Binding SelectedRange, Mode=TwoWay}"
CanUserAddRows="False" CanUserDeleteRows="False"
CanUserReorderColumns="False"
AutoGenerateColumns="False">
<DataGrid.Columns>
<DataGridTextColumn Width="*" Header="Name" Binding="{Binding Name, Mode=TwoWay}"
SortMemberPath="Name"/>
<DataGridTextColumn Width="Auto" MinWidth="40" Header="From" Binding="{Binding From}"
IsReadOnly="True" SortMemberPath="From"/>
<DataGridTextColumn Width="Auto" MinWidth="40" Header="To" Binding="{Binding To}"
IsReadOnly="True" SortMemberPath="To"/>
</DataGrid.Columns>
</DataGrid>
In attached example you will see both ways of using WitMultiRangeSlider
with interaction: