Introduction
This is the first in a series of articles that show you how to build a powerful WPF custom control to use instead of the standard WPF Viewbox
class.
Think of the way Adobe Reader and Google Earth let the user scale and rotate their content in different ways. There is a functionality there that users take for granted in a modern application. Making these capabilities easily available in WPF is the goal of the custom control developed in these articles..
The core functionality of the ZoomBoxPanel
is introduced here, and additional functionality will be added in later articles. Each stage of the development results in a self contained and working control. So if all the functionality you are looking for is in this article, then use the attached code; otherwise, look at the later articles for more.
Requirements
This control is extracted from a real project that has the following requirements:
- Direct replacement for standard WPF controls such as
Viewbox
or Canvas
- Should be usable from XAML, no code-behind class plumbing necessary
- Always maintain aspect ratios when zooming
- Support custom background
- Support padding around the content
- Support choice of centering or absolute positioning of content
- Support the following zoom modes: Actual Size, Fit Page, Fit Width, Fit Height, Fit Visible
Using ZoomBoxPanel
Similar to other WPF containers, ZoomBoxPanel
has no user interface of its own, and its functionality only becomes apparent when it has content. The screenshot shows the main window of the attached sample application. The Window
is split into two parts, both containing a ZoomBoxPanel
. The one on the left contains a stack of controls bound to the surrounding ZoomBoxPanel
, and the one on the right contains an image with a border. Initially, the zoom mode is set to Actual Size, so the contents appear as if you were using a Canvas
control.
When the Fit Page mode is selected, the contents are scaled to fill as much of the available space as possible without clipping any content.
When the Fit Height mode is selected, the content is scaled to take up all vertical space without regard to horizontal clipping.
<Window x:Class="ZoomBox1.Demo1_Window"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:zb="clr-namespace:ZoomBoxLibrary"
Title="ZoomBox Demo 1" Height="430" Width="675">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200" />
<ColumnDefinition Width="10" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<zb:ZoomBoxPanel x:Name="zoomBoxMenu"
ZoomMode="ActualSize" CenterContent="False">
<Border BorderThickness="3" BorderBrush="DarkKhaki">
<StackPanel Background="Beige">
<GroupBox Header="Zoom Mode" Margin="3,0">
<StackPanel>
<RadioButton Height="16" Width="120"
IsChecked="{Binding ElementName=zoomBoxMenu,
Path=IsZoomMode_ActualSize}">
Actual Size</RadioButton>
<RadioButton Height="16" Width="120"
IsChecked="{Binding ElementName=zoomBoxMenu,
Path=IsZoomMode_FitPage}">
Fit Page</RadioButton>
<RadioButton Height="16" Width="120"
IsChecked="{Binding ElementName=zoomBoxMenu,
Path=IsZoomMode_FitWidth}">
Fit Width</RadioButton>
<RadioButton Height="16" Width="120"
IsChecked="{Binding ElementName=zoomBoxMenu,
Path=IsZoomMode_FitHeight}">
Fit Height</RadioButton>
<RadioButton Height="16" Width="120"
IsChecked="{Binding ElementName=zoomBoxMenu,
Path=IsZoomMode_FitVisible}">
Fit Visible</RadioButton>
</StackPanel>
</GroupBox>
<StackPanel Orientation="Horizontal">
<Label VerticalAlignment="Center" Margin="3,0">Zoom:</Label>
<TextBlock Text="{Binding ElementName=zoomBoxMenu, Path=Zoom}"
VerticalAlignment="Center" MaxWidth="60" />
</StackPanel>
<CheckBox Height="16" Width="120"
IsChecked="{Binding ElementName=zoomBoxMenu, Path=CenterContent}"
Margin="3,0">Center Content</CheckBox>
</StackPanel>
</Border>
</zb:ZoomBoxPanel>
<GridSplitter Width="10" ResizeBehavior="PreviousAndNext"
Grid.Column="1" BorderThickness="1" BorderBrush="Black" />
<zb:ZoomBoxPanel ZoomMode="{Binding ElementName=zoomBoxMenu, Path=ZoomMode}"
CenterContent="{Binding ElementName=zoomBoxMenu, Path=CenterContent}"
x:Name="zoomBoxImage"
Background="Bisque" Grid.Column="2" >
<Border BorderThickness="3" BorderBrush="Black">
<Image Stretch="None" Width="400" Height="300"
Source="/ZoomBox1;component/Images/marinbikeride400.jpg" />
</Border>
</zb:ZoomBoxPanel>
</Grid>
</Window>
The demo window is defined entirely in XAML; there is nothing in the code-behind class. The left ZoomBoxPanel
, “zoomBoxMenu
”, is used like any other panel. By default, the ZoomMode
is set to FirstPage
and CenterContent
is set to true
, so they have been overridden here. Each radio button has been bound to a special boolean property of the ZoomBoxPanel which is set to true if that mode is selected. This duplicates the ZoomMode
property which returns an enum of the modes. This is a case of adding extra C# code to the control to make life easier for the user. The checkbox is bound to the CenterContent
property and the TextBlock
is bound to the Zoom
property. The Zoom
property is just for show, but in later articles, we will let the user control this directly. The right ZoomBoxPanel
has its key properties bound to the other ZoomBoxPanel
. This ensures both will behave in the same way.
The ZoomBoxPanel Code
The control contains over 400 lines of C# code, only some of which is described below. It is all there in the attached source code though.
public class ZoomBoxPanel : System.Windows.Controls.Panel, INotifyPropertyChanged
ZoomBoxPanel
inherits directly from Panel
in the same way that the Grid
and Canvas
controls do. This gives us functionality like the Background
property for free. As there is no user interface as such, there is no need for a template or style definition to be defined. The class also implements the INotifyPropertyChanged
interface, the reason for which will be explained later on.
Dependency Properties
The following dependency properties are defined, most of which have been used in the above example:
private static DependencyProperty PaddingProperty;
private static DependencyProperty CenterContentProperty;
private static DependencyProperty MinZoomProperty;
private static DependencyProperty MaxZoomProperty;
private static DependencyProperty ZoomProperty;
private static DependencyProperty ZoomModeProperty;
Dependency properties require a lot more work to set up than traditional properties, but they fully integrate into the WPF system.
PaddingProperty = DependencyProperty.Register(
"Padding", typeof(Thickness), typeof(ZoomBoxPanel),
new FrameworkPropertyMetadata(
new Thickness(DEFAULTLEFTRIGHTMARGIN,DEFAULTTOPBOTTOMMARGIN,
DEFAULTLEFTRIGHTMARGIN,DEFAULTTOPBOTTOMMARGIN),
FrameworkPropertyMetadataOptions.AffectsRender |
FrameworkPropertyMetadataOptions.Journal, null, null),
null);
In this code, the Padding
property is defined, with a default Thickness
definition. The property is given the AffectsRender
flag which causes the control to be automatically re-laid out any time the Padding
is changed, which saves us having to write code to do this.
MinZoomProperty = DependencyProperty.Register(
"MinZoom", typeof(double), typeof(ZoomBoxPanel),
new FrameworkPropertyMetadata(1.0,
FrameworkPropertyMetadataOptions.AffectsRender |
FrameworkPropertyMetadataOptions.Journal,
PropertyChanged_Zoom, CoerceMinZoom),
new ValidateValueCallback(ZoomBoxPanel.ValidateIsPositiveNonZero));
MaxZoomProperty = DependencyProperty.Register(
"MaxZoom", typeof(double), typeof(ZoomBoxPanel),
new FrameworkPropertyMetadata(1000.0,
FrameworkPropertyMetadataOptions.AffectsRender |
FrameworkPropertyMetadataOptions.Journal,
PropertyChanged_Zoom, CoerceMaxZoom),
new ValidateValueCallback(ZoomBoxPanel.ValidateIsPositiveNonZero));
ZoomProperty = DependencyProperty.Register(
"Zoom", typeof(double), typeof(ZoomBoxPanel),
new FrameworkPropertyMetadata(100.0,
FrameworkPropertyMetadataOptions.AffectsRender |
FrameworkPropertyMetadataOptions.Journal, PropertyChanged_Zoom, CoerceZoom),
new ValidateValueCallback(ZoomBoxPanel.ValidateIsPositiveNonZero));
The Zoom
property is the percentage scale of the content magnification. Thus, 100% is actual size. The MinZoom
and MaxZoom
properties act as constraints on the Zoom
property. All three properties can be changed dynamically either in the XAML design process or at runtime. So we need to ensure that all three are valid in themselves and valid in relation to each other at all times.
As part of the Dependency Property definition, we can define our own callback functions to validate, coerce, and react to changes in the property. The code above illustrates both the old and new way of defining callbacks. The new ValidateValueCallback
statement creates a new instance of a callback. However, this step can now be handled by the latest version of the compiler, so PropertyChanged_Zoom
and CoerceMinZoom
can be passed as they are, and the callback instance will be created automatically by the compiler.
The first function to be called is the validate callback:
private static bool ValidateIsPositiveNonZero(object value)
{
double v = (double)value;
return (v > 0.0) ? true : false;
}
All three properties must be greater than zero to avoid divide by zero errors, and this simple function ensures that.
The second function to be called is the coerce callback, and that is much more interesting as it lets us modify the value before it is set.
private static object CoerceZoom(DependencyObject d, object value)
{
double dv = (double)value;
ZoomBoxPanel z = d as ZoomBoxPanel;
if (z != null)
{
if (dv > z.MaxZoom)
dv = z.MaxZoom;
else if (dv < z.MinZoom)
dv = z.MinZoom;
}
return dv;
}
When the Zoom
property is changed, this function checks if the value is outside the MinZoom
/MaxZoom
range, and adjusts the value to fit within the range if it is. What makes this code more complicated is that the function is a static method common to all ZoomBoxPanel
s, but the Min
and Max
values are specific to each instance. So, we have to cast the owner of the property into the ZoomBoxPanel
we expect it to be, and then we have access to its MinZoom
and MaxZoom
values.
The third and final callback function to be called is PropertyChanged_Zoom
, that is called after the property has been assigned the new value. In this version of the control, we do not need to do anything with this event, but it will become crucial as we add more functionality to the control.
Zoom Modes
The key feature of this ZoomBoxPanel
is that it allows a choice of zoom modes, which are defined by the following enum:
public enum eZoomMode
{
CustomSize,
ActualSize,
FitWidth,
FitHeight,
FitPage,
FitVisible
}
ZoomModeProperty = DependencyProperty.Register(
"ZoomMode", typeof(ZoomBoxPanel.eZoomMode), typeof(ZoomBoxPanel),
new FrameworkPropertyMetadata(ZoomBoxPanel.eZoomMode.FitPage,
FrameworkPropertyMetadataOptions.AffectsRender |
FrameworkPropertyMetadataOptions.Journal,
PropertyChanged_AMode, null),
null);
The ZoomMode
is defined as a dependency property in the same way as the ones above. A traditional property is also defined as a wrapper for the dependency property.
public ZoomBoxPanel.eZoomMode ZoomMode
{
set { SetValue(ZoomModeProperty, value); }
get { return (ZoomBoxPanel.eZoomMode)GetValue(ZoomModeProperty); }
}
An enum property like this works great in the design studio, and the enum values can be set directly in XAML, which is very cool.
<zb:ZoomBoxPanel x:Name="zoomBoxMenu" ZoomMode="ActualSize" CenterContent="False">
Light Weight Dependency Properties
However, enums cannot be used directly to set a check on a menu item, checkbox, or radiobutton control. So we need additional properties that return a bool
for each value.
<RadioButton Height="16" Width="120"
IsChecked="{Binding ElementName=zoomBoxMenu, Path=IsZoomMode_ActualSize}">
Actual Size</RadioButton>
Now, I could have made each of these into a new dependency property. This would have involved the usual verbose code required to define them, and I would also have had to write a set of event handlers to ensure that all those related properties were kept in sync. So, if IsZoomMode_FitWidth
is set to true
, then all the others are set to false
, and XoomMode
is set to the correct enum. All of which was far more work and complexity than I wanted, so I came up with a different approach:
public bool IsZoomMode_CustomSize { set { if (value) ZoomMode = eZoomMode.CustomSize; }
get { return ZoomMode == eZoomMode.CustomSize; } }
public bool IsZoomMode_ActualSize { set { if (value) ZoomMode = eZoomMode.ActualSize; }
get { return ZoomMode == eZoomMode.ActualSize; } }
public bool IsZoomMode_FitWidth { set { if (value) ZoomMode = eZoomMode.FitWidth; }
get { return ZoomMode == eZoomMode.FitWidth; } }
public bool IsZoomMode_FitHeight { set { if (value) ZoomMode = eZoomMode.FitHeight; }
get { return ZoomMode == eZoomMode.FitHeight; } }
public bool IsZoomMode_FitPage { set { if (value) ZoomMode = eZoomMode.FitPage; }
get { return ZoomMode == eZoomMode.FitPage; } }
public bool IsZoomMode_FitVisible { set { if (value) ZoomMode = eZoomMode.FitVisible; }
get { return ZoomMode == eZoomMode.FitVisible; } }
Each of the properties that we need is simply defined in a single line as a traditional property that manipulates the main ZoomMode
property.
When I first learned WPF, I assumed that only dependency properties could be used for data binding in XAML, but tradition properties can be as well. Which leads to the question, why do we need to bother with dependency properties at all? The answer is the WPF notification system, its internal plumbing only fully works with dependency properties.
I got away with using the above traditional properties for two reasons. When the property is set, it then sets the value of a dependency property and kicks off the WPF event handling that way.
However, as things stand, WPF has no way of knowing when one of these properties has changed, so that the radiobuttons in the example will not be updated. The solution is to implement the INotifyPropertyChanged
interface as part of the class definition.
public event PropertyChangedEventHandler PropertyChanged;
public void NotifyPropertyChanged()
{
if (PropertyChanged != null)
PropertyChanged(this, new PropertyChangedEventArgs(null));
}
This simple interface implements an event that is called each time a property is changed. WPF automatically detects and listens to this event, and updates all the bound controls for us. So, all we have to do is to trigger the event which is done in the property changed callback of the ZoomMode
property.
private static void PropertyChanged_AMode(DependencyObject d,
DependencyPropertyChangedEventArgs e)
{
ZoomBoxPanel z = d as ZoomBoxPanel;
if (z != null)
z.NotifyPropertyChanged();
}
Transformations
In 2D graphics, there are three basic ways of transforming coordinates: Translation, Rotation, and Scale. We need translation to move the content to the middle or to the top left padding position, and we need to scale/zoom the content to the desired size. We will leave rotation to a future article.
The math for these transformations is complex, and fortunately, WPF provides us with classes to do the work for us. ZoomBoxPanel
has each type of transform defined as a member variable.
private TranslateTransform translateTransform;
private RotateTransform rotateTransform;
private ScaleTransform zoomTransform;
private TransformGroup transformGroup;
public ZoomBoxPanel()
{
translateTransform = new TranslateTransform();
rotateTransform = new RotateTransform();
zoomTransform = new ScaleTransform();
transformGroup = new TransformGroup();
transformGroup.Children.Add(this.rotateTransform);
transformGroup.Children.Add(this.zoomTransform);
transformGroup.Children.Add(this.translateTransform);
In the constructor, the instances of the transforms are created and added to a special TransformGroup
object. This combines all three transforms into a single operation.
protected override void OnInitialized(EventArgs e)
{
base.OnInitialized(e);
ApplyZoom(false);
foreach (UIElement element in base.InternalChildren)
{
element.RenderTransform = this.transformGroup;
}
}
Once the ZoomBoxPanel
and its contents have been initialised, this code assigns our transformation operation to each of the contents. All we have to do now is set the values of the transforms to what we want, and WPF takes care of the rest.
Whenever an important property of a control is changed, then WPF goes through a procedure to re-render it. This is what makes a WPF user interface so flexible, as the layout is repeatedly recalculated. The dependency properties above have the flag FrameworkPropertyMetadataOptions.AffectsRender
set. So, any change will result in the control being re-rendered.
The following method is called as part of the re-rendering process by WPF:
protected override Size ArrangeOverride(Size panelRect)
{
foreach (UIElement element in base.InternalChildren)
{
element.Arrange(new Rect(0, 0, element.DesiredSize.Width,
element.DesiredSize.Height));
}
RecalcPage(panelRect);
return panelRect;
}
The first thing the method does is to arrange each of the control contents to be in the top left corner with each item's default size. The transformations that we assigned to the contents will do the work from that position. The method finishes by calling RecalcPage
which determines exactly what the transformations should be.
The Zoom
property is expressed in percentage terms, but that is not very useful for doing the math, so the ZoomFactor
property is used internally.
protected double ZoomFactor
{
get { return Zoom / 100; }
set { Zoom = value * 100; }
}
The RecalcPage
method is passed the current size of the ZoomBoxPanel
, and calculates two things: the X,Y coordinates of the top left corner of the contents and the ZoomFactor
.
protected void RecalcPage(Size panelRect)
{
double desiredW = 0, desiredH = 0;
double zoomX = 0, zoomY = 0;
double minDimension = 5;
switch (ZoomMode)
{
case eZoomMode.CustomSize:
break;
case eZoomMode.ActualSize:
ZoomFactor = 1.0;
panX = CalcCenterOffset( panelRect.Width, childSize.Width, Padding.Left);
panY = CalcCenterOffset( panelRect.Height, childSize.Height, Padding.Top);
ApplyZoom(false);
break;
case eZoomMode.FitWidth:
desiredW = panelRect.Width - Padding.Left - Padding.Right;
if (desiredW < minDimension) desiredW = minDimension;
zoomX = desiredW / childSize.Width;
ZoomFactor = zoomX;
panX = Padding.Left;
panY = CalcCenterOffset(panelRect.Height, childSize.Height, Padding.Top);
ApplyZoom(false);
break;
case eZoomMode.FitHeight:
desiredH = panelRect.Height - Padding.Top - Padding.Bottom;
if (desiredH < minDimension) desiredH = minDimension;
zoomY = desiredH / childSize.Height;
ZoomFactor = zoomY;
panX = CalcCenterOffset(panelRect.Width, childSize.Width, Padding.Left);
panY = Padding.Top;
ApplyZoom(false);
break;
case eZoomMode.FitPage:
desiredW = panelRect.Width - Padding.Left - Padding.Right;
if (desiredW < minDimension) desiredW = minDimension;
zoomX = desiredW / childSize.Width;
desiredH = panelRect.Height - Padding.Top - Padding.Bottom;
if (desiredH < minDimension) desiredH = minDimension;
zoomY = desiredH / childSize.Height;
if (zoomX <= zoomY)
{
ZoomFactor = zoomX;
panX = Padding.Left;
panY = CalcCenterOffset(panelRect.Height, childSize.Height, Padding.Top);
}
else
{
ZoomFactor = zoomY;
panX = CalcCenterOffset(panelRect.Width, childSize.Width, Padding.Left);
panY = Padding.Top;
}
ApplyZoom(false);
break;
case eZoomMode.FitVisible:
desiredW = panelRect.Width - Padding.Left - Padding.Right;
if (desiredW < minDimension) desiredW = minDimension;
zoomX = desiredW / childSize.Width;
desiredH = panelRect.Height - Padding.Top - Padding.Bottom;
if (desiredH < minDimension) desiredH = minDimension;
zoomY = desiredH / childSize.Height;
if (zoomX >= zoomY)
{
ZoomFactor = zoomX;
panX = Padding.Left;
panY = CalcCenterOffset(panelRect.Height, childSize.Height, Padding.Top);
}
else
{
ZoomFactor = zoomY;
panX = CalcCenterOffset(panelRect.Width, childSize.Width, Padding.Left);
panY = Padding.Top;
}
ApplyZoom(false);
break;
}
}
Each zoom mode is slightly different, but they work in a similar way. They take either the width or height of the ZoomBoxPanel
, taking away the padding, and divides that by the matching dimension of the contents. That gives us the ZoomFactor
. The top left position is stored in the member variables panX
and panY
. It is either set to the padding thickness or to a point that will result in the contents being in the center of the control.
protected double CalcCenterOffset(double parent, double child, double margin)
{
if (CenterContent)
{
double offset = 0;
offset = (parent - (child * ZoomFactor)) / 2;
if (offset > margin)
return offset;
}
return margin;
}
The method that calculates the centre offset checks the CenterContent
property, and if it is false
, then it just returns the padding thickness.
protected void ApplyZoom(bool animate)
{
translateTransform.X = panX;
translateTransform.Y = panY;
zoomTransform.ScaleX = ZoomFactor;
zoomTransform.ScaleY = ZoomFactor;
}
The final stage is to assign the calculated values to the transformations. Setting the properties automatically updates the user interface. To maintain the aspect ratio of the original, we always apply the same zoom to both the X and Y directions. If you wanted the control to behave like the ViewBox
, then you would need to calculate different zoom factors for each axis.
Conclusion
I hope that this article has shown that once the hurdle of the complex WPF dependency property definitions is crossed, then it is easy to produce a powerful generic control.