This content has moved - please find it at https://devblog.cyotek.com.

Although these pages remain accessible, some content may not display correctly in future as the new blog evolves.

Visit https://devblog.cyotek.com.

Creating a custom single-axis scrolling control in WinForms

Neither hot nor cold, but just right

Over the years, I have written several custom controls that supporting scrolling. However, almost invariably they have some flaw that meant scrolling was sub-optimal. For example, in our Gif Animator software, the frame reel can cut off the last frame. In other programs, scrolling goes beyond visible items and thus presents an empty control at worst or a smattering of items at best.

As it seems each control had its own different flaws, I created a dedicated demonstration program to iron out scrolling issues in my code, concentrating on single axis scrolling as most of my controls only scroll in one direction. This article covers the key points in creating a custom scroll control.

Exhibit A: Here, the scrollbar is as far along as it allows, but the last frame is only partially visible

Exhibit B: In this example, the maximum value of the scrollbar allows over scroll for an almost empty display

Setting the scene

I'm making the assumption that the scrolling will be of rows of tiles, either where each tile is the full width of the control (a list), or where there are multiple columns of a fixed size that are either related (a grid) or not (a multi column list).

The DemoScrollControl featured in this sample probably isn't directly usable in your projects but should make an excellent starting point.

I've added ItemCount, ItemHeight and Columns properties which can be used to simulate a list or grid. In a real control you might have Items or Columns collections, but having a simple count property allows me to simulate different control types for testing. It is also good for creating virtual lists, although that is a topic for another post.

The Padding property influences the client area of the control (i.e. the part where the list contents are drawn) and there is also a Gap property to control spacing between items, again mostly for simulation purposes.

I'm using a VScrollBar control to provide the scrolling interface as this is far simpler than applying the WS_VSCROLL style and going native.

As this is a demonstration control, it simply paints the index of each "item". In addition, this article is about scrolling so I'm not going to covering painting, hit testing or anything unrelated to the scrolling aspects. The source code example demonstrates a complete implementation.

Defining the number of rows

When the contents of control changes, we need to calculate the number of rows, as this directly influences the scrollbar. For a list or grid, that would be a simple item count. For a multi-column list, it would be the number of items divided by the column count.

We also need to determine how many rows are at least partially visible in the control, which we use for painting and hit testing. Finally, the number of fully visible rows is used to define the page size.

private void DefineRows()
{
  if (_itemCount > 0 && _columns > 0)
  {
    int height;

    _rows = _itemCount / _columns;
    if (_itemCount % _columns != 0)
    {
      _rows++;
    }

    height = this.InnerClient.Height;

    _fullyVisibleRows = height / (_itemHeight + _gap);
    _visibleRows = _fullyVisibleRows;

    if (_fullyVisibleRows == 0)
    {
      // always make sure there is at least one row, otherwise you can't scroll
      _fullyVisibleRows = 1;
    }

    if (_rows > _visibleRows && height % (_itemHeight + _gap) != 0)
    {
      // account for a partially visible row
      _visibleRows++;
    }
  }
}

We calculate these values whenever a property changes that could affect the display, for example Size, Font and Padding for built-in properties, and ItemCount, ItemHeight, Gap and Columns from the custom.

Updating the scrollbar properties

With our row count and visible row count defined, we can now update our scrollbar by setting the Maximum and LargeChange properties respectively.

This is one of the mistakes I kept making, as I would always set LargeChange to be an arbitrary value for the number of items to scroll, but I think I was getting thrown by the naming of the property (perhaps it is named LargeChange for compatibility with ancient VB6?). Remember that the scrollbar control wraps a Win32 scroll control, and the SCROLLINFO structure describes LargeChange as Page. Thinking of it in these terms let me realise I should be setting this to the number of visible items and solved an overflow issue.

If all items can fit without the need for scrolling, I disable and hide the scrollbar.

The SetScrollValue helper function updates the value of a scrollbar, ensuring that it fits within the minimum and maximum range. However, it also adjusts the range to be the Maximum minus the LargeChange which prevents another overflow issue when using the mouse wheel.

private void DefineRows()
{
  if (_itemCount > 0 && _columns > 0)
  {
    // snip

    if (_scrollBar != null)
    {
      _scrollBar.LargeChange = _fullyVisibleRows;
      _scrollBar.Maximum = _rows - 1;
      this.SetScrollValue(_scrollBar.Value);
    }
  }

  if (_scrollBar != null)
  {
    _scrollBar.Enabled = _rows > _fullyVisibleRows;
    _scrollBar.Visible = _rows > _fullyVisibleRows;
  }
}

private void SetScrollValue(int value)
{
  value = Math.Min(value, _scrollBar.Maximum - (_scrollBar.LargeChange - 1));

  if (value < 0)
  {
    value = 0;
  }

  _scrollBar.Value = value;
}

Note that this means the control can host a maximum of 2,147,483,647 rows. If you need more than this then you'd need to rethink all scrollbar interactions, or your entire UI for that matter... I don't think I'd want to use such an interface!

Setting the first visible item

I choose not to use "smooth scrolling" in most of my controls as it is much easier to always have a partially displayed item at the bottom than at the top. Being able to get and set the first, or "top", item is a core part of making scrolling work.

With the control set up the way it is, the first item is the current scrollbar position multiplied by the number of columns. This also means that when setting the first item, we set the scrollbar position to be the new value divided by the column count. You only need to do this for multi column lists, for lists or grids you can simply apply the value as is.

[Browsable(false)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public int TopItem
{
  get { return _topItem; }
  set
  {
    if (value < 0)
    {
      value = 0;
    }
    else if (value > _itemCount)
    {
      value = _itemCount;
    }

    if (_topItem != value)
    {
      _topItem = value;

      if (_columns > 0)
      {
        this.SetScrollValue(value / _columns);
      }

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

Knowing the first item allows us to easily perform hit testing and painting without having to store bounds information.

Scrolling with the keyboard

As the SetScrollValue method keeps the new value within the range of the scrollbar, this time around I tried something new and all scroll actions were performed by line count. Usually I have a special case for the start and end of the list, but if I tell it to scroll by the negative item count and positive item count, then I can achieve the same result without special cases.

Due to using the arrow keys for scrolling, first I need to intercept IsInputKey so that I can tell our control we want to process them, and I include the other keys I'll use for scrolling for good measure.

Then, in OnKeyDown, I call our ProcessScrollKeys method which looks at the incoming key and scrolls the control accordingly.

Key Rows to scroll
Up -1
Down 1
Home -item count
End item count
Page Up -visible rows
Page Down visible rows
protected override bool IsInputKey(Keys keyData)
{
  return keyData == Keys.Up
    || keyData == Keys.Down
    || keyData == Keys.Home
    || keyData == Keys.End
    || keyData == Keys.PageUp
    || keyData == Keys.PageDown
    || base.IsInputKey(keyData);
}

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

  if (!e.Handled)
  {
    this.ProcessScrollKeys(e);
  }
}

private void ProcessScrollKeys(KeyEventArgs e)
{
  switch (e.KeyCode)
  {
    case Keys.Up:
      this.ScrollControl(-1);
      break;

    case Keys.Down:
      this.ScrollControl(1);
      break;

    case Keys.PageUp:
      this.ScrollControl(-_fullyVisibleRows);
      break;

    case Keys.PageDown:
      this.ScrollControl(_fullyVisibleRows);
      break;

    case Keys.Home:
      this.ScrollControl(-_itemCount);
      break;

    case Keys.End:
      this.ScrollControl(_itemCount);
      break;
  }
}

private void ScrollControl(int lines)
{
  int value;

  try
  {
    value = checked(_scrollBar.Value + lines);
  }
  catch (OverflowException)
  {
    if (lines < 0)
    {
      value = 0;
    }
    else
    {
      value = _itemCount;
    }
  }

  this.SetScrollValue(value);
}

Note: While writing this article, I found that jumping to the end of the list didn't work correctly if the sum of the current position plus the increment was above int.MaxValue due to integer overflow. I changed the ScrollControl method to wrap the increment in a checked statement to throw in this scenario, then choose a new min/max accordingly. This should be a pretty rare scenario so you can always remove the try ... catch block and the checked statement.

Scrolling with the mouse wheel

In previous controls, I might have done something similar to the below. While this works, I have received reports that this isn't always reliable but it is something I have never been able to reproduce.

protected override void OnMouseWheel(MouseEventArgs e)
{
  // naive implementation
  this.ScrollControl(-(e.Delta / SystemInformation.MouseWheelScrollDelta));

  base.OnMouseWheel(e);
}

This time, I decided to use a different solution. Martin Mitáš wrote Custom Controls in Win32 API: Scrolling on Code Project which has a helper function for accumulating wheel deltas. Unfortunately I still don't have a mouse that reports a non-standard delta so I am unable to test that this resolves those issues, but certainly the code works well with my hardware.

I converted the original C++ code into a C# class that I can reuse with other projects.

internal static class WheelHelper
{
  private static readonly int[] _accumulator = new int[2];

  private static readonly uint[] _lastActivity = new uint[2];

  private static readonly object _lock = new object();

  private static IntPtr _hwndCurrent = IntPtr.Zero;

  public static int WheelScrollLines(IntPtr hwnd, int delta, int pageSize, bool isVertical)
  {
    uint now;
    int scrollSysParam;
    int linesPerWheelDelta;
    int dirIndex = isVertical ? 0 : 1;
    int lines;

    now = GetTickCount();

    if (pageSize < 1)
    {
      pageSize = 1;
    }

    scrollSysParam = isVertical
      ? SPI_GETWHEELSCROLLLINES
      : SPI_GETWHEELSCROLLCHARS;

    linesPerWheelDelta = 0;

    if (!SystemParametersInfo(scrollSysParam, 0, ref linesPerWheelDelta, 0))
    {
      linesPerWheelDelta = 3;
    }

    if (linesPerWheelDelta == WHEEL_PAGESCROLL)
    {
      linesPerWheelDelta = pageSize;
    }

    if (linesPerWheelDelta > pageSize)
    {
      linesPerWheelDelta = pageSize;
    }

    lock (_lock)
    {
      if (hwnd != _hwndCurrent)
      {
        _hwndCurrent = hwnd;
        _accumulator[0] = 0;
        _accumulator[1] = 0;
      }
      else if (now - _lastActivity[dirIndex] > SystemInformation.DoubleClickTime * 2)
      {
        _accumulator[dirIndex] = 0;
      }
      else if ((_accumulator[dirIndex] > 0) == (delta < 0))
      {
        _accumulator[dirIndex] = 0;
      }

      if (linesPerWheelDelta > 0)
      {
        _accumulator[dirIndex] += delta;

        lines = _accumulator[dirIndex] * linesPerWheelDelta / WHEEL_DELTA;

        _accumulator[dirIndex] -= lines * WHEEL_DELTA / linesPerWheelDelta;
      }
      else
      {
        lines = 0;
        _accumulator[dirIndex] = 0;
      }

      _lastActivity[dirIndex] = now;
    }

    return isVertical ? -lines : lines;
  }
}

Our OnMouseWheel override now asks the helper class how many lines to scroll by and acts accordingly.

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

  if (_fullyVisibleRows > 0)
  {
    this.ScrollControl(WheelHelper.WheelScrollLines(this.Handle, e.Delta, _fullyVisibleRows, true));
  }
}

Mouse wheel scrolling on older versions of Windows

In versions of Windows prior to Windows 10, the WM_MOUSEWHEEL and WM_MOUSEHWHEEL messages were only sent to the window with focus. Windows 10 (or at least recent versions of it) changed this behaviour so the wheel messages would be sent even if the window didn't have focus.

Therefore, if you want your control to be scrollable via the mouse wheel regardless of if it has focus or not on older versions of Windows, you'd need to intercept the messages yourself. The easiest way of doing this is via a message filter.

The following class can be used to intercept the mouse wheel messages and forward them to the control under the mouse. We do this by checking for the WM_MOUSEWHEEL and WM_MOUSEHWHEEL and on receiving these, if the window under the mouse is an instance of our control we forward the message onto the control and prevent it from being sent to the original window. For all other cases, we let the message pass though and be handled normally.

internal sealed class MouseWheelMessageFilter<T> : IMessageFilter
  where T : Control
{
  private static bool _enabled;

  private static MouseWheelMessageFilter<T> _instance;

  public static bool Enabled
  {
    get { return _enabled; }
    set
    {
      if (_enabled != value)
      {
        _enabled = value;

        if (_enabled)
        {
          Interlocked.CompareExchange(ref _instance, new MouseWheelMessageFilter<T>(), null);

          Application.AddMessageFilter(_instance);
        }
        else if (_instance != null)
        {
          Application.RemoveMessageFilter(_instance);
        }
      }
    }
  }

  bool IMessageFilter.PreFilterMessage(ref Message m)
  {
    bool result;

    result = false;

    if (m.Msg == WM_MOUSEWHEEL || m.Msg == WM_MOUSEHWHEEL)
    {
      IntPtr hControlUnderMouse;

      hControlUnderMouse = WindowFromPoint(new Point((int)m.LParam));

      if (hControlUnderMouse != m.HWnd && Control.FromHandle(hControlUnderMouse) is T)
      {
        SendMessage(hControlUnderMouse, m.Msg, m.WParam, m.LParam);

        result = true;
      }
    }

    return result;
  }
}

While the above filter will work just as well on newer versions of Windows (for example the ImageBox control still currently always applies it), there is no point in running extra code if the OS will handle it, so consider not enabling it for Windows 10 or above.

static DemoScrollControl()
{
  OperatingSystem os;

  os = Environment.OSVersion;

  if (os.Platform == PlatformID.Win32NT && os.Version.Major < 10)
  {
    MouseWheelMessageFilter<DemoScrollControl>.Enabled = true;
  }
}

Important! If the application using your control does not include a manifest that is explicit about which Windows versions it supports, it highly likely that Windows will lie about the version and report an earlier version

Final words

Scrolling with multiple columns

Sitting down and properly thinking about the issues in a sample dedicated purely to that one aspect certainly worked, and hopefully scroll issues will be a thing of the past in my programs. Or at least once I update them.

Getting the source

The demonstration control is available from our GitHub repository.

About The Author

Gravatar

The founder of Cyotek, Richard enjoys creating new blog content for the site. Much more though, he likes to develop programs, and can often found writing reams of code. A long term gamer, he has aspirations in one day creating an epic video game. Until that time, he is mostly content with adding new bugs to WebCopy and the other Cyotek products.

Leave a Comment

While we appreciate comments from our users, please follow our posting guidelines. Have you tried the Cyotek Forums for support from Cyotek and the community?

Styling with Markdown is supported