Friday 23 October 2009

ZoomBoxPanel, add custom commands to a WPF control: Part 2

Introduction

This is the second 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.

The previous article, 'ZoomBoxPanel implementing a choice of page view zoom modes in WPF: Part 1', introduced the core functionality of the ZoomBoxPanel, its ability to resize its contents into a number of different zoom modes, such as 'Fit Page' and 'Fit Width'.

This article extends ZoomBoxPanel to support both standard and custom commands to zoom and rotate the contents.

Commands are a part of the WPF framework and in basic terms they are methods of a class. This being WPF, commands take a lot of complex code to define them, but they do bring several benefits. The most important is that commands can be used in XAML, so it is not necessary to write any code to invoke a method. So it is another case of more code in the control itself to make life easier for the user of the control.

The aim is to produce a self contained and robust custom control that can be used in a variety of situations. This  requires duplicating some functionality to provide more than one way of doing things and doing some things in a more complex manner in order to hide the implementation from the outside world.

Using ZoomBoxPanel Commands

The screenshot shows the main window of the attached sample application.
The window contains a grid with three rows, containing a menu, toolbar and a ZoomBoxPanel.

<zb:ZoomBoxPanel ZoomMode="FitPage" x:Name="zoomBox" Grid.Row="2" MinZoom="20" MaxZoom="300">
    <Border BorderThickness="3" BorderBrush="Black">
        <Image Height="525" Name="image1" Stretch="Uniform" Width="700" Source="/ZoomBox2;component/Images/marinbikeride400.jpg" />
    </Border>
</zb:ZoomBoxPanel> 


The  ZoomBoxPanel contains an image inside a border.  The definition contains restrictions on the range of zoom values allowed; 20% : 300%.



<ComboBox HorizontalAlignment="Left" Margin="1" Name="comboBoxZoomMode" Width="120"
          ItemsSource="{Binding Source={StaticResource zoomModeListConverter}}"
          SelectedIndex="{Binding ElementName=zoomBox, Path=ZoomMode,
              Mode=TwoWay, Converter={StaticResource zoomModeListConverter}}"  /> 


The Zoom Mode is displayed in a combobox, the technique for doing this is described in articles 'WPF Enum List Converter' and 'ZoomBoxPanel implementing a choice of page view zoom modes in WPF: Part 1'.


Commands are invoked from either the buttons on the toolbar or from the menu.



<Button Command="IncreaseZoom" CommandTarget="{Binding ElementName=zoomBox}" HorizontalAlignment="Left" Margin="1" Width="60">Zoom In</Button>
<Button Command="DecreaseZoom" CommandTarget="{Binding ElementName=zoomBox}" HorizontalAlignment="Left" Margin="1" Width="60">Zoom Out</Button> 


The 'Zoom In' and 'Zoom Out' toolbar buttons invoke the commands  IncreaseZoom and  DecreaseZoom. These are part of the standard commands defined in WPF and so do not need to be qualified with a name space.

A button can act as a command source as it implements the interface IcommandSource.  The Command property is set to the command itself and the CommandTarget is bound to the object that will be sent the command.


As well as sending the command to the ZoomBoxPanel the binding also works by querying the state of the  ZoomBoxPanel to see if it can execute the command and if not the button will be automatically disabled.


In the example below the image has been zoomed out to 20% the minimum permitted by the  ZoomBoxPanel's MinZoom property, so the Zoom Out button is disabled.





<MenuItem Header="Zoom">
    <MenuItem Command="IncreaseZoom" CommandTarget="{Binding ElementName=zoomBox}"  />
    <MenuItem Command="DecreaseZoom" CommandTarget="{Binding ElementName=zoomBox}" /> 


The Zoom menu contains items that also make use of the IcommandSource interface in the same way as the buttons. Unlike the buttons the MenuItem automatically uses the command description is no Header is specified.



To illustrate an alternative more old fashioned way of invoking the same functionality the following menu items are defined.



    <MenuItem Name="menuitemZoomInMethod" Header="Zoom In Method" IsEnabled="{Binding ElementName=zoomBox, Path=CanIncreaseZoom}" Click="menuitemZoomInMethod_Click" />
    <MenuItem Name="menuitemZoomOutMethod" Header="Zoom Out Method" IsEnabled="{Binding ElementName=zoomBox, Path=CanDecreaseZoom}" Click="menuitemZoomOutMethod_Click" />
</MenuItem> 


These call methods defined in the Window code behind class which use the methods of the ZoomBoxPanel class. To ensure the menu options are disabled when the command is not available the MenuItems IsEnabled property is bound to the matching 'Can' dependency property of the ZoomBoxPanel.



  private void menuitemZoomInMethod_Click(object sender, RoutedEventArgs e)
  {
      zoomBox.IncreaseZoom();
  }
  private void menuitemZoomOutMethod_Click(object sender, RoutedEventArgs e)
  {
      zoomBox.DecreaseZoom();
  } 


The IncreaseZoom() and DecreaseZoom() are conventional method calls, separate from the command handling.



Rotation



      <Button Command="zb:ZoomBoxPanel.RotateClockwise" CommandTarget="{Binding ElementName=zoomBox}" HorizontalAlignment="Left" Margin="1"  Width="60">Clockwise</Button>
      <Button Command="zb:ZoomBoxPanel.RotateCounterclockwise" CommandTarget="{Binding ElementName=zoomBox}" HorizontalAlignment="Left" Margin="1"  Width="95">Counterclockwise</Button> 


There are no standard rotation commands and so custom commands have been defined by the ZoomBoxPanel. Other than specifying the definition space these work in the same way as standard commands.



 





<MenuItem Header="Rotate">
    <MenuItem Header="Clockwise" Command="zb:ZoomBoxPanel.RotateClockwise" CommandTarget="{Binding ElementName=zoomBox}" />
    <MenuItem Header="Clockwise 90 degrees" Command="zb:ZoomBoxPanel.RotateClockwise" CommandParameter="90"  CommandTarget="{Binding ElementName=zoomBox}" />
    <MenuItem Header="Counterclockwise" Command="zb:ZoomBoxPanel.RotateCounterclockwise"  CommandTarget="{Binding ElementName=zoomBox}"  />
    <MenuItem Header="Counterclockwise 90 degrees" Command="zb:ZoomBoxPanel.RotateCounterclockwise" CommandParameter="90"  CommandTarget="{Binding ElementName=zoomBox}"  />
    <MenuItem Header="Upright"      Command="zb:ZoomBoxPanel.RotateHome"  CommandTarget="{Binding ElementName=zoomBox}" />
    <MenuItem Header="Upside down"  Command="zb:ZoomBoxPanel.RotateReverse"  CommandTarget="{Binding ElementName=zoomBox}" />
</MenuItem> 


The rotate menu contains more options than found on the toolbar. By default the RotateClockwise command rotates the contents by 15 degrees. By specifying a n amount in the CommandParameter property the second MenuItem is able to produce a 90 degree rotation. This shows how CommandParameters can add significant flexibility to the Command mechanism.



The  ZoomBoxPanel Code






The control contains over 700 lines of C# code, only some of which is described below. It is all there in the attached source code though.



To keep the article a manageable size only the implementation of a single command is discussed. The attached source code contains all the commands and demonstrates issues like command parameters, which are not discussed here.



ZoomBoxPanel  implements zooming commands in exactly the same way as the standard WPF control, FlowDocumentPageView.



The IncreaseZoom functionality requires the support of two properties, which are defined as public dependency properties of ZoomBoxPanel.



private static DependencyProperty ZoomIncrementProperty;
public double ZoomIncrement
{
    set { SetValue(ZoomIncrementProperty, value); }
    get { return (double)GetValue(ZoomIncrementProperty); }
} 
static ZoomBoxPanel()
{
    ZoomIncrementProperty = DependencyProperty.Register(
        "ZoomIncrement", typeof(double), typeof(ZoomBoxPanel),
        new FrameworkPropertyMetadata(20.0),
        new ValidateValueCallback(ZoomBoxPanel.ValidateIsPositiveNonZero)); 


The ZoomIncrement is the percentage by which the Zoom is increased when the IncreaseZoom command is executed.

The property is given a initial value of 20 and given a validation method that ensures any value assigned is greater than zero.



private static DependencyPropertyKey CanIncreaseZoomPropertyKey; 
public bool CanIncreaseZoom
{
    get { return (bool)GetValue(CanIncreaseZoomPropertyKey.DependencyProperty); }
} 
static ZoomBoxPanel()
{
   CanIncreaseZoomPropertyKey = DependencyProperty.RegisterReadOnly(
        "CanIncreaseZoom", typeof(bool), typeof(ZoomBoxPanel),
         new FrameworkPropertyMetadata(false, null, null)); 


CanIncreaseZoom indicates whether the Zoom level can be increased.

It is a read only dependency property which requires a different class type and a call to RegisterReadOnly(), instead of Register().


Read only properties are rare in WPF as the aim is to enable the user to be able to adjust as much as possible.  In this case the value logically depends upon the values of Zoom and MaxZoom, so we do not want CanIncreaseZoom to be changed outside of the class.



The command, along with the other registrations is declared in the static or class constructor.



static ZoomBoxPanel()
{
    CommandManager.RegisterClassCommandBinding(
      typeof(ZoomBoxPanel),
      new CommandBinding(NavigationCommands.IncreaseZoom,
        ExecutedEventHandler_IncreaseZoom, CanExecuteEventHandler_IfCanIncreaseZoom)); 


One command binding is defined for the entire class, which links the command to the class and specifies two call back functions; one to execute the command and the other to test is the command is enabled.

This is more complex than the usual way of declaring instance specific bindings in the instance constructor.  However doing it that way adds binding to the public CommandBindings collection, which can be freely edited outside the class. Using class bindings limits the access and therefore makes control more robust. This is the way the standard WPF controls are implemented.



private static void CanExecuteEventHandler_IfCanIncreaseZoom(Object sender, CanExecuteRoutedEventArgs e)
{
    ZoomBoxPanel z = sender as ZoomBoxPanel;
    e.CanExecute = ((z != null) && (z.CanIncreaseZoom));
    e.Handled = true;
} 


The CanExecute callback has to be a static method as it is used in the static constructor.  The sender is converted into the instance that we believe it to be and the CanIncreaseZoom property is used to determine the return value.



In order to ensure CanIncreaseZoom  is accurate we need to update it at the right time. The following method is called when the Zoom is changed.



private void process_PropertyChanged_Zoom(DependencyPropertyChangedEventArgs e)
{
    bool canIncrease = (Zoom < MaxZoom);
    bool canDecrease = (Zoom > MinZoom); 
    if (canIncrease != CanIncreaseZoom)
        this.SetValue(ZoomBoxPanel.CanIncreaseZoomPropertyKey, canIncrease);
    if (canDecrease != CanDecreaseZoom)
        this.SetValue(ZoomBoxPanel.CanDecreaseZoomPropertyKey, canDecrease);
} 


We cannot use the convention property accessor to set the value of  CanIncreaseZoom as it is defined as a read only property.. Instead we call SetValue on the property key. This is private to the class ensuring it can only be altered here.



You will have noticed that this is a complex way of implementing this.  The CanIncreaseZoom property is not needed for the command callback at all.

The following would work and have the advantage of not having to be updated whenever the Zoom value changes.



e.CanExecute = ((z != null) && (Zoom < MaxZoom)); 


The real reason for implementing a  CanIncreaseZoom property is to support a different non-command way of doing things. An example of this is shown in the 'Zoom In Method' MenuItem defined earlier.

So more complexity in the control's code give more flexibility to the user.



The callback to execute the IncreaseZoom command casts the sender into an instance of the class and calls the private method to do the work.



public static void ExecutedEventHandler_IncreaseZoom(Object sender, ExecutedRoutedEventArgs e)
{
    ZoomBoxPanel z = sender as ZoomBoxPanel;
    if (z != null) z.process_ZoomCommand(true);
} 


It is possible to execute a command in code, but it is long winded. So the following public method is declared, as another means of using the functionality.



public void IncreaseZoom()
{
    process_ZoomCommand(true);
} 


The work of implementing the command is split between four methods, the this functionality is reused in different parts of the code.



private void process_ZoomCommand(bool increase)
{
    Point panelPoint = new Point(ActualWidth / 2, ActualHeight / 2); 
    double delta = (1 + (ZoomIncrement / 100));
    if (!increase)
        delta = 1 / delta; 
    ApplyZoomCommand(delta, 1, panelPoint);
} 


The  ZoomIncrement increment is converted from a percentage into a number that can be multiplied.  The increase parameter allows the ZoomDecrease command to be implemented by the same function.

Zooming or a scale transformation works by increasing the distance of coordinated from a fixed point, the center of the zoom. In the case of the IncreaseZoom this is deemed to be the center of the control. In other cases it may be different such as the point under the mouse.



protected void ApplyZoomCommand(double delta, int factor, Point panelPoint)
{
    if (factor > 0)
    {
        double zoom = ZoomFactor; 
        for (int i = 1; i <= factor; i++)
            zoom = zoom * delta; 
        ZoomFactor = zoom; 
        ApplyZoomCommand(panelPoint);
    }
} 


This method simply multiplies the zoom factor by the given delta. In our case we only want to do this once. But other parts of the code may want to execute multiple zoom increases as one time.



protected void ApplyZoomCommand(Point panelPoint)
{
    Point logicalPoint = transformGroup.Inverse.Transform(panelPoint); 
    ZoomMode = eZoomMode.CustomSize; 
    panX = -1 * (logicalPoint.X * ZoomFactor - panelPoint.X);
    panY = -1 * (logicalPoint.Y * ZoomFactor - panelPoint.Y); 
    ApplyZoom(true);
} 


Whenever the Zoom is changed by a command the ZoomMode such as 'Fit Page' is no longer hold true. So we always change the mode to custom size.

The center point of the control is given in screen coordinates and these are different from the logical coordinates of the contents. This first line converts the center point into the logical coordinates and these are then used to calculate the new transformation offset.



protected void ApplyZoom(bool animate)
{
    rotateTransform.Angle = rotateAngle;
    rotateTransform.CenterX = rotateCenterX;
    rotateTransform.CenterY = rotateCenterY; 
    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.



Conclusion






This article tried to show the extra complexity introduced when developing a robust custom control.



Download





Exe file


Full Source code - VS2008

Tuesday 20 October 2009

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

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 to the later articles for more.

Requirements

This control is extracted from a real project that had 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.



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



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 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 call back 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 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 rage 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 beed 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 XoomMode is set to the correct enum. All of wihch 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 if that 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 of 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 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 tranforming 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 fortunetly 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 repeatably 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 controls contents to be in the top left corner with each items 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 divide 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 to 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 bothe 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.



Download



Copyright (c) 2009 Tom Wright. All rights reserved.



This code is distributed under the Microsoft Public License (Ms-PL).



Exe file


Full Source code - VS2008