Tuesday, 3 November 2009

ZoomBoxPanel, adding full mouse support and animation to a WPF custom control: Part 3

Introduction

This is the third 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 first 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 second article 'ZoomBoxPanel: add custom commands to a WPF control: Part 2' extends ZoomBoxPanel to support both standard and custom commands to zoom and rotate its contents.

This article extends ZoomBoxPanel to support the use of the mouse and the mouse wheel in different modes and in addition animation is added for zoom transitions.

 

The ZoomBoxPanel Mouse Support

The ZoomBoxPanel developed in the previous articles has functionality for zooming, rotating and positioning its content, which can be controlled by different commands.
When adding mouse support choices have to made made on what functionality to support as a mouse can perform only a limited number of basic actions.
As  ZoomBoxPanel is intended to be used in a variety of different situations, several different modes of mouse operation are supported.

In the demonstration window, the mouse modes have been added as a comboboxes on the toolbar and to the menu, allowing you to play with each mode.

The MouseMode specifies how the control reacts to a mouse click.

None Mouse clicks are ignored. Useful if you just want the contents of the control to handle the mouse
Pan Supports drag and drop of the mouse to move the content within the control.
Zoom Left click zooms in and a right click zooms out.

The WheelMode specifies how the mouse wheel is used.

None Mouse wheel is ignored.
Zoom Zoom in and out.
Scroll Pans the content up and down
Rotate Rotates the content clockwise and counterclockwise

 

The modes are dependency properties of the control and can be set at design time from the properties window.

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




Animation is not appropriate in every situation and can be annoying to some people. The boolean dependency property Animations switches animations on and off. It is linked to the Animations check box on the tool bar.



<CheckBox Margin="10,1" IsChecked="{Binding ElementName=zoomBox, Path=Animations}">Annimations</CheckBox>
<CheckBox Margin="10,1" IsChecked="{Binding ElementName=zoomBox, Path=LockContent}">Lock</CheckBox>


The LockContent property is a way of turning off the mouse support at certain times, for example when doing a print preview.



The  ZoomBoxPanel Code





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



The mouse and wheel modes are defined as enums within the control class



public enum eMouseMode
{
    None,
    Zoom,
    Pan
}
public enum eWheelMode
{
    None,
    Zoom,
    Scroll,
    Rotate
}


In order to support the new functionality five extra dependency properties need to be defined.



private static DependencyProperty MouseModeProperty;
private static DependencyProperty WheelModeProperty;


The mode properties are of the type of their matching enums. These are defined in the same way as the ZoomModeProperty defined in the previous article.



private static DependencyProperty WheelZoomDeltaProperty;


The WheelZoomDeltaProperty is the percentage by which the zoom should be increased or decreased on each movement of the mouse wheel.



private static DependencyProperty AnimationsProperty;
private static DependencyProperty LockContentProperty;


The final two boolean dependency properties control the animation and lock functionality.



In order to deal with the events generated by the mouse, the control defines seven different event handlers, which it adds to its own inherited mouse events.



protected override void OnInitialized(EventArgs e)
{
    base.OnInitialized(e);
    this.MouseWheel += process_MouseWheel;
    this.MouseDown += process_MouseDown;
    this.MouseUp += process_MouseUp;
    this.MouseMove += process_MouseMove;
    this.PreviewMouseDown += process_PreviewMouseDown;
    this.PreviewMouseUp += process_PreviewMouseUp;
    this.PreviewMouseMove += process_PreviewMouseMove;


The above code uses a simplification feature in the latest  compiler. This is the equivalent of doing defining a handler the old fashioned way.



this.MouseWheel += new MouseWheelEventHandler(process_MouseWheel);


The LockContent feature is implemented  by the following methods, which are use in demonstrating how the WPF event handling works.



void process_PreviewMouseDown(object sender, MouseButtonEventArgs e)
{
    if (LockContent)
        e.Handled = true;
}
void process_PreviewMouseUp(object sender, MouseButtonEventArgs e)
{
    if (LockContent)
        e.Handled = true;
}
void process_PreviewMouseMove(object sender, MouseEventArgs e)
{
    if (LockContent)
        e.Handled = true;
}


Mouse events are first sent to the control under the mouse. In the case of the demo this will be the image object. If that object does not handle the event then it is passed to its parent object, which then has the chance to process the event.

However before sending the event itself WPF sends a 'Preview' event with the same parameters as the actual event. The preview event is sent in the reverse order to the actual event, so it is first passed to the parent and the to the child.


So to implement the LockContent function we only need to set the Handled property to true and this will prevent the following mouse event being generated and so effectively turns off the mouse for both for this control  and its contents.



Mouse Clicks



Providing the LockContent property is set to false the first mouse event we are interested will be triggered.



void process_MouseDown(object sender, MouseButtonEventArgs e)
{
    switch (MouseMode)
    {
        case eMouseMode.Pan:
            if (e.ChangedButton == MouseButton.Left)
            {
                if (((Keyboard.GetKeyStates(Key.LeftAlt) & KeyStates.Down) == 0) && ((Keyboard.GetKeyStates(Key.RightAlt) & KeyStates.Down) == 0))
                {
                    prevCursor = this.Cursor;
                    startMouseCapturePanel = e.GetPosition(this);
                    this.CaptureMouse();
                    this.Cursor = Cursors.ScrollAll;
                }
            }
            break;
    }
}


The drag and drop operation of the Pan mouse mode is started only if Pan mode is enabled and it is the left mouse button that has been clicked and the Ctrl and Alt keys are not pressed.

The code saves the old cursor for the end of the operation and location in the control where the operation started. The CaptureMouse methods ensures all future mouse events are sent to this control even if they occur outside the control. Lastly the cursor is changed to give the user an indicator that the drag and drop operation has started.



Once the mouse has been captured it is a just matter of moving the contents every time the mouse is moved.



void process_MouseMove(object sender, MouseEventArgs e)
{
    Point dcurrentMousePanel = e.GetPosition(this);
    switch (MouseMode)
    {
        case eMouseMode.Pan:
            if (this.IsMouseCaptured)
            {
                Point currentMousePanel = e.GetPosition(this);
                double deltaX = currentMousePanel.X - startMouseCapturePanel.X;
                double deltaY = currentMousePanel.Y - startMouseCapturePanel.Y;
                if ((deltaX != 0) || (deltaY != 0))
                {
                    startMouseCapturePanel.X = currentMousePanel.X;
                    startMouseCapturePanel.Y = currentMousePanel.Y;
                    if (ApplyPanDelta(deltaX, deltaY))
                        ApplyZoom(false);
                }
            }
            break;
    }
}


The method calculates the amount the mouse has moved and then calls the ApplyPanDelta method to do the work. The start position is then updated so that the next mouse move event is relative to the current position.



private bool ApplyPanDelta(double deltaX, double deltaY)
{
    double x = panX + deltaX;
    double y = panY + deltaY;
    switch (ZoomMode)
    {
        case eZoomMode.CustomSize:
            break;
        case eZoomMode.ActualSize:
            break;
        case eZoomMode.FitWidth:
            // disable x move
            x = panX;
            break;
        case eZoomMode.FitHeight:
            // disable x move
            y = panY;
            break;
        case eZoomMode.FitPage:
            x = panX;
            y = panY;
            break;
        case eZoomMode.FitVisible:
            break;
    }
    if ((x != panX) || (y != panY))
    {
        panX = x;
        panY = y;
        return true;
    }
    return false;
}


The coordinates of the contents are held in the member variables panX and panY. So making the move is just a matter of adding the delta to the coordinates.

However in certain zoom modes movement is restricted in one or both directions.  For example in FitWidth mode the contents can be moved up or down but not side to side.



When the mouse button is released the drag and drop operation is ended and the cursor is restored to its previous shape.



void process_MouseUp(object sender, MouseButtonEventArgs e)
{
    switch (MouseMode)
    {
        case eMouseMode.Zoom:
            {
                Point panelPoint = e.GetPosition(this);
                int factor = 0;
                double delta = (1 + (ZoomIncrement / 100));
                switch (e.ChangedButton)
                {
                    case MouseButton.Left:
                        factor = 1;
                        break;
                    case MouseButton.Right:
                        factor = 1;
                        delta = 1 / delta;
                        break;
                }
                ApplyZoomCommand(delta, factor, panelPoint);
            }
            break;
    }
    if (IsMouseCaptured)
    {
        ReleaseMouseCapture();
        this.Cursor = prevCursor;
        prevCursor = null;
    }
}


When in Zoom mode each mouse click will result in the zoom being increased on a left, or decreased on a right click.

The  ApplyZoomCommand is described in the previous article.



Mouse Wheel






Handling mouse wheel events is not straightforward like mouse clicks and needs a fuller explanation.



A mouse wheel can be rotated in either direction and it contains notches restricting small changes in rotation, to about 15 degrees per notch.  The problem is that there is no standard to the values produced by a mouse wheel, it is all hardware specific. Different mice will return provide different delta values for the same physical movement by the same user.

So the most accurate way to handle a mouse wheel event is to write a hardware specific code, which is clearly not a viable option.


An alternative is to treat all mouse wheel events as the same, all equaling one notch. This is not too bad, as Windows fires mouse wheel events very quickly. However if the user moves the wheel quickly then only one event will be generated for multiple notches.



The solution I came up with calibrates the value of a notch as it receives mouse wheel events and is therefore not dependent on specific values. It has proved to reliable in practice.



private int minMouseDelta = 1000000;


A instance variable stores the lowest delta of the mouse, this is initially set extremely high.



void process_MouseWheel(object sender, MouseWheelEventArgs e)
{
    Point panelPoint = e.GetPosition(this);
    double delta = 0;
    int absDelta = Math.Abs(e.Delta);


The mouse wheel event contains a int delta value that indicates how much the wheel has been rotated. The sign of the value indicates the direction of the rotation but the value is hardware specific.

The first thing we do is to discard the sign so we only need to work with positive values.



    if ((minMouseDelta > absDelta) && (absDelta > 0))
        minMouseDelta = absDelta;


The delta is compared with the minimum delta so far observed and if it is smaller then the minimum value is updated. After several events, usually just one,  we can then be certain of the smallest delta change made by the wheel. This is treated as one notch.



    int factor = absDelta / minMouseDelta;
    if (factor < 1)
        factor = 1;


We can then calculate how many notches the current events delta contains. This is stored in the variable factor.

How we use this data is then dependent on the selected wheel mode.



    switch (WheelMode)
    {
        case eWheelMode.Rotate:
            {
                delta = RotateIncrement;
                if (e.Delta <= 0)
                    delta *= -1;
                ApplyRotateCommand(delta, factor, panelPoint);
            }
            break;
        case eWheelMode.Zoom:
            {
                delta = (1 + (WheelZoomDelta / 100));
                if ((e.Delta <= 0) && (delta != 0))
                    delta = 1 / delta;
                ApplyZoomCommand(delta, factor, panelPoint);
            }
            break;


The rotate and zoom command handling is described in full in the previous article.



        case eWheelMode.Scroll:
            {
                delta = scrollDelta;
                if (e.Delta <= 0)
                    delta *= -1;
                ApplyScrollCommand(delta, factor, panelPoint);
            }
            break;
    }
}


The scroll command moves the content up or down by the scrollDelta value. This is currently a private variable, but could benefit from being exposed as a dependency property.



protected void ApplyScrollCommand(double delta, int factor, Point panelPoint)
{
    if (factor > 0)
    {
        double deltaY = delta * factor;
        if (ApplyPanDelta(0, deltaY))
            ApplyZoom(true);
    }
}


The amount the content is panned is calculated by multiplying the number of notches by the scrollDelta amount. This value is passed to the ApplyPanDelta method used by the drag and drop operation, described earlier.



Animation



One of the great things about WPF is that it is built on the same technology that powers video games and make full use of a machine's graphics card. This makes it extremely fast and when zooming out the content of the ZoomBoxPanel, the visual effect is instantaneous. To make a smoother transition between zoom settings it is possible to use the animation support built into WPF.

Animation support has been added for both scale/zoom and for rotation transformations. I tried adding animation for pan operations but it was too annoying, so I turned it off.



The ApplyZoom method is called to apply the newly calculated values to the content. The method handles both animated and non-animated changes.



protected void ApplyZoom(bool animate)
{
    if ((!animate) || (!Animations))
    {
        translateTransform.BeginAnimation(TranslateTransform.XProperty, null);
        translateTransform.BeginAnimation(TranslateTransform.YProperty, null);
        zoomTransform.BeginAnimation(ScaleTransform.ScaleXProperty, null);
        zoomTransform.BeginAnimation(ScaleTransform.ScaleYProperty, null);
        rotateTransform.BeginAnimation(RotateTransform.AngleProperty, null);


The first half of the method sets the new values directly without any animation.

It is necessary, however, to first call BeginAnimation with a null value first, in order to delete any existing animation from a previous operation. An animation will over-ride a set value, even if the value is set after the animation.



        translateTransform.X = panX;
        translateTransform.Y = panY;
        zoomTransform.ScaleX = ZoomFactor;
        zoomTransform.ScaleY = ZoomFactor;
        rotateTransform.Angle = rotateAngle;
        rotateTransform.CenterX = rotateCenterX;
        rotateTransform.CenterY = rotateCenterY;
    }
    else
    {
        DoubleAnimation XPropertyAnimation = MakeZoomAnimation(panX);
        DoubleAnimation YPropertyAnimation = MakeZoomAnimation(panY);
        DoubleAnimation ScaleXPropertyAnimation = MakeZoomAnimation(ZoomFactor);
        DoubleAnimation ScaleYPropertyAnimation = MakeZoomAnimation(ZoomFactor);
        DoubleAnimation AngleAnimation = MakeRotateAnimation(rotateTransform.Angle, rotateAngle);


There are many different animation classes in WPF, but we only need the most common one DoubleAnimation. We create five of these objects for each of the transformation properties that we want to change.

How these are created are explained below.



        translateTransform.BeginAnimation(TranslateTransform.XProperty, XPropertyAnimation);
        translateTransform.BeginAnimation(TranslateTransform.YProperty, YPropertyAnimation);
        zoomTransform.BeginAnimation(ScaleTransform.ScaleXProperty, ScaleXPropertyAnimation);
        zoomTransform.BeginAnimation(ScaleTransform.ScaleYProperty, ScaleYPropertyAnimation);
        rotateTransform.CenterX = rotateCenterX;
        rotateTransform.CenterY = rotateCenterY;
        rotateTransform.BeginAnimation(RotateTransform.AngleProperty, AngleAnimation);
    }
}


Each animation is started asynchroniously using the BeginAnimation method of the transform. This does the same as assigning the new values directly as shown in the non animated version above.



private double animationDuration = 300;
protected DoubleAnimation MakeZoomAnimation(double value)
{
    DoubleAnimation ani = new DoubleAnimation(value, new Duration(TimeSpan.FromMilliseconds(animationDuration)));
    ani.FillBehavior = FillBehavior.HoldEnd;
    return ani;
}


Creating animations for the zoom and translate transforms is very straight forward. The DoubleAnimation constructor takes the final value and the time that the animation should take.

The duration is currently hard coded to 300 milliseconds and this is something that could be exposed as a dependency property.


It is important to set the FillBehavior property otherwise the animation will reset the property back to its original value after the animation is completed.



Making an animation for the rotation is more complicated because the DoubleAnimation will always move directly between the start and the end values. With rotation we are dealing in angles and there is a choice of which way to rotate.

For example if an object starts at 330° and ends at 30° the best way is to rotate it clockwise by 60°. A DoubleAnimation will rotate it counterclockwise by 300°. This looks crazy to the user.


An additional issue is that the speed of rotation should be constant. Thus it should take six time longer to rotate 90° that it should 15°.



protected DoubleAnimation MakeRotateAnimation(double from, double to)
{
    DoubleAnimation ani = new DoubleAnimation();
    ani.FillBehavior = FillBehavior.HoldEnd;
    ani.To = to;
    ani.From = calcStartFromAngle(from, to);
    ani.Duration = new Duration(
        TimeSpan.FromMilliseconds(animationDuration * calcAngleBetweenAngles(from, to) / 30));
    return ani;
}


In the rotation animation creation we specify the start position in the From property. It is not normally necessary to do that, but this is a special case.

The duration is calculated as the set time taken to rotate 30° multiplied by the angle rotated.



protected double calcStartFromAngle(double from, double to)
{
    if (from < to)
    {
        if (to - from > 180.0)
            return from + 360.0;
    }
    else
    {
        if (from - to > 180.0)
            return from - 360.0;
    }
    return from;
}


The trick to calculating the start angle is knowing that the rotate transform is happy to handle angles outside the 0°-360° range.

So for the example above the start position is 330°-360° = -30° to 30°. Which gives us a clockwise rotation of 60°.



protected double calcAngleBetweenAngles(double from, double to)
{
    double a = (from <= to) ? to - from : from - to;
    if (a > 180)
        a = 360 - a;
    return a;
}


In calculating the angle for the duration calculation we need the absolute value, which this calculation gives us.



Conclusion






This article covered two ways of adding to a cutom controls user experience; mouse support and animations.

As it stands now ZoomBoxPanel is a useful and complete control that could be used in many different projects.


The final article in this series will introduce an associated custom control that allows the user to manipulate the zoom level and zoom mode of this control.



Download

Exe file


Full Source code - VS2008

Mouse Wheel Events in Windows WPF

Handling mouse wheel events is not straightforward like mouse clicks and needs a fuller explanation.

A mouse wheel can be rotated in either direction and contain notches restricting small changes in rotation, to about 15 degrees per notch. The problem is that there is no standard to the values produced by a mouse wheel, it is all hardware specific. Different mice will return provide different delta values for the same physical movement by the same user.

So the most accurate way to handle a mouse wheel event is to write a hardware specific code, which is clearly not a viable option.

An alternative is to treat all mouse wheel events as the same, all equaling one notch. This is not too bad as Windows fires mouse wheel events very quickly. However if the user moves the wheel quickly then only one event will be generated for multiple notches.

The solution I came up with calibrates the value of a notch as it receives mouse wheel events and is therefore not dependent on specific values. It has proved to reliable in practice.

private int minMouseDelta = 1000000;
A instance variable stores the lowest delta of the mouse, this is initially set extremely high.
void process_MouseWheel(object sender, MouseWheelEventArgs e)
{
int absDelta = Math.Abs(e.Delta);


The mouse wheel event contains a int delta value that indicates how much the wheel has been rotated. The sign of the value indicates the direction of the rotation but the value is hardware specific.



The first thing we do is to discard the sign so we only need to work with possitive values.



if ((minMouseDelta > absDelta) && (absDelta > 0))
minMouseDelta = absDelta;


The delta is compared with the minimum delta so far observed and if it is smaller then the minimum value is updated. After several events, usually just one, we can then be certain of the smallest delta change made by the wheel. This is treated as one notch.



int factor = absDelta / minMouseDelta;
if (factor < 1)
    factor = 1;


We can then calculate how many notches the current events delta contains. This is stored in the variable factor.

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