Building a dual thumb slider for Silverlight 2 Beta 1

 

hosted by Silverlight Streaming - the source is available as an attachment at the bottom of this post.

The Slider control that shipped with the Silverlight 2 Beta 1 SDK only supports picking a single value. Back at Mix08 in March I threw together a control that allows selecting a range using two thumbs, but I haven't had a chance to talk about it until now. So, without further ado let me introduce the RangeFinder control.

 

[TemplatePart(Name = RangeFinder.RootElementName, Type = typeof(FrameworkElement))]

[TemplatePart(Name = RangeFinder.TrackGridElementName, Type = typeof(Grid))]

[TemplatePart(Name = RangeFinder.LeftTrackElementName, Type = typeof(Rectangle))]

[TemplatePart(Name = RangeFinder.RightTrackElementName, Type = typeof(Rectangle))]

[TemplatePart(Name = RangeFinder.LeftGridSplitterElementName, Type = typeof(GridSplitter))]

[TemplatePart(Name = RangeFinder.RightGridSplitterElementName, Type = typeof(GridSplitter))]

public class RangeFinder : RangeBase

{

    /// <summary>

    /// Raised when the UpperValue dependency property changes

    /// </summary>

    public event RoutedPropertyChangedEventHandler<double> UpperValueChanged;

    /// <summary>

    /// Constructor for the RangeFinder class

    /// </summary>

    public RangeFinder() {}

    /// <summary>

    /// Gets/sets the UpperValue dependency property for the upper value of the selected range

    /// </summary>

    public double UpperValue {get; set;}

    /// <summary>

    /// Gets/sets the ThumbWidth dependency property for width of the individual thumbs

    /// </summary>

    public double ThumbWidth {get; set;}

    /// <summary>

    /// Gets/sets the TrackMargin dependency property for the distance from the edges of the control that track should be displayed

    /// </summary>

    public Thickness TrackMargin {get; set;}

    /// <summary>

    /// Gets/sets the Background dependency property for the background of the control

    /// </summary>

    public Brush Background {get; set;}

    /// <summary>

    /// Gets/sets the Foreground dependency property for the foreground of the control

    /// </summary>

    public Brush Foreground {get; set;}

    /// <summary>

    /// Gets/sets the MinimumRange dependency property for the minimum allowable difference

    /// between the Value and UpperValue properties

    /// </summary>

    public double MinimumRange {get; set;}

}

 

The first decision to make was whether or not I could extend the existing Slider control. I think I could have restyled the thumb by inserting a custom control that would have allowed me to expand it, but that seemed really complex. Instead, I chose to inherit directly from RangeBase and implement the UI of the control from scratch.

The Slider class uses a grid with three columns to lay out the UI. Moving the thumb actually changes the dimensions of the first and third columns. When the size of the first column changes, a rectangle in that column invokes a SizeChanged event handler which updates the Value based on the new size.

I thought that was a pretty slick approach so I borrowed the concept for RangeFinder. I had a problem though; I needed two independent values. Instead of creating a second thumb and trying to coordinate it with the first, I decided to use a control whose sole purpose in life is resizing grids - the GridSplitter.

Here is the general concept:

  • A three-column Grid
  • Two Rectangles, one filling the first column and the other filling the third.
  • Two GridSplitters, one aligned-right in the first column and the other aligned-left in the third.
  • Dragging one of the GridSplitters resizes the adjacent columns causing a rectangle in one of the columns to invoke its SizeChanged event handler which updates the Value or UpperValue property, as appropriate.
  • There is a little math behind the scenes to convert the value range (e.g. 1 to 100) to visual coordinates (e.g. 0 to 50 pixels).

Creating a default RangeFinder

Creating an instance of the control on a Silverlight page is very easy. The first RangeFinder shown in the example above just sticks with default colors and a Minimum of 0 and a Maximum of 1.

<local:RangeFinder x:Name="rangeFinder0" />

Seeing the RangeFinder's values

The RangeFinder isn't very fun though if you can't see what the Value and UpperValue properties actually are. Silverlight 2 Beta 1 doesn't dynamically update any UI data bound to dependency properties so you need to create a little Data Transfer Object (DTO) to fill the gap. The ValueDataObject class in the attached project serves this purpose.

/// <summary>

/// Data transport object for binding the UI to the actual values selected

/// </summary>

/// <remarks>

/// When dependency properties change in Silverlight 2 Beta 1, they do not

/// update the targets of any data binding made to those properties. One

/// way to do this is to use a data transport object that implements the

/// INotifyPropertyChanged interface as the source of the bindings as

/// demonstrated by this ValueDataObject class.

/// </remarks>

public class ValueDataObject : INotifyPropertyChanged

{

    /// <summary>

    /// Implementation of the INotifyPropertyChanged.PropertyChanged event

    /// </summary>

    public event PropertyChangedEventHandler PropertyChanged;

    /// <summary>

    /// Constructor for the ValueDataObject class

    /// </summary>

    /// <param name="instance">RangeFinder object to associate this instance with</param>

    public ValueDataObject(RangeFinder instance, string valueFormat) {}

    /// <summary>

    /// Gets the Value property of the associated RangeFinder object

    /// </summary>

    public Double Value {get; }

    /// <summary>

    /// Gets a formatted string for the Value property of the associated RangeFinder object

    /// </summary>

    public string FormattedValue {get; }

    /// <summary>

    /// Gets the UpperValue property of the associated RangeFinder object

    /// </summary>

    public Double UpperValue {get; }

    /// <summary>

    /// Gets a formatted string for the UpperValue property of the associated RangeFinder object

    /// </summary>

    public string FormattedUpperValue {get; }

    /// <summary>

    /// Gets/sets the string format to be used when formatting the Value and UpperValue properties

    /// </summary>

    public string ValueFormat { get; set; }

}

 

Now, you want to set up the UI to be able to show the values. I used two TextBlocks in the second RangeFinder in the example above. Note that the example code binds the Text property of the TextBlocks to the FormattedValue and FormattedUpperValue properties of ValueDataObject. This allows it to change the formatting when converting the value to a string.

 

<Grid x:Name="customRangeFinderContainer" VerticalAlignment="Center">

    <Grid.ColumnDefinitions>

        <ColumnDefinition />

        <ColumnDefinition Width="200"/>

        <ColumnDefinition />

    </Grid.ColumnDefinitions>

    <Grid.RowDefinitions>

        <RowDefinition Height="20"/>

    </Grid.RowDefinitions>

    <TextBlock Grid.Column="0" Style="{StaticResource valueTextBlockStyle}" Text="{Binding FormattedValue, Mode=OneWay}" />

    <local:RangeFinder x:Name="rangeFinder1" Grid.Column="1"

                      Minimum="0" Maximum="5.5" Value=".5" UpperValue="5.0" MinimumRange="1.5"

                      Background="DarkGray" Foreground="Brown" />

    <TextBlock Grid.Column="2"  Style="{StaticResource valueTextBlockStyle}" Text="{Binding FormattedUpperValue, Mode=OneWay}" />

</Grid>

 

It was very easy to change the default Background and Foreground colors, the Minimum and Maximum properties, and the initial Value and UpperValue properties. It is also easy to set a minimum range.

One last piece is needed to hook it all up. You need to set the DataContext of some parent UI element to be an instance of ValueDataObject. In the example code this is done in the code-behind for Page.xaml.

protected override void OnApplyTemplate()

{

    base.OnApplyTemplate();

    this.customRangeFinderContainer.DataContext = new ValueDataObject(this.rangeFinder1, "'$'0.00");

    ...

}

 

Customizing the RangeFinder

The third RangeFinder example from above re-templates the whole RangeFinder control and re-styles the GridSplitters. Even though the TextBlocks showing the Value and UpperValue properties are part of the GridSplitter style, the ValueDataObject instance in the parent's DataContext gives them something to bind to.

<Grid x:Name="flagRangeFinderContainer" Height="80" Width="300" Background="#FFDDDDDD">

  <local:RangeFinder x:Name="rangeFinder2" Height="70" Width="200" VerticalAlignment="Bottom" HorizontalAlignment="Center"

                      Minimum="10" Maximum="90" Value="20" UpperValue="70"

                      Background="Green">

        <local:RangeFinder.Style>

            <Style TargetType="local:RangeFinder">

                <Setter Property="Template">

                    <Setter.Value>

                        <ControlTemplate TargetType="local:RangeFinder">

                           <Grid x:Name="RootElement" Background="Transparent">

                                <Grid x:Name="TrackElement">

                                    <Grid.Resources>

                                        <SolidColorBrush x:Key="EndCap" Color="#55000000"/>

                                        <LinearGradientBrush x:Key="VerticalShadow" StartPoint="0,0" EndPoint="0,1">

                                            <GradientStopCollection>

                                                <GradientStop Color="Black" Offset="0"/>

                                                <GradientStop Color="Transparent" Offset="1"/>

                                            </GradientStopCollection>

                                        </LinearGradientBrush>

                                        <LinearGradientBrush x:Key="HorizontalShadow" StartPoint="0,0" EndPoint="1,0">

                                            <GradientStopCollection>

                                               <GradientStop Color="Black" Offset="0"/>

                                                <GradientStop Color="Transparent" Offset="1"/>

                                            </GradientStopCollection>

                                        </LinearGradientBrush>

                                        <LinearGradientBrush x:Key="SoutheastShadow" StartPoint="0,0" EndPoint="1,1">

                                            <GradientStopCollection>

                                                <GradientStop Color="Black" Offset=".32"/>

                                                <GradientStop Color="Transparent" Offset=".59"/>

                                            </GradientStopCollection>

                                        </LinearGradientBrush>

                                        <LinearGradientBrush x:Key="RaisedShadow" StartPoint="0,1" EndPoint="0,0">

                                            <GradientStopCollection>

                                                <GradientStop Color="#AA000000" Offset=".2"/>

                                                <GradientStop Color="Transparent" Offset=".8"/>

                                            </GradientStopCollection>

                                        </LinearGradientBrush>

                                        <LinearGradientBrush x:Key="TrackShadow" StartPoint="0,1" EndPoint="0,0">

                                            <GradientStopCollection>

                                                <GradientStop Color="#88000000" Offset=".2"/>

                                                <GradientStop Color="Transparent" Offset=".7"/>

                                                <GradientStop Color="Transparent" Offset=".8"/>

                                     <GradientStop Color="#88000000" Offset=".95"/>

                                            </GradientStopCollection>

                                        </LinearGradientBrush>

                                        <Style x:Key="valueTextBlockStyle" TargetType="TextBlock">

                                            <Setter Property="FontFamily" Value="Trebuchet MS"/>

                                            <Setter Property="FontSize" Value="11"/>

                           </Style>

                                        <Style x:Key="RightGridSplitter" TargetType="GridSplitter">

                                            <Setter Property="Template">

                                                <Setter.Value>

                                                    <ControlTemplate TargetType="GridSplitter">

                                                        <Grid x:Name="RootElement" IsHitTestVisible="{TemplateBinding IsEnabled}">

                          <Grid.RowDefinitions>

                                                                <RowDefinition Height="26"/>

                                                                <RowDefinition Height="*"/>

               </Grid.RowDefinitions>

                                                            <Grid.ColumnDefinitions>

                                                                <ColumnDefinition Width="6"/>

                                                                <ColumnDefinition Width="*"/>

                                                                <ColumnDefinition Width="6"/>

                                                            </Grid.ColumnDefinitions>

                                                            <!-- Inner Shadow -->

                                                            <Rectangle Grid.Row="0" Grid.Column="1" Margin="-.1,5.9,0,0" HorizontalAlignment="Stretch" VerticalAlignment="Top" Height="4" Fill="{StaticResource VerticalShadow}"/>

                                                            <Rectangle Grid.Row="0" Grid.Column="1" Margin="-.1,6,0,6" VerticalAlignment="Stretch" HorizontalAlignment="Left" Width="4" Fill="{StaticResource HorizontalShadow}">

                                                                <Rectangle.Clip>

                                                                    <PathGeometry>

                                             <PathGeometry.Figures>

                                                                            <PathFigure StartPoint="0,0">

                                                                                <PathFigure.Segments>

                                                                                    <LineSegment Point="5,5"/>

                                                                                    <LineSegment Point="5,26"/>

                               <LineSegment Point="0,26"/>

                                                                                </PathFigure.Segments>

                                                                        </PathFigure>

                                                                        </PathGeometry.Figures>

                                                                    </PathGeometry>

                                                          </Rectangle.Clip>

                                                            </Rectangle>

                                                            <!-- Outer Shadow -->

                                                            <Rectangle Grid.Row="0" Grid.Column="1" Margin="0,0,0,-3.9" HorizontalAlignment="Stretch" VerticalAlignment="Bottom" Height="4" Fill="{StaticResource VerticalShadow}"/>

                                                            <Rectangle Grid.Row="0" Grid.Column="2" Margin="0,2,-2.9,5" VerticalAlignment="Stretch" HorizontalAlignment="Right" Width="4" Fill="{StaticResource HorizontalShadow}"/>

                                                            <Rectangle Grid.Row="0" Grid.Column="2" Margin="0,0,-2,-3" HorizontalAlignment="Right" VerticalAlignment="Bottom" Height="8" Width="8" Fill="{StaticResource SoutheastShadow}"/>

                                                            <!-- Stem and Shadow -->

                                                           <Rectangle Grid.Row="1" Grid.Column="0" Margin="0,-1,0,-1" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Fill="{TemplateBinding Background}"/>

                                                            <Rectangle Grid.Row="1" Grid.Column="0" Margin="0,-1,0,0" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Fill="{StaticResource RaisedShadow}"/>

                                                            <!-- Flag Shape -->

                                                            <Polygon Grid.Row="0" Grid.Column="0" Points="0,26 0,5 5,0 6,0 6,26" Fill="{TemplateBinding Background}" />

                                                            <Rectangle Grid.Row="0" Grid.Column="1" Margin="-1,0,0,0" Fill="{TemplateBinding Background}" HorizontalAlignment="Stretch" VerticalAlignment="Top" Height="6" />

                                                            <Rectangle Grid.Row="0" Grid.Column="1" Margin="-1,0,0,0" Fill="{TemplateBinding Background}" HorizontalAlignment="Stretch" VerticalAlignment="Bottom" Height="6" />

                                                            <Polygon Grid.Row="0" Grid.Column="2" Margin="-1,0,0,0" Points="0,26 0,0 6,0 6,21 1,26" Fill="{TemplateBinding Background}"/>

                  <TextBlock Grid.Row="0" Grid.Column="1" Margin="0,2,0,0" HorizontalAlignment="Center" VerticalAlignment="Center" Text="{Binding FormattedUpperValue, Mode=OneWay}" Style="{StaticResource valueTextBlockStyle}" />

                                                        </Grid>

                                                    </ControlTemplate>

                                                </Setter.Value>

                                            </Setter>

   </Style>

                                        <Style x:Key="LeftGridSplitter" TargetType="GridSplitter">

                                            <Setter Property="Template">

                                      <Setter.Value>

                                                    <ControlTemplate TargetType="GridSplitter">

                                                        <Grid x:Name="RootElement" IsHitTestVisible="{TemplateBinding IsEnabled}">

    <Grid.RowDefinitions>

                                                                <RowDefinition Height="26"/>

                                                                <RowDefinition Height="*"/>

                                                            </Grid.RowDefinitions>

                                                            <Grid.ColumnDefinitions>

                                                                <ColumnDefinition Width="6"/>

                                                                <ColumnDefinition Width="*"/>

                                                                <ColumnDefinition Width="6"/>

                                                       </Grid.ColumnDefinitions>

                                                            <!-- Inner Shadow -->

                                                            <Rectangle Grid.Row="0" Grid.Column="1" Margin="-.1,5.9,0,0" HorizontalAlignment="Stretch" VerticalAlignment="Top" Height="4" Fill="{StaticResource VerticalShadow}"/>

                                                            <Rectangle Grid.Row="0" Grid.Column="1" Margin="-.1,6,0,6" VerticalAlignment="Stretch" HorizontalAlignment="Left" Width="4" Fill="{StaticResource HorizontalShadow}">

                                                                <Rectangle.Clip>

                                                                    <PathGeometry>

                                <PathGeometry.Figures>

                                                                            <PathFigure StartPoint="0,0">

                                                                                <PathFigure.Segments>

                                                                                    <LineSegment Point="5,5"/>

                                                                                    <LineSegment Point="5,26"/>

            <LineSegment Point="0,26"/>

                                                                                </PathFigure.Segments>

                                                     </PathFigure>

                                                                        </PathGeometry.Figures>

                                                                    </PathGeometry>

                                       </Rectangle.Clip>

                                                            </Rectangle>

                                                            <!-- Outer Shadow -->

                                                         <Rectangle Grid.Row="0" Grid.Column="1" Margin="0,0,0,-3.9" HorizontalAlignment="Left" VerticalAlignment="Bottom" Width="4" Height="4" Fill="{StaticResource VerticalShadow}">

                                                                <Rectangle.Clip>

                                                                    <PathGeometry>

                                                                        <PathGeometry.Figures>

                                                                            <PathFigure StartPoint="0,0">

                                                                                <PathFigure.Segments>

                                                <LineSegment Point="5,5"/>

                                                                                    <LineSegment Point="26,5"/>

                                                                                 <LineSegment Point="26,0"/>

                                                                                </PathFigure.Segments>

                                                                            </PathFigure>

                                                                        </PathGeometry.Figures>

                                                                    </PathGeometry>

                                                                </Rectangle.Clip>

                                                            </Rectangle>

                                                            <Rectangle Grid.Row="0" Grid.Column="1" Margin="3.9,0,0,-3.9" HorizontalAlignment="Stretch" VerticalAlignment="Bottom" Height="4" Fill="{StaticResource VerticalShadow}"/>

                                                            <Rectangle Grid.Row="0" Grid.Column="2" Grid.RowSpan="2"  Margin="0,7,-3.9,0" VerticalAlignment="Stretch" HorizontalAlignment="Right" Width="4" Fill="{StaticResource HorizontalShadow}">

                                                                <Rectangle.Clip>

                                                                    <PathGeometry>

                                             <PathGeometry.Figures>

                                                                            <PathFigure StartPoint="0,0">

                                                                                <PathFigure.Segments>

                                                                                    <LineSegment Point="5,5"/>

                                                                                    <LineSegment Point="5,26"/>

                               <LineSegment Point="0,26"/>

                                                                                </PathFigure.Segments>

                                                                        </PathFigure>

                                                                        </PathGeometry.Figures>

                                                                    </PathGeometry>

                                                          </Rectangle.Clip>

                                                            </Rectangle>

                                                            <!-- Stem and Shadow -->

                                                            <Rectangle Grid.Row="1" Grid.Column="2" Margin="0,-1,0,-1" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Fill="{TemplateBinding Background}"/>

                                                            <Rectangle Grid.Row="1" Grid.Column="2" Margin="0,-1,0,0" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Fill="{StaticResource RaisedShadow}"/>

                                                            <!-- Flag Shape -->

                                                            <Polygon Grid.Row="0" Grid.Column="2" Points="0,26 0,0 1,0 6,5 6,26" Fill="{TemplateBinding Background}" />

                                                            <Rectangle Grid.Row="0" Grid.Column="1" Margin="-1,0,-1,0" Fill="{TemplateBinding Background}" HorizontalAlignment="Stretch" VerticalAlignment="Top" Height="6" />

                                                            <Rectangle Grid.Row="0" Grid.Column="1" Margin="-1,0,-1,0" Fill="{TemplateBinding Background}" HorizontalAlignment="Stretch" VerticalAlignment="Bottom" Height="6" />

                                                            <Polygon Grid.Row="0" Grid.Column="0" Margin="0,0,-1,0" Points="0,21 0,0 6,0 6,26 5,26" Fill="{TemplateBinding Background}"/>

                           <TextBlock Grid.Row="0" Grid.Column="1" Margin="0,2,0,0" HorizontalAlignment="Center" VerticalAlignment="Center" Text="{Binding FormattedValue, Mode=OneWay}" Style="{StaticResource valueTextBlockStyle}"/>

                  </Grid>

                                                    </ControlTemplate>

                                                </Setter.Value>

                                            </Setter>

                     </Style>

                                    </Grid.Resources>

                                    <Grid.ColumnDefinitions>

                                        <ColumnDefinition Width="*"/>

                                        <ColumnDefinition Width="*"/>

                                        <ColumnDefinition Width="*"/>

                                    </Grid.ColumnDefinitions>

                                    <Grid.RowDefinitions>

                                     <RowDefinition Height="*"/>

                                        <RowDefinition Height="6"/>

                                        <RowDefinition Height="*"/>

                                    </Grid.RowDefinitions>

                             <!-- Placeholder rectangles for measuring - should never be displayed -->

                                    <Rectangle x:Name="LeftTrackElement" Grid.Column="0" Grid.RowSpan="3" Fill="Transparent" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"/>

                                    <Rectangle x:Name="RightTrackElement" Grid.Column="2" Grid.RowSpan="3" Fill="Transparent" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"/>

                                    <!-- The two thumbs -->

                                    <GridSplitter x:Name="LeftGridSplitterElement" Style="{StaticResource LeftGridSplitter}" Grid.Column="0" HorizontalAlignment="Right" Width="{TemplateBinding ThumbWidth}" Background="{TemplateBinding Background}" VerticalAlignment="Bottom" Height="30"/>

                                    <GridSplitter x:Name="RightGridSplitterElement" Style="{StaticResource RightGridSplitter}" Grid.Column="2" HorizontalAlignment="Left" Width="{TemplateBinding ThumbWidth}" Background="{TemplateBinding Background}" VerticalAlignment="Bottom" Height="30"/>

                                    <!-- The track and its shadow -->

                                    <Rectangle Grid.Row="1" Margin="{TemplateBinding TrackMargin}"  Grid.ColumnSpan="3" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Fill="{TemplateBinding Background}"/>

                                    <Rectangle Grid.Row="1" Margin="{TemplateBinding TrackMargin}"  Grid.ColumnSpan="3" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Fill="{StaticResource TrackShadow}"/>

                                    <!-- The end caps for the track -->

                                    <Rectangle Grid.Row="1" Margin="{TemplateBinding TrackMargin}" Width="1" HorizontalAlignment="Left" VerticalAlignment="Stretch" Fill="{StaticResource EndCap}"/>

                                    <Rectangle Grid.Row="1" Grid.Column="2" Margin="{TemplateBinding TrackMargin}" Width="1" HorizontalAlignment="Right" VerticalAlignment="Stretch" Fill="{StaticResource EndCap}"/>

                                </Grid>

                            </Grid>

                        </ControlTemplate>

                    </Setter.Value>

                </Setter>

                <Setter Property="Background" Value="DarkGray"/>

                <Setter Property="ThumbWidth" Value="40"/>

            </Style>

        </local:RangeFinder.Style>

    </local:RangeFinder>

</Grid>

 

There are some things the RangeFinder sample code is missing - fancy state transitions, rock-solid coercion, and thorough testing. The code is provided as-is but feel free to party away and mutate it in to your own concoction.

RangeFinderExampleCode.zip