Creating a custom single-axis scrolling control in WinForms
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.
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 theSCROLLINFO
structure describesLargeChange
asPage
. 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 theScrollControl
method to wrap the increment in achecked
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 thetry
...catch
block and thechecked
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
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.
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?