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 to Value
property
of common Slider, i.e. position of thumb on slider axis). RightValue
–
right value of range, associated with slider.MinimumValue
– the minimal boundary to LeftValue
, which is equal to RightValue
of previous
slider.MaximumValue
– the maximum boundary to RightValue
, which
is equal to LeftValue
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: