Introduction
This is the fourth in a series of articles that shows you how to build a powerful WPF custom control to use instead of the standard WPF Viewbox
class.
The first article, 'ZoomBoxPanel implementing a choice of page view zoom modes in WPF: Part 1', introduced the core functionality of the ZoomBoxPanel
and its ability to resize its contents into a number of different zoom modes, such as 'Fit Page' and 'Fit Width'. The second article, 'ZoomBoxPanel: add custom commands to a WPF control: Part 2', extended ZoomBoxPanel
to support both standard and custom commands to zoom and rotate its contents. The third article, 'ZoomBoxPanel, adding full mouse support and animation to a WPF custom control: Part 3', extended ZoomBoxPanel
to support the use of the mouse and the mouse wheel in different modes, and in addition, animation was added for zoom transitions.
This article adds a new subsidiary slider control to control the ZoomBoxPanel
. The slider is designed to sit on top of the visual content and fade into the background when not in use, similar to the sliders in Google Earth and Visual Studio.
Subsidiary controls are a useful technique to add an optional user interface to a control that has no visible interface, like ZoomBoxPanel
. Floating above the content, with the ability to fade into the background when not being used, these subsidiary controls give an application a modern look and feel.
Using the ZoomBoxSlider
The ZoomBoxSlider
is a custom control that is designed to work with the ZoomBoxPanel
. It displays the current zoom scale as a percentage, and has buttons to increase and decrease the zoom. In between those is a slider that can be used to control the zoom. At the bottom is a button that sets the zoom mode to 'Page View'.
While the control is being used, it is displayed fully opaque. When the mouse is moved away from the control, it will gradually fade into the background, as can be seen in the sequence above. The ZoomBoxSlider
makes use of the application's style for the buttons and slider. The example uses the default XP Windows style. The following screenshot shows the same control used in a real application that has its own skinned interface:
<zb:ZoomBoxSlider Margin="10" Grid.Row="2" Grid.Column="0"
HorizontalAlignment="Right" VerticalAlignment="Top"
ZoomBox="{Binding ElementName=zoomBox}" />
This XAML snippet adds a ZoomBoxSlider
to the demo window. The first five properties place it in the top right corner in the third row of the grid beneath the toolbar; all standard stuff. The only special thing that needs to be done is to bind the ZoomBoxPanel
instance to the ZoomBox
property of the slider. This links the slider to the ZoomBoxPanel
, and potentially allows any number of ZoomBoxPanel
s and sliders to be placed on the same window.
The ZoomBoxSlider Template
The ZoomBoxSlider
is a lookless control. Its functionality is defined in a C# class, while its user interface is defined in a XAML template. This allows the look of the control to be changed without affecting its core functionality. This is the template for the ZoomBoxSlider
:
<Style TargetType="{x:Type zb:ZoomBoxSlider}">
<Setter Property="SnapsToDevicePixels" Value="true"/>
<Setter Property="BorderBrush" Value="{StaticResource ZB_DefaultBorderBrush}"/>
<Setter Property="Background" Value="{StaticResource ZB_DefaultBackgroundBrush}"/>
<Setter Property="Foreground" Value="{StaticResource ZB_DefaultForegroundBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="MinHeight" Value="120"/>
<Setter Property="MinWidth" Value="40"/>
<Setter Property="ContOpacity" Value="1.0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type zb:ZoomBoxSlider}">
<Border Grid.Column="1"
Width="{TemplateBinding Width}"
Height="{TemplateBinding Height}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Margin="0">
<Grid >
<Grid.RowDefinitions>
<RowDefinition Height="13" />
<RowDefinition Height="10" />
<RowDefinition />
<RowDefinition Height="10" />
<RowDefinition Height="20" />
</Grid.RowDefinitions>
<Grid Name="PART_outerGrid"
Background="{TemplateBinding Background}"
Grid.RowSpan="5"
Opacity="{Binding Path=ContOpacity,Mode=OneWay,
RelativeSource={RelativeSource TemplatedParent}}" />
<TextBlock Name="PART_DisplayText"
Text="{Binding Path=Zoom,Mode=OneWay,
RelativeSource={RelativeSource TemplatedParent},
Converter={StaticResource
zoomBoxSliderDisplayConverter}}"
TextAlignment="Center"
FontSize="9" VerticalAlignment="Center"
Opacity="{Binding Path=ContOpacity,
Mode=OneWay,RelativeSource=
{RelativeSource TemplatedParent}}" />
<Slider Grid.Row="2" Name="PART_Slider"
TickPlacement="Both" Orientation="Vertical"
Maximum="{Binding Path=MaxZoomTick,Mode=OneWay,
RelativeSource={RelativeSource TemplatedParent}}"
Minimum="{Binding Path=MinZoomTick,Mode=OneWay,
RelativeSource={RelativeSource TemplatedParent}}"
Value="{Binding Path=ZoomTick,Mode=TwoWay,
RelativeSource={RelativeSource TemplatedParent}}"
LargeChange="10"
SmallChange="1"
TickFrequency="10"
MinHeight="100"
HorizontalAlignment="Center"
Opacity="{Binding Path=ContOpacity,Mode=OneWay,
RelativeSource={RelativeSource TemplatedParent}}" />
<Button Grid.Row="4" Name="PART_FitPageButton"
Command="Zoom" HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
Opacity="{Binding Path=ContOpacity,Mode=OneWay,
RelativeSource={RelativeSource TemplatedParent}}">
<Grid>
<Canvas Height="12" Width="18">
<Rectangle Canvas.Left="3" Canvas.Top="3"
Height="6" Width="12"
Stroke="{StaticResource
ZB_LightBackgroundHighlight}"
Fill="{StaticResource
ZB_LightBackgroundHighlight}" />
<Rectangle Canvas.Left="1" Canvas.Top="1"
Height="2" Width="2"
Stroke="{StaticResource
ZB_LightBackgroundHighlight}"
Fill="{StaticResource
ZB_LightBackgroundHighlight}" />
<Rectangle Canvas.Left="1" Canvas.Top="9"
Height="2" Width="2"
Stroke="{StaticResource
ZB_LightBackgroundHighlight}"
Fill="{StaticResource
ZB_LightBackgroundHighlight}" />
<Rectangle Canvas.Left="15" Canvas.Top="9"
Height="2" Width="2"
Stroke="{StaticResource
ZB_LightBackgroundHighlight}"
Fill="{StaticResource
ZB_LightBackgroundHighlight}" />
<Rectangle Canvas.Left="15" Canvas.Top="1"
Height="2" Width="2"
Stroke="{StaticResource
ZB_LightBackgroundHighlight}"
Fill="{StaticResource
ZB_LightBackgroundHighlight}" />
</Canvas>
</Grid>
</Button>
<Button Grid.Row="1" Name="PART_ZoomInButton"
Command="IncreaseZoom" HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
Opacity="{Binding Path=ContOpacity,Mode=OneWay,
RelativeSource={RelativeSource TemplatedParent}}">
</Button>
<Button Grid.Row="3" Name="PART_ZoomOutButton"
Command="DecreaseZoom" HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
Opacity="{Binding Path=ContOpacity,Mode=OneWay,
RelativeSource={RelativeSource TemplatedParent}}">
</Button>
</Grid>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
As this is not a beginner's article, I will not explain all this, except to point out some key features.
The opacity of the controls within the template is bound to the dependency property ContOpacity
of the parent object. Other template control properties are also bound to dependency properties of the parent, such as the slider's position is bound to the ZoomTick
dependency property. By using binding in this way, the parent class can alter the appearance of the control indirectly by simply changing the values in its own properties.
To make it easier to customize the control without the effort of re-writing the template, I placed the color choices in separate definitions.
<LinearGradientBrush x:Key="ZB_LightBackgroundHighlight"
StartPoint="0,0" EndPoint="0,1">
<GradientBrush.GradientStops>
<GradientStopCollection>
<GradientStop Color="#D0D0D0" Offset="0.0"/>
<GradientStop Color="#A5A5A5" Offset="1.0"/>
</GradientStopCollection>
</GradientBrush.GradientStops>
</LinearGradientBrush>
<SolidColorBrush x:Key="BS_DefaultBorderBrush_dark" Color="#FF7D7D7D" />
<SolidColorBrush x:Key="ZB_DefaultBorderBrush" Color="#FF909090" />
<SolidColorBrush x:Key="ZB_DefaultBackgroundBrush" Color="#FFD0D0D0" />
<SolidColorBrush x:Key="ZB_DefaultForegroundBrush" Color="#FF000000" />
At the top of the control, the current zoom percentage is displayed in the TextBlock
, 'PART_DisplayText
'. This is bound to the Zoom
property of the parent which is a double
and which will contain values with many digits after the decimal point. In order to show a rounded percentage figure, a custom converter is used.
<zb:ZoomBoxSliderDisplayConverter x:Key="zoomBoxSliderDisplayConverter"/>
The converter itself is a small class written in C#.
public class ZoomBoxSliderDisplayConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter,
System.Globalization.CultureInfo culture)
{
double? d = value as double?;
if (d != null)
return String.Format("{0:0.}%", d);
return "0%";
}
public object ConvertBack(object value, Type targetType, object parameter,
System.Globalization.CultureInfo culture)
{
throw new NotSupportedException("unexpected Convertback");
}
}
The ZoomBoxSlider Code
The ZoomBoxSlider
class comprises of about 230 lines of C# code, which is a lot of code for a straightforward control like this. There is a fair amount of interesting functionality in there though, and of course, WPF and dependency properties, in particular, require a lot of verbose code.
Dependency Properties
The class defines the following dependencies, most of which are used in the user interface template shown above:
private static DependencyProperty ContOpacityProperty;
private static DependencyProperty ZoomTickProperty;
private static DependencyProperty MinZoomTickProperty;
private static DependencyProperty MaxZoomTickProperty;
private static DependencyProperty ZoomProperty;
private static DependencyProperty ZoomBoxProperty;
private static DependencyProperty TargetElementProperty;
ContOpacity
is used to set the opacity of the control itself. Using a dependency property makes it easy to apply animation to cause the control to gradually fade away.
The ZoomTick
, MinZoomTick
, and MaxZoomTick
are used to set the value and the range of the slider control. It would be possible to bind the slider to the Zoom
property; however, that would not produce the expected result for the user. This is because each change in the zoom should be the same relative and not absolute increase. So, the derived ZoomTick
property is used instead to bind to the slider's value. ZoomTick
is the ratio of the log values of the distance between the zoom value and the minimum zoom, and the distance between the zoom value and the maximum zoom.
The following method performs the calculation, and it is probably easier to understand than the explanation above:
private void calcZoomTick()
{
double logMin = Math.Log10(MinZoom);
double logMax = Math.Log10(MaxZoom);
double logZoom = Math.Log10(Zoom);
if (logMax <= logMin)
logMax = logMin + 0.01;
double perc = (logZoom - logMin) / (logMax - logMin);
ZoomTick = (perc * (MaxZoomTick - MinZoomTick)) + MinZoomTick;
}
The slider is a two way link so a change to the ZoomTick
must be translated to a change in the Zoom
, which is done by this method:
private void calcZoomFromTick()
{
double logMin = Math.Log10(MinZoom);
double logMax = Math.Log10(MaxZoom);
if (logMax <= logMin)
logMax = logMin + 0.01;
double perc = (ZoomTick - MinZoomTick) / (MaxZoomTick - MinZoomTick);
double logZoom = (perc * (logMax - logMin)) + logMin;
Zoom = Math.Pow(10.0, logZoom);
Point panelPoint = new Point(ActualWidth / 2, ActualHeight / 2);
ApplyZoomCommand(panelPoint);
}
The ZoomBox
dependency property provides the link between the slider and the control it acts upon.
ZoomBoxProperty = DependencyProperty.Register(
"ZoomBox", typeof(ZoomBoxPanel), typeof(ZoomBoxSlider),
new FrameworkPropertyMetadata(null, PropertyChanged_ZoomBox),
new ValidateValueCallback(ZoomBoxSlider.ValidateIsZoomBox));
The type of the property is obviously ZoomBoxPanel
, and the validate function simply checks the assigned control is in fact a ZoomBoxPanel
.
private static bool ValidateIsZoomBox(object value)
{
return (value == null) || (value is ZoomBoxPanel);
}
A null
value is treated as a valid value. This is to allow the slider to be used in the design process, without it complaining about not having a valid ZoomBox
before the designer has a chance to define it.
The Internal Bindings
One of the reasons for the amount of code in the ZoomBoxSlider
is replication of functionality. The ZoomBoxSlider
contains a Zoom
dependency property that must be kept in sync with the Zoom
dependency property of the linked ZoomBoxPanel
. It would be possible to bind the TextBlock
in the template directly to the Zoom
property in the ZoomBoxPanel
. The same could be done with the other properties. However, that would make the control less robust, and potentially cause confusion during the design process. So, extra C# code is justified to make life easier for the designer.
When the ZoomBox
property is set, the following method is called, which binds the slider's properties to the matching properties on the ZoomBoxPanel
.
void ZoomBoxChangeEvent()
{
if (ZoomBox != null)
{
Binding binding;
binding = new Binding();
binding.Source = ZoomBox;
binding.Path = new PropertyPath("Zoom");
binding.Mode = BindingMode.OneWay;
BindingOperations.SetBinding(this, ZoomProperty, binding);
binding = new Binding();
binding.Source = ZoomBox;
binding.Path = new PropertyPath("ZoomTick");
binding.Mode = BindingMode.TwoWay;
BindingOperations.SetBinding(this, ZoomTickProperty, binding);
binding = new Binding();
binding.Source = ZoomBox;
binding.Path = new PropertyPath("MinZoomTick");
binding.Mode = BindingMode.OneWay;
BindingOperations.SetBinding(this, MinZoomTickProperty, binding);
binding = new Binding();
binding.Source = ZoomBox;
binding.Path = new PropertyPath("MaxZoomTick");
binding.Mode = BindingMode.OneWay;
BindingOperations.SetBinding(this, MaxZoomTickProperty, binding);
}
}
The buttons in the slider's template are linked to the control using WPF commands. Three commands are configured: Zoom
, IncreaseZoom
and DecreaseZoom
.
private void SetUpCommands()
{
CommandBinding binding = new CommandBinding(NavigationCommands.Zoom,
ZoomCommand_Executed, ZoomCommand_CanExecute);
this.CommandBindings.Add(binding);
binding = new CommandBinding(NavigationCommands.IncreaseZoom,
IncreaseZoomCommand_Executed, IncreaseZoomCommand_CanExecute);
this.CommandBindings.Add(binding);
binding = new CommandBinding(NavigationCommands.DecreaseZoom,
DecreaseZoomCommand_Executed, DecreaseZoomCommand_CanExecute);
this.CommandBindings.Add(binding);
}
The IncreaseZoom
and DecreaseZoom
commands are passed straight on to the ZoomBoxPanel
:
private void IncreaseZoomCommand_Executed(object sender, ExecutedRoutedEventArgs e)
{
if (ZoomBox != null)
NavigationCommands.IncreaseZoom.Execute(null,ZoomBox);
FocusOnTarget();
}
private void DecreaseZoomCommand_Executed(object sender, ExecutedRoutedEventArgs e)
{
if (ZoomBox != null)
NavigationCommands.DecreaseZoom.Execute(null,ZoomBox);
FocusOnTarget();
}
The Zoom
command has no set meaning. The slider interprets this command to change the zoom mode to FitPage
.
private void ZoomCommand_Executed(object sender, ExecutedRoutedEventArgs e)
{
if (ZoomBox != null)
ZoomBox.ZoomMode = ZoomBoxPanel.eZoomMode.FitPage;
FocusOnTarget();
}
Fade Away Animation
The slider is designed to gradually fade away when it is not being used as explained at the start of this article.
There are five basic requirements for this functionality:
- If the mouse is over the control, don't fade away.
- Don't fade away immediately after the mouse has left the control, wait a while. That way, the user will not be distracted by the control fading away, and the fade away will occur when the user's attention is elsewhere.
- When the mouse is over the control, it should fade in, and this should start immediately and happen much faster than the fade out.
- The timings and the faded opacity should all be configurable.
- It is not just the slider that could benefit from this functionality.
The final requirement is met by placing the functionality into a separate class from which the slider control inherits.
public class ZoomBoxSlider : TWWPFUtilityLib.TWFadeAwayControl
public class TWFadeAwayControl : Control
The TWFadeAwayControl
class defines the following dependency properties:
private static DependencyProperty ContOpacityProperty;
private static DependencyProperty FadeAwayDurationProperty;
private static DependencyProperty FadeInDurationProperty;
private static DependencyProperty FadeAwayDelayProperty;
private static DependencyProperty FadedAwayOpacityProperty;
The ContOpacity
property contains the current opacity to be used in the template. The next three properties define the durations in milliseconds, and the last property defines the final faded out opacity. It is this property that will most commonly be adjusted at design time. The following defaults are used for these properties:
private const double DEFAULT_FADEAWAYDURATION = 2000;
private const double DEFAULT_FADEINDURATION = 450;
private const double DEFAULT_FADEAWAYDELAY = 3000;
private const double DEFAULT_FADEDAWAYOPACITY = 0.25;
The fade away functionality relies on the following three member variables:
bool isVisualActive = true;
If true then the control is being used, and is set to full opacity.
private bool mouseIsOver = false;
Records whether the mouse is currently over the control.
private double timeSinceMouseOut = 0;
Records the total time since the mouse left the control.
Once an instance has been created and the template applied, the control sets its visual state to inactive and starts up its timer.
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
setControlVisualActive(false, false);
SetUpTimer();
}
A DispatcherTimer
is a useful class that saves us the trouble of writing multiple threads. The instance method dispatcherTimer_Tick
is called every half a second.
private void SetUpTimer()
{
dispatcherTimer = new System.Windows.Threading.DispatcherTimer();
dispatcherTimer.Tick += new EventHandler(dispatcherTimer_Tick);
dispatcherTimer.Interval = new TimeSpan(0, 0, 0, 0, TICK_DURATION);
dispatcherTimer.Start();
}
When the mouse is moved into the control, then the following method is called:
protected override void OnMouseEnter(MouseEventArgs e)
{
mouseIsOver = true;
setControlVisualActive(true, true);
base.OnMouseEnter(e);
}
The visual state of the control is set to active, if it is not already. The opacity of the control is increased to 1.
protected override void OnMouseLeave(MouseEventArgs e)
{
mouseIsOver = false;
timeSinceMouseOut = 0;
base.OnMouseLeave(e);
}
When the mouse leaves the control, the mouseIsOver
flag is set to false
and the time record is set to zero. So nothing happens right away.
private void dispatcherTimer_Tick(object sender, EventArgs e)
{
timeSinceMouseOut += TICK_DURATION;
if ((!mouseIsOver) && (isVisualActive) &&
(timeSinceMouseOut >= FadeAwayDelay))
{
setControlVisualActive(false, true);
}
}
When the timer method is called, the first thing it does is to add 500 milliseconds to the time record. If the mouse has left the control, and it is still active, and there has been a three second delay, then the control is set to inactive and the control fades away.
This is the method that sets the state of the control, with or without animation.
private void setControlVisualActive(bool isOn, bool annimate)
{
if (isVisualActive != isOn)
{
isVisualActive = isOn;
if (!annimate)
{
ContOpacity = isVisualActive ? 1.0 : FadedAwayOpacity;
}
else
{
double dur = isVisualActive ? FadeInDuration : FadeAwayDuration;
double toVal = isVisualActive ? 1.0 : FadedAwayOpacity;
DoubleAnimation da = new DoubleAnimation(toVal, TimeSpan.FromMilliseconds(dur));
BeginAnimation(ContOpacityProperty, da, HandoffBehavior.SnapshotAndReplace);
}
}
}
The non-animated option is the easiest to understand as it simply sets the ContOpacity
property to either fully opaque or to the faded opacity set by the designer.
The animated option does the same job, but uses a DoubleAnimation
instance to do the work in the specified time. The only thing of interest is the SnapshotAndReplace
option. This is required because animations take time and the user may interrupt an animation before it is completed. For example, the control could be half way through a fade operation with an opacity of 0.6. At that moment, the user moves the mouse into the control and the event creates a new animation to increase the opacity to 1. What we want to happen is for the opacity to stop decreasing at 0.6 and then rise from that value to 1. That is exactly what the SnapshotAndReplace
options does. There are other options, including the default one, that produces different effects, none of which are desirable in the case.
One Last Trick
There is one last issue we need to address. At present, the slider only becomes active when the mouse moves over it. However, the user can alter the zoom by other means, such as by the mouse wheel, or with the menu. While the zoom is changing, it looks good if the slider becomes active. To do this, we need a couple of extra methods.
private static void PropertyChanged_Zoom(DependencyObject d,
DependencyPropertyChangedEventArgs e)
{
ZoomBoxSlider z = d as ZoomBoxSlider;
if (z != null)
z.FadeAway = false;
}
This method is included in the slider's Zoom
dependency property definition to be called whenever the zoom changes value. It is a static method, so it needs to cast an instance, and it then sets FadeAway
to false
in order to make the control active again.
public bool FadeAway
{
get { return !isVisualActive; }
set { timeSinceMouseOut = 0; setControlVisualActive(!value, true); }
}
The TWFadeAwayControl
class implements the FadeAway
property as an easy way for others to manually trigger a change of status. If the control is set to active and the mouse is not over the control, then it will begin to fade after a delay, just as if the mouse left the control.
Conclusion
This article concludes the series on developing a generic custom control that can be used in different WPF projects. The main principle behind the design of the control and the main theme of the articles, is that it is desirable to put as much of the complexity into the code as possible in order to keep the XAML design process as simple as possible.
The source code attached to this article is complete and functional, but is not updated. For the latest version, check out my blog.