Creating a scrollable and zoomable image viewer in C# Part 3

After part 2 added scrolling support, we are now going to extend this to support keyboard scrolling and panning with the mouse.

The ImageBox component, demonstrated in a sample application
The ImageBox component, demonstrated in a sample application

Design support

In order to enable panning, we're going to add three new properties. The AutoPan property will control if the user can click and drag the image with the mouse in order to scroll. Also, we'll add an InvertMouse property to control how the scrolling works. Finally the IsPanning property; however it can only be read publicly, not set.

As well as the backing events for the above properties, we'll also add extra events - PanStart and PanEnd The normal Scroll event will be utilized while panning is in progress rather than a custom event.

Mouse Panning

To pan with the mouse, the user needs to "grab" the control by clicking and holding down the left mouse button. As they move the mouse, the control should automatically scroll in the opposite direction the mouse is moving (or if InvertMouse is set, in the same direction). Once the button is released, scrolling should stop.

We'll implement this by overriding OnMouseMove and OnMouseUp, shown below.

csharp
protected override void OnMouseMove(MouseEventArgs e)
{
  base.OnMouseMove(e);

  if (e.Button == MouseButtons.Left && this.AutoPan && this.Image != null)
  {
    if (!this.IsPanning)
    {
      _startMousePosition = e.Location;
      this.IsPanning = true;
    }

    if (this.IsPanning)
    {
      int x;
      int y;
      Point position;

      if (!this.InvertMouse)
      {
        x = -_startScrollPosition.X + (_startMousePosition.X - e.Location.X);
        y = -_startScrollPosition.Y + (_startMousePosition.Y - e.Location.Y);
      }
      else
      {
        x = -(_startScrollPosition.X + (_startMousePosition.X - e.Location.X));
        y = -(_startScrollPosition.Y + (_startMousePosition.Y - e.Location.Y));
      }

      position = new Point(x, y);

      this.UpdateScrollPosition(position);
    }
  }
}

protected override void OnMouseUp(MouseEventArgs e)
{
  base.OnMouseUp(e);

  if (this.IsPanning)
    this.IsPanning = false;
}

protected virtual void UpdateScrollPosition(Point position)
{
  this.AutoScrollPosition = position;
  this.Invalidate();
  this.OnScroll(new ScrollEventArgs(ScrollEventType.ThumbPosition, 0));
}

UpdateScrollPosition is a common method to set the viewport and refresh the control. The IsPanning property is used to notify the control internally that a pan operation has been started. It will also set a semi-appropriate cursor (we'll look at custom cursors another time), and raise either the PanStart or PanEnd events.

csharp
[DefaultValue(false), DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden), Browsable(false)]
public bool IsPanning
{
  get { return _isPanning; }
  protected set
  {
    if (_isPanning != value)
    {
      _isPanning = value;
      _startScrollPosition = this.AutoScrollPosition;

      if (value)
      {
        this.Cursor = Cursors.SizeAll;
        this.OnPanStart(EventArgs.Empty);
      }
      else
      {
        this.Cursor = Cursors.Default;
        this.OnPanEnd(EventArgs.Empty);
      }
    }
  }
}

Keyboard Scrolling

The first two versions of this component effectively disabled keyboard support via the ControlStyles.Selectable control style and TabStop property. However, we now want to allow keyboard support. So the first thing we do is remove the call to disable the selectable style and resetting of the tab stop property from the constructor. We also remove the custom TabStop property we had implemented for attribute overriding.

With this done, we can now add some keyboard support. As the ScrollableControl doesn't natively support this, we'll do it ourselves by overriding OnKeyDown. One of the initial drawbacks is that it won't always capture special keys, such as the arrow keys.

In order for it to do so we need to let the control know that such keys are required by overriding IsInputKey - if this returns true, then the specified key is required and will be captured in OnKeyDown.

csharp
protected override bool IsInputKey(Keys keyData)
{
  bool result;

  if ((keyData & Keys.Right) == Keys.Right | (keyData & Keys.Left) == Keys.Left | (keyData & Keys.Up) == Keys.Up | (keyData & Keys.Down) == Keys.Down)
    result = true;
  else
    result = base.IsInputKey(keyData);

  return result;
}

protected override void OnKeyDown(KeyEventArgs e)
{
  base.OnKeyDown(e);

  switch (e.KeyCode)
  {
    case Keys.Left:
      this.AdjustScroll(-(e.Modifiers == Keys.None ? this.HorizontalScroll.SmallChange : this.HorizontalScroll.LargeChange), 0);
      break;
    case Keys.Right:
      this.AdjustScroll(e.Modifiers == Keys.None ? this.HorizontalScroll.SmallChange : this.HorizontalScroll.LargeChange, 0);
      break;
    case Keys.Up:
      this.AdjustScroll(0, -(e.Modifiers == Keys.None ? this.VerticalScroll.SmallChange : this.VerticalScroll.LargeChange));
      break;
    case Keys.Down:
      this.AdjustScroll(0, e.Modifiers == Keys.None ? this.VerticalScroll.SmallChange : this.VerticalScroll.LargeChange);
      break;
  }
}

protected virtual void AdjustScroll(int x, int y)
{
  Point scrollPosition;

  scrollPosition = new Point(this.HorizontalScroll.Value + x, this.VerticalScroll.Value + y);

  this.UpdateScrollPosition(scrollPosition);
}

When the left, right, up or down arrow keys are pressed, the control checks to see if a modifier such as shift or control is active. If not, then the control is scrolled either horizontally or vertically using the "small change" value of the appropriate scrollbar. If a modifier was set, then the scroll is made using the "large change" value.

The AdjustScroll method is used to "nudge" the scrollbars in the given direction, using values read from the HorizontalScroll and VerticalScroll - reading the AutoScrollPosition property didn't return appropriate results in our testing.

Sample Project

You can download the third sample project from the links below. The final article in the series will add autofit, centering and of course, zoom support.

Update History

  • 2010-08-23 - First published
  • 2020-11-21 - Updated formatting

Like what you're reading? Perhaps you like to buy us a coffee?

Donate via Buy Me a Coffee

Donate via PayPal


Files



Comments

We'll never share your email with anyone else Styling with Markdown is supported
# gyurisc

This controll is supercool! Thanks for taking the time and writing the control and articles.

I have one question though. If I click on the control, how can I get the coordinates of the click on the original image?

Reply
# Richard Moss

gyurisc,

Thanks for the comment. I was working on a small update to the control, but as I haven't got one of the bugs fixed yet I'll just post the function you need in this comment.

[csharp] public virtual Point PointToImage(Point point) { Rectangle viewport; int x; int y;

  viewport = this.GetImageViewPort();

  if (viewport.Contains(point))
  {
    if (this.AutoScrollPosition != Point.Empty)
      point = new Point(point.X - this.AutoScrollPosition.X, point.Y - this.AutoScrollPosition.Y);

    x = (((int)(point.X / this.ZoomFactor)) - viewport.X) + 1; // Add 1 to both X and Y to so that hovering over 0,0 will not return Point.Empty
    y = (((int)(point.Y / this.ZoomFactor)) - viewport.Y) + 1;
  }
  else
  {
    x = 0; // Return Point.Empty if we couldn't match
    y = 0;
  }

  return new Point(x, y);
}

[/csharp]

Add this to your copy of ImageBox, then call it as you would the PointToClient and PointToScreen methods, for example:

[csharp] private void imageBox_MouseMove(object sender, MouseEventArgs e) { Point point;

  point = ((ImageBox)sender).PointToImage(e.Location);

  if (!point.IsEmpty)
    cursorToolStripStatusLabel.Text = point.ToString();
  else
    cursorToolStripStatusLabel.Text = string.Empty;
}

[/csharp]

Note that this function always returns 1 more than the actual point, as Point.Empty returns true for 0,0 and I can't return null for a structure. A bit annoying...

Hope this helps!

Regards; Richard Moss

Reply