Entries tagged with 'imagebox'

Extending the ImageBox component to display the contents of a PDF file using C#

In this article, I'll describe how to extend the ImageBox control discussed in earlier articles to be able to display PDF files with the help of the GhostScript library and the conversion library described in the previous article.

Getting Started

You can download the source code used in this article from the links below, these are:

  • Cyotek.GhostScript - core library providing GhostScript integration support
  • Cyotek.GhostScript.PdfConversion - support library for converting a PDF document into images
  • PdfImageBoxSample - sample project containing an updated ImageBox control, and the extended PdfImageBox.

Please note that the native GhostScript DLL is not included in these downloads, you will need to obtain that from the GhostScript project page.

Extending the ImageBox

To start extending the ImageBox, create a new class and inherit the ImageBox control. I also decided to override some of the default properties, so I added a constructor which sets the new values.

    public PdfImageBox()
    {
      // override some of the original ImageBox defaults
      this.GridDisplayMode = ImageBoxGridDisplayMode.None;
      this.BackColor = SystemColors.AppWorkspace;
      this.ImageBorderStyle = ImageBoxBorderStyle.FixedSingleDropShadow;

      // new pdf conversion settings
      this.Settings = new Pdf2ImageSettings();
    }

To ensure correct designer support, override versions of the properties with new DefaultValue attributes were added. With this done, it's time to add the new properties that will support viewing PDF files. The new properties are:

  • PdfFileName - the filename of the PDF to view
  • PdfPassword - specifies the password of the PDF file if one is required to open it (note, I haven't actually tested that this works!)
  • Settings - uses the Pdf2ImageSettings class discussed earlier to control quality settings for the converted document.
  • PageCache - an internal dictionary which stores a Bitmap against a page number to cache pages after these have loaded.

With the exception of PageCache, each of these properties also has backing event for change notifications, and as Pdf2ImageSettings implements INotifyPropertyChanged we'll also bind an event detect when the individual setting properties are modified.

    [Category("Appearance"), DefaultValue(typeof(Pdf2ImageSettings), "")]
    public virtual Pdf2ImageSettings Settings
    {
      get { return _settings; }
      set
      {
        if (this.Settings != value)
        {
          if (_settings != null)
            _settings.PropertyChanged -= SettingsPropertyChangedHandler;

          _settings = value;
          _settings.PropertyChanged += SettingsPropertyChangedHandler;

          this.OnSettingsChanged(EventArgs.Empty);
        }
      }
    }
    
    private void SettingsPropertyChangedHandler(object sender, PropertyChangedEventArgs e)
    {
      this.OnSettingsChanged(e);
    }

    protected virtual void OnSettingsChanged(EventArgs e)
    {
      this.OpenPDF();

      if (this.SettingsChanged != null)
        this.SettingsChanged(this, e);
    }

Navigation support

Although the PdfImageBox doesn't supply a user interface for navigating to different pages, we want to make it easy for the hosting application to provide one. To support this, a new CurrentPage property will be added for allowing the active page to retrieved or set, and also a number of readonly CanMove* properties. These properties allow the host to query which navigation options are applicable in order to present the correct UI.

    [Browsable(false)]
    public virtual int PageCount
    { get { return _converter != null ? _converter.PageCount : 0; } }

    [Category("Appearance"), DefaultValue(1)]
    public int CurrentPage
    {
      get { return _currentPage; }
      set
      {
        if (this.CurrentPage != value)
        {
          if (value < 1 || value > this.PageCount)
            throw new ArgumentException("Page number is out of bounds");

          _currentPage = value;

          this.OnCurrentPageChanged(EventArgs.Empty);
        }
      }
    }

    [Browsable(false)]
    public bool CanMoveFirst
    { get { return this.PageCount != 0 && this.CurrentPage != 1; } }

    [Browsable(false)]
    public bool CanMoveLast
    { get { return this.PageCount != 0 && this.CurrentPage != this.PageCount; } }

    [Browsable(false)]
    public bool CanMoveNext
    { get { return this.PageCount != 0 && this.CurrentPage < this.PageCount; } }

    [Browsable(false)]
    public bool CanMovePrevious
    { get { return this.PageCount != 0 && this.CurrentPage > 1; } }

Again, to make it easier for the host to connect to the control, we also add some helper navigation methods.

    public void FirstPage()
    {
      this.CurrentPage = 1;
    }

    public void LastPage()
    {
      this.CurrentPage = this.PageCount;
    }

    public void NextPage()
    {
      this.CurrentPage++;
    }

    public void PreviousPage()
    {
      this.CurrentPage--;
    }

Finally, it can sometimes take a few seconds to convert a page in a PDF file. To allow the host to provide a busy notification, such as setting the wait cursor or displaying a status bar message, we'll add a pair of events which will be called before and after a page is converted.

public event EventHandler LoadingPage;

public event EventHandler LoadedPage;

Opening the PDF file

Each of the property changed handlers in turn call the OpenPDF method. This method first clears any existing image cache and then initializes the conversion class based on the current PDF file name and quality settings. If the specified file is a valid PDF, the first page is converted, cached, and displayed.

    public void OpenPDF()
    {
      this.CleanUp();

      if (!this.DesignMode)
      {
        _converter = new Pdf2Image()
        {
          PdfFileName = this.PdfFileName,
          PdfPassword = this.PdfPassword,
          Settings = this.Settings
        };

        this.Image = null;
        this.PageCache= new Dictionary<int, Bitmap>();
        _currentPage = 1;

        if (this.PageCount != 0)
        {
          _currentPage = 0;
          this.CurrentPage = 1;
        }
      }
    }

    private void CleanUp()
    {
      // release  bitmaps
      if (this.PageCache != null)
      {
        foreach (KeyValuePair<int, Bitmap> pair in this.PageCache)
          pair.Value.Dispose();
        this.PageCache = null;
      }
    }

Displaying the image

Each time the CurrentPage property is changed, it calls the SetPageImage method. This method first checks to ensure the specified page is present in the cache. If it is not, it will load the page in. Once the page is in the cache, it is then displayed in the ImageBox, and the user can then pan and zoom as with any other image.

    protected virtual void SetPageImage()
    {
      if (!this.DesignMode && this.PageCache != null)
      {
        lock (_lock)
        {
          if (!this.PageCache.ContainsKey(this.CurrentPage))
          {
            this.OnLoadingPage(EventArgs.Empty);
            this.PageCache.Add(this.CurrentPage, _converter.GetImage(this.CurrentPage));
            this.OnLoadedPage(EventArgs.Empty);
          }

          this.Image = this.PageCache[this.CurrentPage];
        }
      }
    }

Note that we operate a lock during the execution of this method, to ensure that you can't try and load the same page twice.

With this method in place, the control is complete and ready to be used as a basic PDF viewer. In order to keep the article down to a reasonable size, I've excluded some of the definitions, overloads and helper methods; these can all be found in the sample download below.

The sample project demonstrates all the features described above and provides an example setting up a user interface for navigating a PDF document.

Future changes

At the moment, the PdfImageBox control processes on page at a time and caches the results. This means that navigation through already viewed pages is fast, but displaying new pages can be less than ideal. A possible enhancement would be to make the control multithreaded, and continue to load pages on a background thread.

Another issue is that as the control is caching the converted images in memory, it may use a lot of memory in order to display large PDF files. Not quite sure on the best approach to resolve this one, either to "expire" older pages, or to keep only a fixed number in memory. Or even save each page to a temporary disk file.

Finally, I haven't put in any handling at all for if the converter fails to convert a given page... I'll add this to a future update, and hopefully get the code hosted on an SVN server for interested parties.

Downloads:

  • PdfImageBoxSample.zip

    (513.29 KB | 04 September 2011 )

    Sample project showing how to extend the ImageBox control in order to display convert and display PDF files in a .NET WinForms application with the help of GhostScript.

  • Cyotek.GhostScript.zip

    (11.68 KB | 04 September 2011 )

    Work in progress class library for providing GhostScript integration in a .NET application.

  • Cyotek.GhostScript.PdfConversion.zip

    (5.43 KB | 04 September 2011 )

    Class library for converting PDF files into images using GhostScript. Also requires the Cyotek.GhostScript assembly.

2 comments | | Trackback specific URL for this entry

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

In the conclusion to our series on building a scrollable and zoomable image viewer, we'll add support for zooming, auto centering, size to fit and some display optimizations and enhancements.

Getting Started

Unlike parts 2 and 3, we're actually adding quite a lot of new functionality, some of it more complicated than others.

First, we're going to remove the ShowGrid property. This originally was a simple on/off flag, but we want more control this time.

We've also got a number of new properties and backing events to add:

  • AutoCenter - controls if the image is automatically centered in the display area if the image isn't scrolled.
  • SizeToFit - if this property is set, the image will automatically zoom to the maximum size for displaying the entire image.
  • GridDisplayMode - this property, which replaces ShowGrid will determine how the background grid is to be drawn.
  • InterpolationMode - determines how the zoomed image will be rendered.
  • Zoom - allows you to specify the zoom level.
  • ZoomIncrement - specifies how much the zoom is increased or decreased using the scroll wheel.
  • ZoomFactor - this protected property returns the current zoom as used internally for scalling.
  • ScaledImageWidth and ScaledImageHeight - these protected properties return the size of the image adjusted for the current zoom.

Usually the properties are simple assignments, which compare the values before assignment and raise an event. The zoom property is slightly different as it will ensure that the new value fits within a given range before setting it.

private static readonly int MinZoom = 10;
private static readonly int MaxZoom = 3500;

[DefaultValue(100), Category("Appearance")]
public int Zoom
{
  get { return _zoom; }
  set
  {
    if (value < ImageBox.MinZoom)
      value = ImageBox.MinZoom;
    else if (value > ImageBox.MaxZoom)
      value = ImageBox.MaxZoom;

    if (_zoom != value)
    {
      _zoom = value;
      this.OnZoomChanged(EventArgs.Empty);
    }
  }
}

Using the MinZoom and MaxZoom constants we are specifying a minimum value of 10% and a maximum of 3500%. The values you are assign are more or less down to your own personal preferences - I don't have any indications of what a "best" maximum value would be.

Setting the SizeToFit property should disable the AutoPan property and vice versa.

Layout Updates

Several parts of the component work from the image size, however as these now need to account for any zoom level, all such calls now use the ScaledImageWidth and ScaledImageHeight properties.

protected virtual int ScaledImageHeight
{ get { return this.Image != null ? (int)(this.Image.Size.Height * this.ZoomFactor) : 0; } }

protected virtual int ScaledImageWidth
{ get { return this.Image != null ? (int)(this.Image.Size.Width * this.ZoomFactor) : 0; } }

protected virtual double ZoomFactor
{ get { return (double)this.Zoom / 100; } }

The AdjustLayout method which determines the appropriate course of action when certain properties are changed has been updated to support the size to fit functionality by calling the new ZoomToFit method.

protected virtual void AdjustLayout()
{
  if (this.AutoSize)
    this.AdjustSize();
  else if (this.SizeToFit)
    this.ZoomToFit();
  else if (this.AutoScroll)
    this.AdjustViewPort();
  this.Invalidate();
}

public virtual void ZoomToFit()
{
  if (this.Image != null)
  {
    Rectangle innerRectangle;
    double zoom;
    double aspectRatio;

    this.AutoScrollMinSize = Size.Empty;

    innerRectangle = this.GetInsideViewPort(true);

    if (this.Image.Width > this.Image.Height)
    {
      aspectRatio = ((double)innerRectangle.Width) / ((double)this.Image.Width);
      zoom = aspectRatio * 100.0;
      
      if (innerRectangle.Height < ((this.Image.Height * zoom) / 100.0))
      {
        aspectRatio = ((double)innerRectangle.Height) / ((double)this.Image.Height);
        zoom = aspectRatio * 100.0;
      }
    }
    else
    {
      aspectRatio = ((double)innerRectangle.Height) / ((double)this.Image.Height);
      zoom = aspectRatio * 100.0;
      
      if (innerRectangle.Width < ((this.Image.Width * zoom) / 100.0))
      {
        aspectRatio = ((double)innerRectangle.Width) / ((double)this.Image.Width);
        zoom = aspectRatio * 100.0;
      }
    }

    this.Zoom = (int)Math.Round(Math.Floor(zoom));
  }
}

Due to the additional complexity in positioning and sizing, we're also adding functions to return the different regions in use by the control.

  • GetImageViewPort - returns a rectangle representing the size of the drawn image.
  • GetInsideViewPort - returns a rectangle representing the client area of the control, offset by the current border style, and optionally padding.
  • GetSourceImageRegion - returns a rectangle representing the area of the source image that will be drawn onto the control.

The sample project has been updated to be able to display the results of the GetImageViewPort and GetSourceImageRegion functions.

public virtual Rectangle GetImageViewPort()
{
  Rectangle viewPort;

  if (this.Image != null)
  {
    Rectangle innerRectangle;
    Point offset;

    innerRectangle = this.GetInsideViewPort();

    if (this.AutoCenter)
    {
      int x;
      int y;

      x = !this.HScroll ? (innerRectangle.Width - (this.ScaledImageWidth + this.Padding.Horizontal)) / 2 : 0;
      y = !this.VScroll ? (innerRectangle.Height - (this.ScaledImageHeight + this.Padding.Vertical)) / 2 : 0;

      offset = new Point(x, y);
    }
    else
      offset = Point.Empty;

    viewPort = new Rectangle(offset.X + innerRectangle.Left + this.Padding.Left, offset.Y + innerRectangle.Top + this.Padding.Top, innerRectangle.Width - (this.Padding.Horizontal + (offset.X * 2)), innerRectangle.Height - (this.Padding.Vertical + (offset.Y * 2)));
  }
  else
    viewPort = Rectangle.Empty;

  return viewPort;
}

public Rectangle GetInsideViewPort()
{
  return this.GetInsideViewPort(false);
}

public virtual Rectangle GetInsideViewPort(bool includePadding)
{
  int left;
  int top;
  int width;
  int height;
  int borderOffset;

  borderOffset = this.GetBorderOffset();
  left = borderOffset;
  top = borderOffset;
  width = this.ClientSize.Width - (borderOffset * 2);
  height = this.ClientSize.Height - (borderOffset * 2);

  if (includePadding)
  {
    left += this.Padding.Left;
    top += this.Padding.Top;
    width -= this.Padding.Horizontal;
    height -= this.Padding.Vertical;
  }

  return new Rectangle(left, top, width, height);
}

public virtual Rectangle GetSourceImageRegion()
{
  int sourceLeft;
  int sourceTop;
  int sourceWidth;
  int sourceHeight;
  Rectangle viewPort;
  Rectangle region;

  if (this.Image != null)
  {
    viewPort = this.GetImageViewPort();
    sourceLeft = (int)(-this.AutoScrollPosition.X / this.ZoomFactor);
    sourceTop = (int)(-this.AutoScrollPosition.Y / this.ZoomFactor);
    sourceWidth = (int)(viewPort.Width / this.ZoomFactor);
    sourceHeight = (int)(viewPort.Height / this.ZoomFactor);

    region = new Rectangle(sourceLeft, sourceTop, sourceWidth, sourceHeight);
  }
  else
    region = Rectangle.Empty;

  return region;
}

Drawing the control

As with the previous versions, the control is drawn by overriding OnPaint, this time we are not using clip regions or drawing the entire image even if only a portion of it is visible.

  // draw the borders
  switch (this.BorderStyle)
  {
    case BorderStyle.FixedSingle:
      ControlPaint.DrawBorder(e.Graphics, this.ClientRectangle, this.ForeColor, ButtonBorderStyle.Solid);
      break;
    case BorderStyle.Fixed3D:
      ControlPaint.DrawBorder3D(e.Graphics, this.ClientRectangle, Border3DStyle.Sunken);
      break;
  }

Depending on the value of the GridDisplayMode property, the background tile grid will either not be displayed, will be displayed to fill the client area of the control, or new for this update, to only fill the area behind the image. The remainder of the control is filled with the background color.

  Rectangle innerRectangle;

  innerRectangle = this.GetInsideViewPort();

  // draw the background
  using (SolidBrush brush = new SolidBrush(this.BackColor))
    e.Graphics.FillRectangle(brush, innerRectangle);

  if (_texture != null && this.GridDisplayMode != ImageBoxGridDisplayMode.None)
  {
    switch (this.GridDisplayMode)
    {
      case ImageBoxGridDisplayMode.Image:
        Rectangle fillRectangle;

        fillRectangle = this.GetImageViewPort();
        e.Graphics.FillRectangle(_texture, fillRectangle);

        if (!fillRectangle.Equals(innerRectangle))
        {
          fillRectangle.Inflate(1, 1);
          ControlPaint.DrawBorder(e.Graphics, fillRectangle, this.ForeColor, ButtonBorderStyle.Solid);
        }
        break;
      case ImageBoxGridDisplayMode.Client:
        e.Graphics.FillRectangle(_texture, innerRectangle);
        break;
    }
  }

Previous versions of the control drew the entire image using the DrawImageUnscaled method of the Graphics object. In this final version, we're going to be a little more intelligent and only draw the visible area, removing the need for the previous clip region. The InterpolationMode is used to determine how the image is drawn when it is zoomed in or out.

  // draw the image
  g.InterpolationMode = this.InterpolationMode;
  g.DrawImage(this.Image, this.GetImageViewPort(), this.GetSourceImageRegion(), GraphicsUnit.Pixel);

Zooming Support

With the control now all set up and fully supporting zoom, it's time to allow the end user to be able to change the zoom.

The first step is to disable the ability to double click the control, by modifying the control styles in the constructor.

this.SetStyle(ControlStyles.StandardDoubleClick, false);

We're going to allow the zoom to be changed two ways - by either scrolling the mouse wheel, or left/right clicking the control.

By overriding OnMouseWheel, we can be notified when the user spins the wheel, and in which direction. We then adjust the zoom using the value of the ZoomIncrement property. If a modifier key such as Shift or Control is pressed, then we'll modify the zoom by five times the increment.

protected override void OnMouseWheel(MouseEventArgs e)
{
  if (!this.SizeToFit)
  {
    int increment;

    if (Control.ModifierKeys == Keys.None)
      increment = this.ZoomIncrement;
    else
      increment = this.ZoomIncrement * 5;

    if (e.Delta < 0)
      increment = -increment;

    this.Zoom += increment;
  }
}

Normally, whenever we override a method, we always call it's base implementation. However, in this case we will not; the ScrollbableControl that we inherit from uses the mouse wheel to scroll the viewport and there doesn't seem to be a way to disable this undesirable behaviour.

As we also want to allow the user to be able to click the control with the left mouse button to zoom in, and either the right mouse button or left button holding a modifier key to zoom out, we'll also override OnMouseClick.

protected override void OnMouseClick(MouseEventArgs e)
{
  if (!this.IsPanning && !this.SizeToFit)
  {
    if (e.Button == MouseButtons.Left && Control.ModifierKeys == Keys.None)
    {
      if (this.Zoom >= 100)
        this.Zoom = (int)Math.Round((double)(this.Zoom + 100) / 100) * 100;
      else if (this.Zoom >= 75)
        this.Zoom = 100;
      else
        this.Zoom = (int)(this.Zoom / 0.75F);
    }
    else if (e.Button == MouseButtons.Right || (e.Button == MouseButtons.Left && Control.ModifierKeys != Keys.None))
    {
      if (this.Zoom > 100 && this.Zoom <= 125)
        this.Zoom = 100;
      else if (this.Zoom > 100)
        this.Zoom = (int)Math.Round((double)(this.Zoom - 100) / 100) * 100;
      else
        this.Zoom = (int)(this.Zoom * 0.75F);
    }
  }

  base.OnMouseClick(e);
}

Unlike with the mouse wheel and it's fixed increment, we want to use a different approach with clicking. If zooming out and the percentage is more than 100, then the zoom level will be set to the current zoom level + 100, but rounded to the nearest 100, and the same in reserve for zooming in.

If the current zoom is less than 100, then the new value will +- 75% of the current zoom, or reset to 100 if the new value falls between 75 and 125.

This results in a nicer zoom experience then just using a fixed value.

Sample Project

You can download the final sample project from the link below.

What's next?

One of the really annoying issues with this control that has plagued me during writing this series is scrolling the component. During scrolling there is an annoying flicker as the original contents are moved, then the new contents are drawn. At present I don't have a solution for this, I've tried overriding various WM_* messages but without success. A future update to this component will either fix this issue, or do it's own scrollbar support without inheriting from ScrollableControl, although I'd like to avoid this latter solution.

If anyone knows of a solution please let us know!

Another enhancement would be intelligent use of the interpolation mode. Currently the control uses a fixed value, but some values are better when zoomed in, and some better when zoomed out. The ability for the control to automatically select the most appropriate mode would be useful.

Other articles in this series

Downloads:

  • imageboxsample-part4.zip

    (448.63 KB | 28 August 2010 )

    Fourth and final in a multi part series on creating an image viewer that can be scrolled and zoomed in C#. After part three added panning, we now add zoom support via the mouse wheel and clicking with the left or right buttons, along with some additional display properties.

11 comments | | Trackback specific URL for this entry

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.

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 publically, 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.

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.

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

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, centring and of course, zoom support.

Other articles in this series

Downloads:

  • imageboxsample-part3.zip

    (443.55 KB | 23 August 2010 )

    Sample project showing how to create an image viewer that supports scrolling with the mouse and keyboard, and panning with the mouse.

1 comment | | Trackback specific URL for this entry

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

In the second part of our Creating a scrollable and zoomable image viewer in C# series we will update our component to support automatic scrolling when auto size is disabled and the image is larger than the client area of the control.

Setting up auto scrolling

Originally we inherited from Control, however this does not support automatic scrolling. Rather than reinventing the wheel at this point, we'll change the control to inherit from ScrollableControl instead. This will expose a number of new members, the ones we need are:

  • AutoScroll - Enables or disables automatic scrolling
  • AutoScrollMinSize - Specifies the minimum size before scrollbars appear
  • AutoScrollPosition - Specifies the current scroll position
  • OnScroll - Raised when the scroll position is changed

Using the above we can now offer full scrolling.

As the control will take care of the scrolling behaviour, we don't want the AutoScrollMinSize property to be available, so we'll declare a new version of it and hide it with attributes.

[Browsable(false), EditorBrowsable(EditorBrowsableState.Never), DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public new Size AutoScrollMainSize
{
  get { return base.AutoScrollMinSize; }
  set { base.AutoScrollMinSize = value; }
}

Initially the component only offered auto sizing and so we had defined an AdjustSize method which was called in response to various events and property changes. As we now need to set up the scrolling area if AutoScroll is enabled, this method is no longer as suitable. Instead, we add a pair of new methods, AdjustLayout and AdjustScrolling. Existing calls to AdjustSize are changed to call AdjustLayout instead, and this method now calls either AdjustScrolling or AdjustSize depending on the state of the AutoSize and AutoScroll properties.

The AdjustScrolling method is used to set the AutoScrollMainSize property. When this is correctly set, the ScrollableControl will automatically take care of displaying scrollbars.

protected virtual void AdjustLayout()
{
  if (this.AutoSize)
    this.AdjustSize();
  else if (this.AutoScroll)
    this.AdjustScrolling();
}

protected virtual void AdjustScrolling()
{
  if (this.AutoScroll && this.Image != null)
    this.AutoScrollMinSize = this.Image.Size;
}

Reacting to scroll changes

By overriding the OnScroll event we get notifications whenever the user scrolls the control, and can therefore redraw the image.

protected override void OnScroll(ScrollEventArgs se)
{
  this.Invalidate();

  base.OnScroll(se);
}

Painting adjustments

The initial version of our ImageBox tiled a bitmap across the client area of the control. In this new version, when we create the background tile, we now create a new TextureBrush. During drawing we can call FillRectangle and pass in the new brush and it will be tiled for us.

Another shortcoming of the first version was the borders. These were painted last, so that if the image was larger than the controls client area, the image wouldn't be painted on top of the borders. Now, the borders are drawn first and a clip region applied to prevent any overlap.

Finally of course, the position of the drawn image needs to reflect any scrollbar offset.

protected override void OnPaint(PaintEventArgs e)
{
  int borderOffset;
  Rectangle innerRectangle;

  borderOffset = this.GetBorderOffset();

  if (borderOffset != 0)
  {
    // draw the borders
    switch (this.BorderStyle)
    {
      case BorderStyle.FixedSingle:
        ControlPaint.DrawBorder(e.Graphics, this.ClientRectangle, this.ForeColor, ButtonBorderStyle.Solid);
        break;
      case BorderStyle.Fixed3D:
        ControlPaint.DrawBorder3D(e.Graphics, this.ClientRectangle, Border3DStyle.Sunken);
        break;
    }

    // clip the background so we don't overwrite the border
    innerRectangle = Rectangle.Inflate(this.ClientRectangle, -borderOffset, -borderOffset);
    e.Graphics.SetClip(innerRectangle);
  }
  else
    innerRectangle = this.ClientRectangle;

  // draw the background
  if (_texture != null && this.ShowGrid)
    e.Graphics.FillRectangle(_texture, innerRectangle);
  else
  {
    using (SolidBrush brush = new SolidBrush(this.BackColor))
      e.Graphics.FillRectangle(brush, innerRectangle);
  }

  // draw the image
  if (this.Image != null)
  {
    int left;
    int top;

    left = this.Padding.Left + borderOffset;
    top = this.Padding.Top + borderOffset;

    if (this.AutoScroll)
    {
      left += this.AutoScrollPosition.X;
      top += this.AutoScrollPosition.Y;
    }

    e.Graphics.DrawImageUnscaled(this.Image, new Point(left, top));
  }

  // reset the clipping
  if (borderOffset != 0)
    e.Graphics.ResetClip();
}

Sample Project

You can download the second sample project from the link below. The next article in the series will look at panning the image using the mouse within the client area of the image control.

Other articles in this series

Downloads:

  • imageboxsample-part2.zip

    (441.84 KB | 13 August 2010 )

    Second in a multi part series on creating an image viewer that can be scrolled and zoomed in C#. After part one created the initial component with auto resize, we now add scrolling support.

Post a Comment | | Trackback specific URL for this entry

Recent News

Recent Articles

Most Popular

Tags

Advertisments