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

1 comment: