Friday, 23 October 2009

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


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

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

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)
  private void menuitemZoomOutMethod_Click(object sender, RoutedEventArgs e)

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


      <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}" />

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()
      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()

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; 

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); 

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.


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


Exe file

Full Source code - VS2008

No comments:

Post a Comment