Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

A WPF Custom Control for Zooming and Panning

0.00/5 (No votes)
21 Mar 2011 81  
Examines a custom content control that can be used to zoom and pan its content

Sample Code

Introduction

This article examines the use and implementation of a reusable WPF custom control that is used to zoom and pan its content. The article and the sample code show how to use the control from XAML and from C# code.

The main class, ZoomAndPanControl, is derived from the WPF ContentControl class. This means that the primary purpose of the control is to display content. In XAML, content controls are wrapped around other UI elements. For example, the content might be an image, a map or a chart. In this article, I use a Canvas as the content. This Canvas contains some colored rectangles that can be dragged about by the user.

I'll present this article in two parts. The first part shows how to use ZoomAndPanControl and has walkthrough of the three sample projects. This should be enough if all you want to do is use the code or just try it out. The second part of the article goes into detail as to how the control is implemented. This part will be useful if you want to make your own modifications to ZoomAndPanControl or generally to help you understand how to develop a non-trivial custom control.

Screenshot

This screenshot shows the data-binding sample.

The large window with scrollbars is the viewport onto the content. The toolbar contains some buttons and a slider for changing the zoom level. It also shows the current zoom level as a percentage. The content, as already mentioned, is a Canvas with colored rectangles.

The small overview window in the bottom left corner shows an overview of the entire content. The transparent yellow rectangle shows the portion of the content that is currently visible in the viewport.

Assumed Knowledge

It is assumed that you already know C# and have a basic knowledge of using WPF and XAML.

Background

In my previous article, I alluded that I have been working on a flow-charting control. The working area in which the flow-chart is displayed and edited can be much larger than the window that contains it. Usually, this is the perfect place to use a ScrollViewer to wrap the content. ScrollViewer is a pretty easy class to use. It handles content larger than itself by providing a viewport onto the content. The viewport is optionally bounded by scrollbars that allow the user to view any portion of that content.

However I wanted the user to be able to zoom in and see more detail or zoom out to see an overview. Implementing the zooming by scaling the content is fairly easily done using WPF 2D transformations. Although making it play nicely with the ScrollViewer is another matter entirely!

Writing this kind of custom control is harder than you might think. My first implementation was a bit of a disaster (well not completely - it did inspire me to rewrite the code and then write this article). The code for zooming and panning was intertangled with the code for displaying and editing the flow-chart. It probably didn't help that I was also learning WPF at the time. There are one or two examples around on the internet that show how to do this kind of thing, however I found that they were either lacking in that they didn't do entirely what I wanted or that they came with baggage in the form of extra code that I just didn't need. Suffice to say that before I wrote the code for this article, what I had was a complicated mess that kept breaking and was generally difficult to modify.

ZoomAndPanControl: What it is, What it is not

My main aim with this article is to keep the complexity of the code to a minimum. To this end ZoomAndPanControl doesn't attempt to do anything much more than what I need of it. Notably I have not attempted to implement any kind of UI virtualisation.

In addition, there is no input handling logic in the reusable control. I think that this kind of code is application specific and likely to change. Implementing it generically would add complication and so I have delegated input handling to the application code. In the sample code, the input handling code can be found in the MainWindow class (which is derived from Window).

I have found that moving the zoom and pan logic to a custom control has allowed me to cleanly separate out this code from the rest of the application. As a result, both sets of code are simpler, cleaner and more understandable.

Part 1 - Sample Project Walkthroughs

I have included three sample projects to demonstrate the use of ZoomAndPanControl. Each of the samples have basically the same content: a small collection of colored rectangle that can be dragged around by the user.

  • SimpleZoomAndPanSample.zip demonstrates the simplest possible usage of ZoomAndPanControl. This project shows how to implement left-mouse-drag panning, simple mouse-wheel zooming and plus/minus keys for zooming in and out.

  • AdvancedZoomAndPanSample.zip adds more advanced features to the simple sample. This project demonstrates the use of animated zooming to zoom to a rectangle that the user has dragged out. It has Google-maps style mouse-wheel zooming and the backspace key allows the user to jump back to the previous zoom level. It also shows how to use other UI controls (a label, buttons and a slider) to control zooming functionality.

  • DataBindingZoomAndPanSample.zip is more advanced again. This project demonstrates the use of a simple data-model and data-binding in order to share the data (the colored rectangles) between the main window and an overview window. The overview window shows a view of the content in its entirety, Photoshop style, and has a transparent yellow rectangle that shows the extent of the content that is displayed in the viewport.

  • InfiniteWorkspaceZoomAndPanSample.zip is a new sample project that has been added to this article after it was originally written. It shows how the concept of an infinite workspace can be built using the ZoomAndPanControl. As this is a new addition, I don't mention it again in the article but I will describe it briefly right here. The content canvas is initially set to size 0,0 and is automatically expanded to contain the initial content. As the user drags the colored rectangles, the canvas is automatically expanded or contracted to contain the modified content. The overview window has been changed in this sample so that it adjusts its zoom level as the canvas is expanded/contracted so that the canvas always fills the viewport. In this sample, the scrollbars have been removed and left-mouse-drag-panning and the overview window are the only ways to navigate the content.

The Basics

ZoomAndPanControl is used from XAML in basically the same way as a regular ContentControl. It wraps up the content that it is to display. The content viewport shows a portion of that content. The content can be zoomed, that is to say scaled larger or smaller than the viewport. The user is able to move the viewport by panning with the mouse or by using the scrollbars.

Here is a quick look at the main classes and their relationships (thanks to StarUML). Also shown are the main ZoomAndPanControl dependency properties and examples of some of the methods. The solid lines represents inheritance. The dashed line represents a dependency.

The next diagram attempts to illustrate how the content viewport maps to the scaled content (please excuse my amateurish Photoshop skills) :

The previous diagram showed the relationships between the various coordinate systems that I will refer to in this article. Coordinates that are relative to the content, which for this article is a Canvas, are called 'content coordinates'. Coordinates that are relative to the viewport are called 'viewport coordinates'. It might be helpful for you to think of 'viewport coordinates' as 'screen coordinates'. This probably aids in understanding but it isn't really the case because WPF is resolution independent.

To go from content coordinates to viewport coordinates an XY point is transformed by the 'content offset' and then the 'content scale' as indicated by the following diagram:

Like any WPF control, ZoomAndPanControl has dependency properties that are used to set and retrieve the control's values at runtime.

The three most important properties are:

  • ContentScale - This specifies the zoom level, or more precisely the scale of the content being viewed. When set to the default value of 1.0, the content is being viewed at 100%. I refer to this property as 'content scale'.
  • ContentOffsetX and ContentOffsetY - These values constitute the XY offset of the content viewport. These values are specified in content coordinates. I refer to these two properties collectively as 'content offset'.

Simple Sample Walkthrough

To follow along with the walkthrough, you should load the simple sample in Visual Studio (I use VS 2008) and then build and run the application.

First, let's try out the input controls that are used to interact with ZoomAndPanControl. Simply pressing the plus and minus keys zooms in and out on the content. Holding down shift and left- or right-clicking, or using the mouse-wheel also zooms in and out on the content. Clicking with the left-mouse button in open space and dragging pans the content viewport. Left-dragging can also be used to move the colored rectangles.

Now let's look at the usage ZoomAndPanControl in MainWindow.xaml. The first thing that needs to be done is to reference the namespace and assembly that contains ZoomAndPanControl:

<Window x:Class="ZoomAndPanSample.MainWindow"
    ...
    xmlns:ZoomAndPan="clr-namespace:ZoomAndPan;assembly=ZoomAndPan"
    ...
    >

    ... main window content ...

</Window>

The next snippet shows the most basic definition of ZoomAndPanControl in XAML:

<ZoomAndPan:ZoomAndPanControl
    x:Name="zoomAndPanControl"
    ...
    >

    ... content to be zoomed and panned is defined here ...

</ZoomAndPan:ZoomAndPanControl>

The content to be displayed is embedded in the XAML within the ZoomAndPanControl. For this article, a Canvas is used as the content:

<ZoomAndPan:ZoomAndPanControl
    x:Name="zoomAndPanControl"
    ...
    >
    <Canvas
        x:Name="content"
        ...
        >

        ... contents of the canvas defined here ...

    </Canvas>
</ZoomAndPan:ZoomAndPanControl>

If you already know WPF, then you will know that assigning the name "content" to the Canvas in the previous snippet causes a MainWindow member variable to be generated that references the instance of the Canvas. Likewise a "zoomAndPanControl" member variable is also generated. Shortly, we will be using these generated member variables in C# code.

Adding a Width and Height to the Canvas sets the size of the content (in content coordinates):

<ZoomAndPan:ZoomAndPanControl
    x:Name="zoomAndPanControl"
    >
    <Canvas
        x:Name="content"
        Width="2000"
        Height="2000"
        ...
        >

        ... contents of the canvas defined here ...

    </Canvas>
</ZoomAndPan:ZoomAndPanControl>

Note that a value for ContentScale was not explicitly specified in the previous snippets. The default value for ContentScale is 1.0 which means that content coordinates and viewport coordinates are at the same scale. For example, with ContentScale at 1.0, the size of our content is 2000 by 2000 in both content and viewport coordinates. However if ContentScale were set to 0.5, then the size in viewport coordinates would be scaled to half the size (50%) or 1000 by 1000. Likewise if it were set to 2.0, then the size in viewport coordinates would be double the size (200%) or 4000 by 4000.

By wrapping the ZoomAndPanControl in a ScrollViewer, we get scrollbars for free:

<ScrollViewer
    ...
    CanContentScroll="True"
    VerticalScrollBarVisibility="Visible"
    HorizontalScrollBarVisibility="Visible"
    >
    <ZoomAndPan:ZoomAndPanControl
        x:Name="zoomAndPanControl"
        >

            ... content ...

    </ZoomAndPan:ZoomAndPanControl>
</ScrollViewer>

The ZoomAndPanControl class implements the IScrollInfo interface. This interface allows the control to have an intimate relationship with the ScrollViewer. Note that CanContentScroll is set to 'True'. This is required to instruct the ScrollViewer to communicate with the ZoomAndPanControl, via IScrollInfo, to determine the horizontal and vertical scrollbar offsets and the extent of its content. I'll talk more about IScrollInfo in part 2.

As mentioned previously, ZoomAndPanControl itself doesn't handle any user input. The implementation of user input is delegated to MainWindow. Event handlers are defined for the ZoomAndPanControl for all the common mouse operations:

<ZoomAndPan:ZoomAndPanControl
    x:Name="zoomAndPanControl"
    Background="LightGray"
    MouseDown="zoomAndPanControl_MouseDown"
    MouseUp="zoomAndPanControl_MouseUp"
    MouseMove="zoomAndPanControl_MouseMove"
    MouseWheel="zoomAndPanControl_MouseWheel"
    >
    <Canvas
        x:Name="content"
        Width="2000"
        Height="2000
        Background="White"
        >

        ... contents of the canvas defined here ...

    </Canvas>
</ZoomAndPan:ZoomAndPanControl>

Note that Background has been set for both the ZoomAndPanControl and the Canvas. Primarily, this is because Background must be set in order to receive mouse events on the ZoomAndPanControl. When Background is left unset, hit testing fails and the mouse events are not raised. Background is also set to highlight the difference between the content, which is white, and the background behind the content, which is light gray. You'll see what I mean if you zoom out from the content.

The mouse event handlers in MainWindow.xaml.cs perform zooming and panning by directly setting properties of ZoomAndPanControl. This next snippet illustrates how left-mouse-button dragging updates the content offset:

private void zoomAndPanControl_MouseMove(object sender, MouseEventArgs e) 
{ 
    if (mouseHandlingMode == MouseHandlingMode.Panning) 
    { 
        Point curContentMousePoint = e.GetPosition(content); 
        Vector dragOffset = curContentMousePoint - origContentMousePoint; 
        zoomAndPanControl.ContentOffsetX -= dragOffset.X; 
        zoomAndPanControl.ContentOffsetY -= dragOffset.Y; 
        e.Handled = true; 
    } // ... other mouse input handling ... 
} 

The distance the mouse has been dragged is calculated and assigned to dragOffset. This value is then used to calculate the new content offset. Note that dragOffset is calculated in content coordinates because we are working with points that are relative to the content. If you are paying attention, you might wonder how the code in the previous snippet can actually work? Surely if origContentMousePoint is initialised in zoomAndPanControl_MouseDown then as the mouse is dragged further and further from the original point dragOffset will get larger and larger making the panning get faster and faster. At first glance, this might appear to be the case, but you have to consider that the content itself is being moved (well, internally it is actually being translated by the WPF 2D transformation system). As part of the panning, the content moves along with the mouse cursor and therefore the point in the content that the mouse is hovering over is never far from the original point. This happens because as the cursor is moved far enough to warrant a call to zoomAndPanControl_MouseMove, the content is moved to bring the original point back under the current position of the mouse cursor.

Zooming is accomplished by updating the ContentScale property. In MainWindow.xaml.cs, the methods ZoomOut and ZoomIn are called in response to various input events (plus & minus keys, mouse-wheel and shift-left/right clicks). These methods simply increase or decrease ContentScale by a small amount. For instance, ZoomOut looks like this:

private void ZoomOut()
{
    zoomAndPanControl.ContentScale -= 0.1;
}

In these code samples, the mouse wheel is only used for zooming in and out. The event handler that does the work is zoomAndPanControl_MouseWheel. There is an alternative method of handling mouse wheel input - and that is to use it to pan the viewport in the same way as a standard ScrollViewer. To have mouse wheel input work this way, set the IsMouseWheelScrollingEnabled property of ZoomAndPanControl to true. Additionally, you should not handle the MouseWheel event for ZoomAndPanControl, that is to say you won't need to have the zoomAndPanControl_MouseWheel that exists in the simple sample.

The simple sample only has limited features. It restricts itself to manipulating content offset and content scale directly. As these are dependency properties the WPF animation system can be used to animate them. However, for convenience, ZoomAndPanControl provides a number of methods that perform animated zoom and pan operations. We will look at these methods over the next few sections.

Advanced Sample Walkthrough

For this section, you should load the advanced sample in Visual Studio and build and run the application.

The advanced sample has the same features that we looked at in the simple sample. In addition, it uses the convenient ZoomAndPanControl methods to perform animated zooming and panning.

To summarize, the new features are:

  • A label that shows the current zoom level as a percentage
  • A slider to select the current zoom level
  • A toolbar with buttons for various zoom operations
  • The backspace key jumps back to the previous zoom level
  • Double-clicking centers on the clicked location
  • Drag zooming; and
  • Google-maps style mouse wheel zooming

I'll first say a quick few words about the simpler features like the slider and the buttons before moving on to the more complex features: drag-zooming and the google-maps style zooming.

The label in the toolbar shows the current zoom level as a percentage. Looking in MainWindow.xaml, you will see that the ScaleToPercentage convertor is used to convert the scale value in ContentScale to the percentage value that is displayed in the label.

The Slider in the toolbar is used to change the zoom level and it also uses the ScaleToPercentage convertor. The Value of the slider is data-bound to ContentScale:

<Slider
    ...
    Value="{Binding ElementName=zoomAndPanControl, Path=ContentScale,
	Converter={StaticResource scaleToPercentConverter}}"
    />

I can skip the zoom-in/out buttons in the toolbar - they simply call the ZoomIn and ZoomOut methods already discussed in the simple sample walkthrough.

The Fill and 100% buttons show the first example of animated zooming. For example, Fill_Executed is the method that is called when the user clicks the Fill button. It calls AnimatedScaleToFit:

private void Fill_Executed(object sender, ExecutedRoutedEventArgs e)
{
    SavePrevZoomRect();

    zoomAndPanControl.AnimatedScaleToFit();
}

AnimatedScaleToFit starts an animation that zooms in or out so that the entire content fits completely within the viewport.

Note that Fill_Executed also calls SavePrevZoomRect. This method saves the current viewport rectangle and the content scale:

private void SavePrevZoomRect()
{
    prevZoomRect = new Rect(zoomAndPanControl.ContentOffsetX,
	zoomAndPanControl.ContentOffsetY, zoomAndPanControl.ContentViewportWidth,
	zoomAndPanControl.ContentViewportHeight);
    prevZoomScale = zoomAndPanControl.ContentScale;
    prevZoomRectSet = true;
}

When the user presses the backspace key, JumpBackToPrevZoom is called which jumps back to the previous zoom level by calling AnimatedZoomTo. The previously saved viewport rectangle and content scale are passed as arguments:

private void JumpBackToPrevZoom()
{
    zoomAndPanControl.AnimatedZoomTo(prevZoomScale, prevZoomRect);

    ClearPrevZoomRect();
}

Double-clicking in the content causes the clicked location to be centered in the viewport. This is another feature that makes use of the animation methods, in this case by calling AnimatedSnapTo.

Now that I have discussed the functionality behind some of the simpler features, I'll move on to the most interesting features, which are drag-zooming and google-maps style zooming.

The drag-zooming feature allows the user to hold down the shift key and left-drag out a rectangle. ZoomAndPanControl then zooms in so that the rectangle fills the entire viewport. The visual for the rectangle is a Border that is embedded within its own Canvas within the content. By default, this Border is hidden:

<Canvas
    x:Name="dragZoomCanvas"
    Visibility="Collapsed"
    >
    <Border
        x:Name="dragZoomBorder"
        BorderBrush="Black"
        BorderThickness="1"
        Background="Silver"
        CornerRadius="1"
        Opacity="0"
        />
</Canvas>

When the user starts dragging out the rectangle, the Border is made visible:

public void InitDragZoomRect(Point pt1, Point pt2)
{
    SetDragZoomRect(pt1, pt2);

    dragZoomCanvas.Visibility = Visibility.Visible;
    dragZoomBorder.Opacity = 0.5;
}

The call to SetDragZoomRect sets the position and the size of the Border based on the parameters pt1 and pt2. SetDragZoomRect is called repeatedly whilst the user continues to drag out the rectangle:

private void zoomAndPanControl_MouseMove(object sender, MouseEventArgs e)
{
    ... handle other mouse handling modes ...

    else if (mouseHandlingMode == MouseHandlingMode.DragZooming)
    {
        Point curContentMousePoint = e.GetPosition(content);
            SetDragZoomRect(origContentMouseDownPoint, curContentMousePoint);
        e.Handled = true;
        }
    }

If you look at the code for SetDragZoomRect, you will see that it has responsibility for reversing pt1 and pt2 if the user starts dragging out the rectangle left or up rather than right or down.

When the user has finished dragging out the rectangle, AnimatedZoomTo is called:

private void ApplyDragZoomRect()
{
    SavePrevZoomRect();

    double contentX = Canvas.GetLeft(dragZoomBorder);
    double contentY = Canvas.GetTop(dragZoomBorder);
    double contentWidth = dragZoomBorder.Width;
    double contentHeight = dragZoomBorder.Height;
    zoomAndPanControl.AnimatedZoomTo(new Rect
		(contentX, contentY, contentWidth, contentHeight));

    FadeOutDragZoomRect();
}

AnimatedZoomTo performs an animated zoom so that the dragged out rectangle fills the viewport. Also note the call to FadeOutDragZoomRect. This starts an animation that fades out the Border and returns it to its default hidden state.

The other advanced feature to mention is the google-maps style zooming. This is implemented by the method ZoomAboutPoint. This method zooms in or out while keeping the 'zoom focus' locked to the same point in the viewport:

private void ZoomOut(Point contentZoomCenter)
{
    zoomAndPanControl.ZoomAboutPoint
	(zoomAndPanControl.ContentScale - 0.1, contentZoomCenter);
}

This same method is called in response to shift left- or right-click and scrolling the mouse-wheel. In either case, the contentZoomCenter parameter that is passed is set to the position under the mouse cursor. Locking the zoom focus means that we can zoom in and out and the point that is under the mouse cursor remains under the mouse cursor as we zoom.

We are now finished looking at the advanced sample. I have covered a number of the animated zoom methods, for a full list see the section ZoomAndPanControl Methods. Now let's move onto the data binding sample.

Data Binding Sample Walkthrough

The purpose of this project is to show how a data-model and data-binding can be used to share content between the main window and an overview window. The main window is the same as it was in the advanced sample. It shows a view of the content that we can zoom and pan. The overview window is new in this project and shows all of the content in its entirety. It displays a transparent yellow rectangle that shows the position and size of the main window's viewport onto the content.

The simple and advanced projects both use a simple Canvas as the container for our content. The content, the colored rectangles, was embedded statically within the XAML. Now that we are using a data-model to share content between views, we need to replace the Canvas with a control that supports data-binding. I chose to use a ListBox partially because of its good data-binding support, but also because I wanted to demonstrate how its selection logic could be reused for content that can also be zoomed and panned. For example, if you left-click one of the colored rectangles, it will be selected and a blue border is displayed. A Canvas is still used however, but it is now embedded with in the ListBox. The ListBox is bound to the data-source and it fills the Canvas with UI elements that are generated from data-templates.

Load the data-binding sample in Visual Studio. The data-model can be found in DataModel.cs. Purely for convenience, DataModel is a singleton class. It has a Rectangles property which is a list of RectangleData objects. This property is the data-source that will populate the list boxes in both the main window and the overview window.

Let's look at the code for the overview window. Open OverviewWindow.xaml and you will see it contains a ZoomAndPanControl. Note that the SizeChanged event is handled:

<ZoomAndPan:ZoomAndPanControl
    x:Name="overview"
    SizeChanged="overview_SizeChanged"
    >

    ... overview content ...

</ZoomAndPan:ZoomAndPanControl>

The implementation of overview_SizeChanged in OverviewWindow.xaml.cs calls ScaleToFit on the ZoomAndPanControl. Whenever the user resizes the overview window the content is rescaled so that it fits in its entirety:

private void overview_SizeChanged(object sender, SizeChangedEventArgs e)
{
    overview.ScaleToFit();
}

Next have a look at MainWindow.xaml and how a ListBox is used as the content. The ListBox's ItemsSource property is data-bound to the Rectangles property of the data-model:

<ListBox
    x:Name="content"
    ...
    ItemsSource="{Binding Source={x:Static local:DataModel.Instance},
	Path=Rectangles}"
    ...
    />

The ListBox is restyled to provide a new visual template:

<ListBox
    x:Name="content"
        ...
    ItemsSource="{Binding Source={x:Static local:DataModel.Instance},
	Path=Rectangles}"
    Style="{StaticResource noScrollViewerListBoxStyle}"
    ...
    />

The replacement visual template that is specified by noScrollViewerListBoxStyle is one that has no embedded ScrollViewer:

<Style x:Key="noScrollViewerListBoxStyle" TargetType="ListBox">
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="ListBox">
                <Canvas
                    Background="{TemplateBinding Background}"
                    IsItemsHost="True"
                    />
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

The ScrollViewer in the default visual template is redundant because we already have a ScrollViewer wrapped around the ZoomAndPanControl in MainWindow.xaml. Note that the replacement visual template is where the Canvas is defined as the ListBox's panel, this is why IsItemsHost is set to True.

A style is also specified for each ListBoxItem by setting ItemContainerStyle.

<ListBox
    x:Name="content"
        ...
    ItemsSource="{Binding Source={x:Static local:DataModel.Instance},
	Path=Rectangles}"
    Style="{StaticResource noScrollViewerListBoxStyle}"
    ItemContainerStyle="{StaticResource listBoxItemStyle}"
    />

listBoxItemStyle contains data-bindings that are used to position each list box item within the Canvas:

<Style
    x:Key="listBoxItemStyle"
    TargetType="ListBoxItem"
    >
    <Setter
        Property="Canvas.Left"
        Value="{Binding X}"
        />
    <Setter
        Property="Canvas.Top"
        Value="{Binding Y}"
        />

    ...

</Style> 

The style also defines a Border that is used to show when the item is selected. Normally, the Border is transparent and thus invisible. However a trigger changes the Border to blue when IsSelected is set to true:

<Style
    x:Key="listBoxItemStyle"
    TargetType="ListBoxItem"
    >

    ...

    <Setter
        Property="IsSelected"
        Value="{Binding IsSelected}"
        />

    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="ListBoxItem">
                <Border
                    Name="Border"
                    BorderThickness="1"
                    Padding="2"
                    >
                    <ContentPresenter />
                </Border>
                <ControlTemplate.Triggers>
                    <!--
                    When the ListBoxItem is selected draw a
				simple blue border around it.
                    -->
                    <Trigger Property="IsSelected" Value="true">
                        <Setter
                            TargetName="Border"
                            Property="BorderBrush"
                            Value="Blue"
                            />
                    </Trigger>
              </ControlTemplate.Triggers>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

Now we will examine the implementation of the transparent yellow rectangle in the overview window that shows the position and extent of the content viewport. Looking at OverviewWindow.xaml again, we can see that it is a Thumb that has its visual template set to a transparent yellow Border:

<Canvas>
    <Thumb
        x:Name="overviewZoomRectThumb"
        ...
        Opacity="0.5"
        >
        <Thumb.Template>
            <ControlTemplate TargetType="{x:Type Thumb}">
                <Border
                    ...
                    Background="Yellow"
                    />
            </ControlTemplate>
        </Thumb.Template>
    </Thumb>
</Canvas>                           

A Thumb is used because it conveniently provides the DragDelta event. DragDelta allows us respond to the user dragging the Thumb:

<Canvas>
    <Thumb
        x:Name="overviewZoomRectThumb"
            ...
        DragDelta="overviewZoomRectThumb_DragDelta"
        Opacity="0.5"
        >
        <Thumb.Template>
            <ControlTemplate TargetType="{x:Type Thumb}">
                <Border
                        ...
                    Background="Yellow"
                    />
            </ControlTemplate>
        </Thumb.Template>
    </Thumb>
</Canvas>

The event handler pans the viewport when the user drags the yellow rectangle. It achieves this by simply updating the Canvas position of the Thumb:

private void overviewZoomRectThumb_DragDelta
		(object sender, DragDeltaEventArgs e)
{
    double newContentOffsetX = Math.Min(Math.Max(0.0, Canvas.GetLeft
	(overviewZoomRectThumb) + e.HorizontalChange), DataModel.Instance.ContentWidth -
	DataModel.Instance.ContentViewportWidth);
    Canvas.SetLeft(overviewZoomRectThumb, newContentOffsetX);

    double newContentOffsetY = Math.Min(Math.Max(0.0, Canvas.GetTop
	(overviewZoomRectThumb) + e.VerticalChange),
	DataModel.Instance.ContentHeight - DataModel.Instance.ContentViewportHeight);
    Canvas.SetTop(overviewZoomRectThumb, newContentOffsetY);
}

You should notice, in the previous snippet, that the content offset is kept clamped within its valid range. For the X offset that is from 0.0 (ContentWidth - ContentViewportWidth) (both members of DataModel) and a similar formula is used for the Y offset.

How does updating the Canvas position of the Thumb pan the viewport? Because the position and size of the Thumb are bound to the data-model:

<Canvas>
    <Thumb
        x:Name="overviewZoomRectThumb"
        Canvas.Left="{Binding Source={x:Static local:DataModel.Instance}, 
			Path=ContentOffsetX, Mode=TwoWay}"
            Canvas.Top="{Binding Source={x:Static local:DataModel.Instance}, 
			Path=ContentOffsetY, Mode=TwoWay}"
            Width="{Binding Source={x:Static local:DataModel.Instance}, 
			Path=ContentViewportWidth}"
            Height="{Binding Source={x:Static local:DataModel.Instance}, 
			Path=ContentViewportHeight}"
        DragDelta="overviewZoomRectThumb_DragDelta"
        Opacity="0.5"
        >
        <Thumb.Template>
            <ControlTemplate TargetType="{x:Type Thumb}">
                <Border
                    ...
                    Background="Yellow"
                    />
            </ControlTemplate>
        </Thumb.Template>
    </Thumb>
</Canvas>

Switching back to MainWindow.xaml, we can see that the position and extent of the content viewport here are also bound to the data-model:

<ZoomAndPan:ZoomAndPanControl
    x:Name="zoomAndPanControl"
    ContentScale="{Binding Source={x:Static local:DataModel.Instance}, 
		Path=ContentScale, Mode=TwoWay}"
    ContentOffsetX="{Binding Source={x:Static local:DataModel.Instance}, 
		Path=ContentOffsetX, Mode=TwoWay}"
    ContentOffsetY="{Binding Source={x:Static local:DataModel.Instance}, 
		Path=ContentOffsetY, Mode=TwoWay}"
    ContentViewportWidth="{Binding Source={x:Static local:DataModel.Instance}, 
		Path=ContentViewportWidth, Mode=OneWayToSource}"
    ContentViewportHeight="{Binding Source={x:Static local:DataModel.Instance}, 
		Path=ContentViewportHeight, Mode=OneWayToSource}"
    ...
    >

    ... content defined here ...

</ZoomAndPan:ZoomAndPanControl>

A few paragraphs back, I mentioned the ContentWidth property of DataModel. There is also a matching ContentHeight property. These properties define the size of the content. In MainWindow.xaml, we can see that the Width and Height properties are bound to the data-model properties:

<ZoomAndPan:ZoomAndPanControl
    ...
    >

    <Grid
        Width="{Binding Source={x:Static local:DataModel.Instance}, Path=ContentWidth}"
        Height="{Binding Source={x:Static local:DataModel.Instance}, Path=ContentHeight}"
        >

        ... content defined here ...

    </Grid>
</ZoomAndPan:ZoomAndPanControl>

This concludes the walkthrough of the sample projects. Data binding, if you are not already versed, is arguably a hard topic to come to terms with and hopefully you have made it this far! The main thing I wanted to show is that data-binding is a good way to keep the main window viewport and the overview window synchronized.

The next two sections are a summary of ZoomAndPanControl properties and methods. After that, we move onto Part 2 which discusses the implementation of ZoomAndPanControl.

ZoomAndPanControl Properties

This section is a summary of ZoomAndPanControl dependency properties.

Name Description
ContentScale The zoom level, or more precisely the scale of the content being viewed.
MinContentScale, MaxContentScale The valid range of values for ContentScale.
ContentOffsetX, ContentOffsetY The XY offset of the content viewport (in content coordinates).
AnimationDuration The duration of the zoom and pan animations (in seconds) started by calling AnimatedZoomTo and the other animation methods.
ContentZoomFocusX, ContentZoomFocusY The offset in the content (in content coordinates) that currently has the zoom focus. This is automatically updated whenever the viewport is panned and when AnimatedZoomTo or the other animation methods are called.
ViewportZoomFocusX, ViewportZoomFocusY The offset in the viewport (in viewport coordinates) that currently has the zoom focus. This is usually set to the center of the viewport, but is automatically updated when AnimatedZoomTo or the other animation methods are called.
ContentViewportWidth, ContentViewportHeight The width and height of the viewport, but specified in content coordinates. These are updated automatically when ever the viewport is resized.
IsMouseWheelScrollingEnabled Set to true to enable the control to pan the viewport in response to mouse wheel input. This is set to false by default.

The following properties of IScrollInfo are implemented (although they aren't dependency properties):

Name Description
HorizontalOffset, VerticalOffset The XY offset of the viewport (in scaled content coordinates).
ViewportWidth, ViewportHeight The width and height of the viewport (in viewport coordinates).
ExtentWidth, ExtentHeight The width and height of the content (in scaled content coordinates).

ZoomAndPanControl Methods

ZoomAndPanControl contains a number of methods that perform animated and non-animated zooming and panning. Some of these methods have already been discussed and some others have not. This section has a summary of all such methods.

Note: The duration of the animation can be set by setting the value of the AnimationDuration property.

The Rectangle and Point parameters to all methods are specified in content coordinates.

Name Description
AnimatedSnapTo(Point contentPoint)
SnapTo(Point contentPoint) 
Snaps the position of the viewport so that it is centered on a particular point (without changing the content scale).
AnimatedZoomTo(double contentScale)
ZoomTo(double contentScale) 
Zooms to the specified content scale.
AnimatedZoomTo(Rect contentRect)
ZoomTo(Rect contentRect) 
Zooms in or out so that the specified rectangle fits the viewport.
AnimatedZoomTo(double newScale, Rect contentRect) 
This is a special version of AnimatedZoomTo that specifies the content rectangle to zoom to and in addition specifies what the final content scale for when the zoom animation has completed. This is used to jump back to the previous zoom level where we already know the exact content scale to return to. Specifying the content scale exactly removes the possibility of rounding errors creeping in as the content offset is updated during the zoom animation.
AnimatedZoomAboutPoint
(double newContentScale, Point contentZoomFocus)
ZoomAboutPoint
(double newContentScale, Point contentZoomFocus) 
Zooms to the specified content scale. The content zoom focus point is kept locked to its current position in the viewport. This method is used to implement Google-maps style zooming.
AnimatedScaleToFit()
ScaleToFit() 
Scales the content so that it fits entirely in the viewport.

Part 2 - ZoomAndPanControl Internals

This part of the article looks at how the ZoomAndPanControl is implemented.

The main class ZoomAndPanControl is defined in ZoomAndPanControl.cs and derives from ContentControl:

public class ZoomAndPanControl : ContentControl, IScrollInfo
{
    // ...
}

As you can see, ZoomAndPanControl also implements IScrollInfo, but I won't mention that again until the end of part 2.

As a custom control, it can be restyled to customize or replace the default UI. The default visual template for ZoomAndPanControl is defined by the WPF Style that is found in ZoomAndPan\Themes\Generic.xaml. The XAML definition is simple and contains only one named part which is called PART_Content:

<Style
    TargetType="{x:Type local:ZoomAndPanControl}"
    >
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type local:ZoomAndPanControl}">
                <Border
                    ...
                    >
                    <ContentPresenter x:Name="PART_Content"/>
                </Border>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

PART_Content is defined as a ContentPresenter. It is this UI element that displays (or presents) the content. Its RenderTransform is used to scale and translate the content.

As with all custom controls, ZoomAndPanControl is associated with its Style via a call to OverrideMetadata in the static class constructor:

public class ZoomAndPanControl : ContentControl, IScrollInfo
{
    ...

    static ZoomAndPanControl()
    {
        DefaultStyleKeyProperty.OverrideMetadata(typeof(ZoomAndPanControl), 
		new FrameworkPropertyMetadata(typeof(ZoomAndPanControl)));
    }

    ...
}

When developing a custom control, remember to add the following to Assembly.cs:

[assembly: ThemeInfo(
    ResourceDictionaryLocation.None,
    ResourceDictionaryLocation.SourceAssembly
)]

This is required for Generic.xaml to be located when the visual template is applied to the control. I often forget to add this when creating a new custom control!

The OnApplyTemplate method in ZoomAndPanControl is called when the visual template has been applied to the control. Here PART_Content is retrieved and cached for later use:

public override void OnApplyTemplate()
{
    base.OnApplyTemplate();

    content = this.Template.FindName("PART_Content", this) as FrameworkElement;

    ...
}

Note that PART_Content is only referenced as a FrameworkElement. We don't actually need to use any ContentPresenter properties or methods so the base class is used to reference the content.

WPF 2D transformations are used to scale and translate content. A ScaleTransform and TranslateTransform are instanced and added to a TransformGroup. This is then assigned to RenderTransform:

public override void OnApplyTemplate()
{
    base.OnApplyTemplate();

    content = this.Template.FindName("PART_Content", this) as FrameworkElement;
    if (content != null)
    {
        this.contentScaleTransform = 
		new ScaleTransform(this.ContentScale, this.ContentScale);
        this.contentOffsetTransform = new TranslateTransform();
        UpdateTranslationX();
        UpdateTranslationY();

        TransformGroup transformGroup = new TransformGroup();
        transformGroup.Children.Add(this.contentOffsetTransform);
        transformGroup.Children.Add(this.contentScaleTransform);
        content.RenderTransform = transformGroup;
    }
}

These transforms are kept synchronized with the current values of ContentScale, ContentOffsetX and ContentOffsetY. Whenever the values of these properties are changed, the 'property changed' event handlers execute code that updates the cached transforms. For example, ContentOffsetX_PropertyChanged calls UpdateTranslationX which updates the X coordinate of contentOffsetTransform based on the current value of ContentOffsetX:

private static void ContentOffsetX_PropertyChanged
		(DependencyObject o, DependencyPropertyChangedEventArgs e)
{
    ZoomAndPanControl c = (ZoomAndPanControl)o;

    c.UpdateTranslationX();

    ...
}

UpdateTranslationX actually recalculates contentOffsetTransform.X in one of two ways. When the content fits completely within the viewport, in this case in the horizontal axis, then the X translation is calculated so that the content is centered within the viewport. Otherwise, when the content doesn't fit within the viewport, the X translation is calculated simply from ContentOffsetX:

private void UpdateTranslationX()
{
    if (this.contentOffsetTransform != null)
    {
        double scaledContentWidth = this.unScaledExtent.Width * this.ContentScale;
        if (scaledContentWidth < this.ViewportWidth)
        {
            //
            // 1st case: When the content can fit entirely within the viewport, 
            // center it.
            //
            this.contentOffsetTransform.X = -this.ContentOffsetX + 
		((this.ContentViewportWidth - this.unScaledExtent.Width) / 2);
        }
        else
        {
            //
            // 2nd case: When the content doesn't fit within the viewport.
            //
            this.contentOffsetTransform.X = -this.ContentOffsetX;
        }
    }
}

The code for ContentOffsetY_PropertyChanged is similar. ContentScale_PropertyChanged, on the other hand, does a lot more work.

First it updates contentScaleTransform from ContentScale:

private static void ContentScale_PropertyChanged
	(DependencyObject o, DependencyPropertyChangedEventArgs e)
{
    ZoomAndPanControl c = (ZoomAndPanControl)o;

    if (c.contentScaleTransform != null)
    {
        c.contentScaleTransform.ScaleX = c.ContentScale;
        c.contentScaleTransform.ScaleY = c.ContentScale;
    }

    ...
}

Then it calls UpdateContentViewportSize:

private static void ContentScale_PropertyChanged
	(DependencyObject o, DependencyPropertyChangedEventArgs e)
{
    ZoomAndPanControl c = (ZoomAndPanControl)o;

    if (c.contentScaleTransform != null)
    {
        c.contentScaleTransform.ScaleX = c.ContentScale;
        c.contentScaleTransform.ScaleY = c.ContentScale;
    }

    c.UpdateContentViewportSize();

    ...
}

UpdateContentViewportSize first calculates the size of the viewport in content coordinates:

private void UpdateContentViewportSize()
{
    ContentViewportWidth = ViewportWidth / ContentScale;
    ContentViewportHeight = ViewportHeight / ContentScale;

    ...
}

UpdateContentViewportSize then calculates and caches some values that represent the 'constrained content viewport size'. These are set to the size of the viewport in content coordinates, but they actually max out at the size of the content:

private void UpdateContentViewportSize()
{
    ContentViewportWidth = ViewportWidth / ContentScale;
    ContentViewportHeight = ViewportHeight / ContentScale;

    constrainedContentViewportWidth = 
		Math.Min(ContentViewportWidth, unScaledExtent.Width);
    constrainedContentViewportHeight = 
		Math.Min(ContentViewportHeight, unScaledExtent.Height);

    ...
}

The 'coerce' callbacks for ContentOffsetX and ContentOffsetY use the cached 'constrained content viewport size' to keep the values of these properties with in the valid range of viewable area in the content. For example, ContentOffsetX_Coerce looks like this:

private static object ContentOffsetX_Coerce(DependencyObject d, object baseValue)
{
    ZoomAndPanControl c = (ZoomAndPanControl)d;
    double value = (double)baseValue;
    double minOffsetX = 0.0;
    double maxOffsetX = Math.Max(0.0, c.unScaledExtent.Width -
        c.constrainedContentViewportWidth);
    value = Math.Min(Math.Max(value, minOffsetX), maxOffsetX);
    return value;
}

The last two lines of code in UpdateContentViewportSize update contentOffsetTransform. When the content fits within the viewport, this causes the content to be centered within the viewport as the viewport changes size.

private void UpdateContentViewportSize()
{
    ContentViewportWidth = ViewportWidth / ContentScale;
    ContentViewportHeight = ViewportHeight / ContentScale;

    constrainedContentViewportWidth = 
		Math.Min(ContentViewportWidth, unScaledExtent.Width);
    constrainedContentViewportHeight = 
		Math.Min(ContentViewportHeight, unScaledExtent.Height);
		
    UpdateTranslationX();    
        UpdateTranslationY();
    }

Now, finally back in ContentScale_PropertyChanged the content offset is conditionally recalculated. enableContentOffsetUpdateFromScale is only set to true when a zoom animation is in progress such as zooming about a point (Google-maps style zooming) or zooming to a particular rectangle that the user has dragged out. The calculation involves the viewport zoom focus and the content zoom focus. When enabled this code keeps the two focus points locked together:

private static void ContentScale_PropertyChanged
		(DependencyObject o, DependencyPropertyChangedEventArgs e)
{
    ZoomAndPanControl c = (ZoomAndPanControl)o;

    if (c.contentScaleTransform != null)
    {
        c.contentScaleTransform.ScaleX = c.ContentScale;
        c.contentScaleTransform.ScaleY = c.ContentScale;
    }

    c.UpdateContentViewportSize();

    if (c.enableContentOffsetUpdateFromScale)
    {
        ...

        double viewportOffsetX = c.ViewportZoomFocusX - (c.ViewportWidth / 2);
        double viewportOffsetY = c.ViewportZoomFocusY - (c.ViewportHeight / 2);
        double contentOffsetX = viewportOffsetX / c.ContentScale;
        double contentOffsetY = viewportOffsetY / c.ContentScale;
        c.ContentOffsetX = (c.ContentZoomFocusX - 
		(c.ContentViewportWidth / 2)) - contentOffsetX;
        c.ContentOffsetY = (c.ContentZoomFocusY - 
		(c.ContentViewportHeight / 2)) - contentOffsetY;

        ...
    }

    ...
}

The drag-zooming feature in the advanced and data-binding samples uses the AnimatedZoomTo method. We will now look at this method to investigate how animated zooming is implemented.

First let's look at how AnimatedZoomTo is called in MainWindow.xaml.cs:

private void ApplyDragZoomRect()
{
    ...
    
    double contentX = ...
    double contentY = ...
    double contentWidth = ...
    double contentHeight = ...
    zoomAndPanControl.AnimatedZoomTo(new Rect
		(contentX, contentY, contentWidth, contentHeight));

        ...
}

AnimatedZoomTo is passed a rectangle that specifies the area of the content to zoom to. An animation is run that results in the content scale and content offset changing so that the rectangle fits within the viewport.

Internally AnimatedZoomTo calculates a new content scale that is derived from the passed rectangle. This is followed by a call to the internal helper method AnimatedZoomPointToViewportCenter. This method is passed the new content scale and the center of the rectangle, which is the location in the content that the zoom will focus on:

public void AnimatedZoomTo(Rect contentRect)
{
    double scaleX = this.ContentViewportWidth / contentRect.Width;
    double scaleY = this.ContentViewportHeight / contentRect.Height;
    double newScale = this.ContentScale * Math.Min(scaleX, scaleY);

    AnimatedZoomPointToViewportCenter(newScale, 
	new Point(contentRect.X + (contentRect.Width / 2), contentRect.Y + 
	(contentRect.Height / 2)), null);
}

AnimatedZoomPointToViewportCenter starts by canceling any current animations. This is achieved by calling CancelAnimation for each dependency property that might already have animation in progress:

private void AnimatedZoomPointToViewportCenter
	(double newContentScale, Point contentZoomFocus, EventHandler callback)
{
    ...

    AnimationHelper.CancelAnimation(this, ContentZoomFocusXProperty);
    AnimationHelper.CancelAnimation(this, ContentZoomFocusYProperty);
    AnimationHelper.CancelAnimation(this, ViewportZoomFocusXProperty);
    AnimationHelper.CancelAnimation(this, ViewportZoomFocusYProperty);

    ...
}

CancelAnimation is method from my AnimationHelper class. This class contains a few simple wrappers that make the WPF animation system a bit easier to use.

Next the zoom focus points are determined. The point that specifies the content zoom focus is passed in as an argument. The viewport zoom focus, however, is calculated by transforming the content zoom focus into viewport coordinates:

private void AnimatedZoomPointToViewportCenter
	(double newContentScale, Point contentZoomFocus, EventHandler callback)
{
    ...

    AnimationHelper.CancelAnimation(this, ContentZoomFocusXProperty);
    AnimationHelper.CancelAnimation(this, ContentZoomFocusYProperty);
    AnimationHelper.CancelAnimation(this, ViewportZoomFocusXProperty);
    AnimationHelper.CancelAnimation(this, ViewportZoomFocusYProperty);

    ContentZoomFocusX = contentZoomFocus.X;
    ContentZoomFocusY = contentZoomFocus.Y;
    ViewportZoomFocusX = (ContentZoomFocusX - ContentOffsetX) * ContentScale;
    ViewportZoomFocusY = (ContentZoomFocusY - ContentOffsetY) * ContentScale;

    ...
}

Lastly the dependency property animations that perform the zoom are started. This is achieved by calling StartAnimation. enableContentOffsetUpdateFromScale is set to true whilst the animation is in progress so that the code to lock the focus points in ContentScale_PropertyChanged is enabled. The anonymous function that is passed to StartAnimation is called when the animation has completed and it resets enableContentOffsetUpdateFromScale to false:

private void AnimatedZoomPointToViewportCenter
	(double newContentScale, Point contentZoomFocus, EventHandler callback)
{
    ...

    AnimationHelper.CancelAnimation(this, ContentZoomFocusXProperty);
    AnimationHelper.CancelAnimation(this, ContentZoomFocusYProperty);
    AnimationHelper.CancelAnimation(this, ViewportZoomFocusXProperty);
    AnimationHelper.CancelAnimation(this, ViewportZoomFocusYProperty);

    ContentZoomFocusX = contentZoomFocus.X;
    ContentZoomFocusY = contentZoomFocus.Y;
    ViewportZoomFocusX = (ContentZoomFocusX - ContentOffsetX) * ContentScale;
    ViewportZoomFocusY = (ContentZoomFocusY - ContentOffsetY) * ContentScale;

    enableContentOffsetUpdateFromScale = true;

    AnimationHelper.StartAnimation(this, ContentScaleProperty, 
			newContentScale, AnimationDuration,
            delegate(object sender, EventArgs e)
            {
                enableContentOffsetUpdateFromScale = false;

                if (callback != null)
                {
                    callback(this, EventArgs.Empty);
                }
            });

    AnimationHelper.StartAnimation(this, ViewportZoomFocusXProperty, 
			ViewportWidth / 2, AnimationDuration);
    AnimationHelper.StartAnimation(this, ViewportZoomFocusYProperty, 
			ViewportHeight / 2, AnimationDuration);
}

This may seem like a round-about and unintuitive way to implement animated zooming, I will try to explain my thinking. From reading the code, you can see that ContentScale is animated from its present value to the new value. It is probably not obvious why the viewport zoom focus is being animated. ViewportZoomFocusX and ViewportZoomFocusY track the current point in the viewport that is the focus of zooming. Usually this is set to the center of the viewport and this means that when we hit the plus or minus buttons we zoom in or out focused on the center of the viewport. However when using Google-maps style zooming, the viewport zoom focus is set to the location of the mouse cursor. As already discussed, the code in ContentScale_PropertyChanged keeps the viewport zoom focus locked to the content zoom focus while zooming is in progress, and this is how the Google-maps style zooming works.

As it turns out, the zoom focus locking also makes for a good implementation of drag-zooming. The viewport zoom focus is animated from its present value to the center of the viewport. Because viewport zoom focus and content zoom focus are locked together, this animation has the effect of shifting the content zoom focus. As ContentScale_PropertyChanged calculates the content offset from the content zoom focus, then this has the effect of panning the content viewport while the content scale is changing. I experimented with multiple ways of implementing the animation to zoom to the rectangle, but this implementation fits in nicely with the google-maps style zooming and results in smoother animated zooming.

The very last thing is to mention IScrollInfo. This interface allows a control that is embedded within a ScrollViewer to communicate with that ScrollViewer. It is how the ScrollViewer determines the position and range of the scrollbars. My implementation of the IScrollInfo methods can be found in ZoomAndPanControl_IScrollInfo.cs. This file contains a partial implementation of ZoomAndPanControl that contains only those methods and properties required by IScrollInfo. To understand how IScrollInfo works, I'll refer you to the following articles that are already out there: WPF Tutorial - Implementing IScrollInfo and IScrollInfo in Avalon part I, part II, part III and part IV.

Conclusion

This example has explained a reusable WPF custom control that does zooming and panning of generic content. In doing so, we have touched on a number of non-trivial areas in WPF such as animation, 2D transformation, custom controls and the implementation of the IScrollInfo interface. It took a lot of time to develop the ideas and code presented in this article and I hope it will be of use to others.

As I mentioned in the beginning, I haven't tried to implement any kind of UI virtualisation. Maybe this will be the topic of a future article. I welcome feedback and improvements to the code. Thanks for reading the article.

Updates

  • 08/06/2010 
    • Based on feedback from Paul Selormey, I modified the code to constrain the content offset to within the viewable area of the content. The article has been updated accordingly.
  • 09/06/2010 
    • Based on more feedback from Paul Selormey, I have added the property IsMouseWheelScrollingEnabled to ZoomAndPanControl. Setting this property to true enables the control to pan the viewport in response to mouse wheel input. This works in much the same way as mouse wheel input for a standard ScrollViewer. I also added a list of ZoomAndPanControl properties to accompany the existing list of methods.
  • 18/06/2010
    • Setting the value of ContentScale used to result in ContentOffsetX and ContentOffsetY being automatically updated. It no longer works this way. I discovered a bad effect that can happen when you bind all three dependency properties to a data-source and then want to update all three of them at once by changing the data-source. The binding to ContentScale updates it, which in turn wrongly updates ContentOffsetX and ContentOffsetY in your data-source! This doesn't happen anymore and the automatic update of ContentOffsetX and ContentOffsetY is now limited to only where it is needed and that is while the animations for Google-maps style zooming and drag zooming are in progress.
  • 22/06/2010 
    • When the viewport is resized (due to the window resizing), ContentOffsetX and ContentOffsetY are now constrained to the valid range. This is a fix to an issue reported by Patrick Walz. When the scrollbars are at the bottom or right extents and you resize the associated edge of the window (eg bottom or right side of the window) the content offset is now clamped at the edge of the content, rather than allowing the area beyond the content to be displayed.
  • 29/06/2010
    • Fixed an issue with centering the content when the viewport is larger than the content. This was working ok when the content was scaled down, but when the content was unscaled and the window was maximized so that the viewport was larger than the content the centering wasn't working correctly. I had to add explicit alignment to the ContentPresenter declared in Generic.xaml and modified the calculation that determines the centered content offset.
  • 19/11/2010
    • Added function SnapContentOffsetTo. This makes the ZoomAndPanControl snap ContentOffsetX and Y to the specified point.
    • Fixed the MakeVisible function (that implements IScrollInfo). This function now works as you would expect.
    • Fixed an issue reported by tmsife and Member 7483521. Setting the size of the content from code behind now works as expected.
  • 09/12/2010
    • A new sample project has been added that demonstrates the use of ZoomAndPanControl to create the concept of an infinite workspace. A small section has been added near the start of part 1 that describes this new sample project.
  • 21/03/2011
    • Fixed an issue in the Advanced Sample that was reported by skybluecodeflier. The size of the ZoomAndPanControl content wasn't being set and so panning and scrolling wasn't working.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here