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.
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).
DemoScrollControl featured in this sample probably isn't
directly usable in your projects but should make an excellent
which can be used to simulate a list or grid. In a real control
you might have
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.
Padding property influences the client area of the control
(i.e. the part where the list contents are drawn) and there is
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
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.
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.
We calculate these values whenever a property changes that could
affect the display, for example
built-in properties, and
Columns from the custom.
With our row count and visible row count defined, we can now
update our scrollbar by setting the
This is one of the mistakes I kept making, as I would always set
LargeChangeto 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
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.
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
LargeChange which prevents another overflow issue
when using the mouse wheel.
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
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.
Knowing the first item allows us to easily perform hit testing and painting without having to store bounds information.
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
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.
OnKeyDown, I call our
which looks at the incoming key and scrolls the control
|Key||Rows to scroll|
|Page Up||-visible rows|
|Page Down||visible rows|
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.MaxValuedue to integer overflow. I changed the
ScrollControlmethod to wrap the increment in a
checkedstatement 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
catchblock and the
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.
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.
OnMouseWheel override now asks the helper class how many
lines to scroll by and acts accordingly.
In versions of Windows prior to Windows 10, the
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
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
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.
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
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.
The demonstration control is available from our GitHub repository.