Click here to Skip to main content
15,860,943 members
Articles / Desktop Programming / WPF

ZoomBoxPanel implementing a choice of page view zoom modes in WPF: Part 1

Rate me:
Please Sign up or sign in to vote.
4.50/5 (4 votes)
21 Oct 2009Ms-PL10 min read 28.6K   996   27   1
Think of the way Adobe Reader and Google Earth let the user scale and rotate their content in different ways. Making these capabilities easily available in WPF is the goal of this custom control.

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..

Image 1

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.

Image 2

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.

Image 3

When the Fit Height mode is selected, the content is scaled to take up all vertical space without regard to horizontal clipping.

XML
<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.

C#
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:

C#
// Layout
private static DependencyProperty PaddingProperty;
private static DependencyProperty CenterContentProperty;

// Zoom Scale
private static DependencyProperty MinZoomProperty;
private static DependencyProperty MaxZoomProperty;
private static DependencyProperty ZoomProperty;

// modes
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.

C#
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.

C#
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:

C#
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.

C#
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 ZoomBoxPanels, 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:

C#
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.

C#
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.

XML
<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.

XML
<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:

C#
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.

C#
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.

C#
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.

C#
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.

C#
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:

C#
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.

C#
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.

C#
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.

C#
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.

C#
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.

License

This article, along with any associated source code and files, is licensed under The Microsoft Public License (Ms-PL)


Written By
Software Developer
United Kingdom United Kingdom
Tom has been developing software for 20 years and has done a lot of different stuff. Recently this has mostly been C# WPF/Silverlight, ASP.Net and Ruby On Rails.

Blog: http://rightondevelopment.blogspot.com/

Comments and Discussions

 
GeneralMy vote of 5 Pin
along_chen27-Jul-16 18:30
along_chen27-Jul-16 18:30 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.