Articles in this series
Introduction
Many controls are designed to present some dynamic contents, or larger amount of data which cannot fit into the control or a dialog. In such cases, the controls often need to be equipped with scrollbars so that user can navigate within the data. Standard controls like list view or tree view can serve as prime examples of this approach.
Windows API directly supports scrollbars within every single window and in the todays article we are going to show how to take advantage of it.
Note that in COMCTL32.DLL, Windows also offers a standalone scrollbar control but we won't cover it here. Once you understand the implicit scrollbar support we will talk about, usage of the standlone scrollbar control becomes very simple and straightforward.
Non-Client Area
Before we start talking about the scrollbars, we need to know about the concept of non-client area. In Windows, every window (HWND
) distinguishes its client and non-client area. The client area (usually) covers most (or all) of the window on the screen, and actually more or less all fundamental contents of controls is painted in it.
Non-client area is an optional margin around the client area which can be used for some auxiliary content. For top-level windows, this involves the window border, the window caption with the window title and buttons for minimizing, maximizing and closing the window, the menubar and a border around the window.
Also child windows can and quite often take use of the non-client area. In most cases, a simple border and possibly (if needed) the scrollbars are painted in it. Usually (i.e. unless overridden) if the control has a style WS_BORDER
or extended style WS_EX_CLIENTEDGE
, the control gets the border.
Similarly, if the control decides it needs a scrollbar, Windows reserves more space in the non-client area on right and/or bottom side of the control for the scrollbars.
This behavior for the border and scrollbars is implemented in the function DefWindowProc()
which handles many messages:
WM_NCCALCSIZE
determines dimensions of the non-client area. The default implementation looks for example at the style WS_BORDER
, extended style WS_EX_CLIENTEDGE
and state of the scrollbars to do so. WM_NCxxxx
counterparts of various mouse messages together with WM_NCHITTEST
handle interactivity of the non-client area. In the case of child control this typically involves reaction on the scrollbar buttons and DefWindowProc()
does this for us. WM_NCPAINT
is called to paint the non-client area. Again, handler of the message in DefWindowProc()
knows how to paint the border and the scrollbars.
All of this standard behavior can be overridden if you handle these messages in your window procedure, but that's not the what I want to talk about today. For the purpose of scrolling we can stick with the default behavior offered by DefWindowProc()
.
Setting Up the Scrollbars
Each HWND
remembers two sets of few integer values which describe state of both the horizontal and vertical scrollbars. The set corresponds to the members of structure SCROLLINFO
(except the auxiliary cbSize
and fMask
):
typedef struct tagSCROLLINFO
{
UINT cbSize;
UINT fMask;
int nMin;
int nMax;
UINT nPage;
int nPos;
int nTrackPos;
} SCROLLINFO, FAR *LPSCROLLINFO;
Anatomy of the scrollbar
Note you are free to choose any units for the scrolling you like. Use whatever suits logic of your control the best. The values may be pixels, count of rows (or columns), amount of lines of text or whatever.
The values nMin
and nMax
determine range of the scrollbars, i.e. minimal and maximal positions corresponding to the scrollbar's thumb moved to top (or left) and bottom (or right) position. In most cases nMin
can be just always set to zero and control updates just the upper limit nMax
.
The value nPage
describes portion of the contents between nMin
and nMax
which can be displayed in the control given the size of its client area. Windows also visualizes this value in proportional size of the scrollbars thumb.
The value nPos
determines the current scrollbar position, so the control is supposed to paint the corresponding portion of its content.
The value nTrackPos
is position of the thumb when it is currently being dragged to a new position. This value is read-only and cannot be directly changed programmatically.
Controls which want to support the scrolling can update these values with function SetScrollInfo()
, or alternatively with some less general function like SetScrollPos()
or SetScrollRange()
which can update only subsets of the values.
Remember that all these setter functions implicitly ensure that nPos
and nPage
are always in the allowed ranges so that the following conditions hold all the time:
0 <= nPage < nMax - nMin
nMin <= nPos <= nMax - nPage
If it is logically impossible to fulfill the conditions, e.g. because nPage > nMax - nMin
, then no scrolling is needed, Windows resets nPos
to zero and hides the scrollbar (which results to the resizing of the client area and WM_SIZE
message).
This means that you, as a caller of those function, do not need to care too much about the boundary cases. If, for example, you handle reaction to the key [PAGE UP] as scrolling a page up, you simply may do something like this:
int scrollbarId = (isVertical ? SB_VERT : SB_HORZ);
SCROLLINFO si;
si.cbSize = sizeof(SCROLLINFO);
si.fMask = SIF_POS | SIF_PAGE;
GetScrollInfo(hwnd, scrollbarId, &si);
si.fMask = SIF_POS;
si.nPos -= si.nPage;
SetScrollInfo(hwnd, scrollbarId, &si);
GetScrollInfo(hwnd, scrollbarId, &si);
Typically, controls supporting the scrolling need to update the state of the scrollbars in the following situations:
- Control has to update
nMin
and/or nMax
when amount or size of visible contents of the control changes. E.g. in a case of a control similar to a standard tree-view whenever new (visible) items are added or removed, or when an item is expanded or collapsed. - Control has to update
nPage
when size of the client area changes (i.e. when handling WM_SIZE
) so that it reflects amount of content which can fit in it. - Control has to update
nPos
when it responds to the scrolling event as described by WM_VSCROLL
or WM_HSCROLL
. We will cover this more thoroughly later in this article.
Some other situations when the scrollbar state needs to be updated can be when dimension of some elements of the contents changes, e.g. when control starts to use different font which has different size. Often, the amount of related work depends how smartly you choose the scrolling unit: Consider a tree-view control and vertical scrolling: If it uses pixels as the scrolling units, then change of item height (e.g. as a result of WM_SETFONT
) has to be reflected by recomputing of the scrollbar's state, but if you use rows as the scrolling units, then it does not.
Little Gotcha
When your control supports both horizontal and vertical scrollbars, there is a little trap. Remember that when setting up e.g. a vertical scrollbar, and the values change so that the scrollbar gets visible or gets hidden, the size of its client area changes.
This change in client area size can result also in the need to update state of the other scrollbar.
Consider the following code demonstrating the issue:
static void
CustomOnWmSize(HWND hWnd, UINT uWidth, UINT uHeight)
{
SCROLLINFO si;
si.cbSize = sizeof(SCROLLINFO);
si.fMask = SIF_PAGE;
si.nPage = uWidth;
SetScrollInfo(hWnd, SB_HORZ, &si, FALSE);
si.nPage = uHeight;
SetScrollInfo(hWnd, SB_VERT, &si, TRUE);
}
static LRESULT
CustomProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch(uMsg) {
...
case WM_SIZE:
CustomOnWmSize(hWnd, LOWORD(lParam), HIWORD(lParam));
return 0;
...
}
}
Once you understand the issue, the fix is simple:
static void
CustomOnWmSize(HWND hWnd, UINT uWidth, UINT uHeight)
{
SCROLLINFO si;
si.cbSize = sizeof(SCROLLINFO);
si.fMask = SIF_PAGE;
si.nPage = uWidth;
SetScrollInfo(hWnd, SB_HORZ, &si, FALSE);
{
RECT rc;
GetClientRect(hWnd, &rc);
uHeight = rc.bottom - rc.top;
}
si.nPage = uHeight;
SetScrollInfo(hWnd, SB_VERT, &si, TRUE);
}
Handling WM_VSCROLL and WM_HSCROLL
When the scrollbar is visible (i.e. whenever nMax - nMin > nPage
), and user interacts with it e.g. by clicking on a scrolling arrow button or by dragging the thumb, the window procedure gets corresponding non-client mouse messages. When passed to DefWindowProc()
, they are translated to messages WM_VSCROLL
(for the vertical scrollbar) and WM_HSCROLL
(for the horizontal scrollbar).
The control's window procedure is supposed to handle them as follows:
- Analyze the action requested by the user.
- Update
nPos
accordingly. - Refresh client area so that the control presents corresponding portion of the contents.
Hence the typical handler code may look as follows:
static void
CustomHandleVScroll(HWND hwnd, int iAction)
{
int nPos;
int nOldPos;
SCROLLINFO si;
si.cbSize = sizeof(SCROLLINFO);
si.fMask = SIF_RANGE | SIF_PAGE | SIF_POS | SIF_TRACKPOS;
GetScrollInfo(pData->hwnd, SB_VERT, &si);
nOldPos = si.nPos;
switch (iAction) {
case SB_TOP: nPos = si.nMin; break;
case SB_BOTTOM: nPos = si.nMax; break;
case SB_LINEUP: nPos = si.nPos - 1; break;
case SB_LINEDOWN: nPos = si.nPos + 1; break;
case SB_PAGEUP: nPos = si.nPos - CustomLogicalPage(si.nPage); break;
case SB_PAGEDOWN: nPos = si.nPos + CustomLogicalPage(si.nPage); break;
case SB_THUMBTRACK: nPos = si.nTrackPos; break;
default:
case SB_THUMBPOSITION: nPos = si.nPos; break;
}
SetScrollPos(hwnd, SB_VERT, nPos, TRUE);
nPos = GetScrollPos(hwnd, SB_VERT);
ScrollWindowEx(hwnd, 0, (nOldPos - nPos) * scrollUnit
NULL, NULL, NULL, NULL, SW_ERASE | SW_INVALIDATE);
}
static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch(uMsg) {
...
case WM_VSCROLL:
CustomHandleVScroll(hwnd, LOWORD(wParam));
return 0;
...
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
For WM_HSCROLL
, the code would be very similar.
Updating the Client Area
In the code snippet above, we have used the function ScrollWindowEx()
. Lets now take a closer look on it.
Painting the client area is task the control usually performs in the handler of message WM_PAINT
. In case of control which supports scrolling, the function has to take the current nPos
value into consideration. (Or actually two values, nPosHoriz
and nPosVert
if the control supports scrolling in both directions.)
Typically this means that the control contents is painted with vertical and horizontal offsets, -(nPosVert * uScrollUnitHeight)
and -(nPosHoriz * uScrollUnitWidth)
, so that the control presents content further to the bottom and right when the scrollbars are not in the minimal positions. (uScrollUnitWidth
and uScrollUnitHeight
determine width and height of the scrolling units in pixels.)
When application changes state of the scrollbar (i.e. the range, the position, or even the page size), it usually needs to repaint itself. It could just invalidate its client area and let WM_PAINT
paint everything from scratch.
Or it can do something much smarter. In most cases when scrolling, there is often quite a lot of correctly painted stuff already available on the screen. It's just painted on bad position which corresponds to the old value of nPos
, right?
The solution is to simply move all the still valid contents from the old position to the new one, and only invalidate portion of the client area which really needs to be repainted from scratch, i.e. only the area which roughly corresponds to the horizontal or vertical stripe which moves into the visible view-port from "behind the corner" during the scrolling operation.
And that is exactly what the function ScrollWindowEx()
is good for. You tell it a rectangle, you tell it a horizontal and vertical offsets (difference between old and new nPos
) in pixels, and it does all the magic. It actually copies/moves some graphic memory from one place to another to reuse as much as possible of the old contents, and it only invalidates those portions of the rectangle which really need to be repainted. Assuming the handler of WM_PAINT
is implemented as it should and repaints only the dirty rectangle (refer to our 2nd part of this series about painting), it will then have much less work to do.
Scrolling with Keyboard
In many cases it's useful to scroll by appropriate keys on a keyboard. Assuming for example that arrow keys, [HOME], [END], [PAGE DOWN] and [PAGE UP] should translate directly to the scrolling commands, the code can be very simple:
static void
CustomHandleKeyDown(CustomData* pData, UINT vkCode)
{
switch (vkCode) {
case VK_HOME: CustomHandleVScroll(pData, SB_TOP); break;
case VK_END: CustomHandleVScroll(pData, SB_BOTTOM); break;
case VK_UP: CustomHandleVScroll(pData, SB_LINEUP); break;
case VK_DOWN: CustomHandleVScroll(pData, SB_LINEDOWN); break;
case VK_PRIOR: CustomHandleVScroll(pData, SB_PAGEUP); break;
case VK_NEXT: CustomHandleVScroll(pData, SB_PAGEDOWN); break;
}
}
static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch(uMsg) {
...
case WM_KEYDOWN:
CustomHandleKeyDown(pData, wParam);
return 0;
...
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
Scrolling with Mouse Wheel
Adding support for scrolling with a mouse wheel is somewhat more interesting. The main reason why it is not that simple is diversity of available hardware. The mouse wheel in many cases is not really a wheel, and often there is no mouse at all. Consider for example modern trackpads which may map some finger gestures to a virtual mouse wheel.
Even among mouses, there are vast differences. As you should know, computers work mainly with numbers. And hence a scrolling the mouse wheel translates to some number which we may call "delta". Depending on the hardware, its driver, system configuration and position of planets in the Solar system, the same action with the mouse wheel can sometimes result in a larger delta coming at once, or a sequence of smaller deltas coming in short succession.
On Windows, the delta propagates as a parameter of the message WM_MOUSEWHEEL
for vertical wheel, or WM_MOUSEHWHEEL
for horizontal one: It is stored as the high word of WPARAM
.
So, to handle these messages, application (or control in our case) has to accumulate the delta until it reaches some threshold value meaning "scroll one line down" (or up; or to left or right for horizontal scrolling).
Furthermore, the control should respect sensitivity of the wheel as configured in the system. On Windows, this settings can be retrieved with SystemParametersInfo(SPI_GETWHEELSCROLLLINES)
for vertical wheel and SystemParametersInfo(SPI_GETWHEELSCROLLCHARS)
for horizontal one. Both values correspond to the amount of vertical or horizontal scrolling units the control should scroll when the accumulated delta value reaches the value defined with macro WHEEL_DELTA
(120).
The above may look quite difficult, but it's not that bad. Furthermore we may actually implement it just once: Windows supports only one mouse pointer and that implies there is never more then one vertical wheel and one horizontal wheel. Therefore we can use global variables for the accumulated values and one wrapping function dealing with them instead of bloating per-control data structures and reimplementing it in each window procedure.
The code of such function may look as follows:
static CRITICAL_SECTION csWheelLock;
int
WheelScrollLines(HWND hwnd, int iDelta, UINT nPage, BOOL isVertical)
{
static HWND hwndCurrent = NULL; static int iAccumulator[2] = { 0, 0 }; static DWORD dwLastActivity[2] = { 0, 0 };
UINT uSysParam;
UINT uLinesPerWHEELDELTA; int iLines; int iDirIndex = (isVertical ? 0 : 1); DWORD dwNow;
dwNow = GetTickCount();
if (nPage < 1)
nPage = 1;
uSysParam = (isVertical ? SPI_GETWHEELSCROLLLINES : SPI_GETWHEELSCROLLCHARS);
if (!SystemParametersInfo(uSysParam, 0, &uLinesPerWHEELDELTA, 0))
uLinesPerWHEELDELTA = 3; if (uLinesPerWHEELDELTA == WHEEL_PAGESCROLL) {
uLinesPerWHEELDELTA = nPage;
}
if (uLinesPerWHEELDELTA > nPage) {
uLinesPerWHEELDELTA = nPage;
}
EnterCriticalSection(&csWheelLock);
if (hwnd != hwndCurrent) {
hwndCurrent = hwnd;
iAccumulator[0] = 0;
iAccumulator[1] = 0;
} else if (dwNow - dwLastActivity[iDirIndex] > GetDoubleClickTime() * 2) {
iAccumulator[iDirIndex] = 0;
} else if ((iAccumulator[iDirIndex] > 0) == (iDelta < 0)) {
iAccumulator[iDirIndex] = 0;
}
if (uLinesPerWHEELDELTA > 0) {
iAccumulator[iDirIndex] += iDelta;
iLines = (iAccumulator[iDirIndex] * (int)uLinesPerWHEELDELTA) / WHEEL_DELTA;
iAccumulator[iDirIndex] -= (iLines * WHEEL_DELTA) / (int)uLinesPerWHEELDELTA;
} else {
iLines = 0;
iAccumulator[iDirIndex] = 0;
}
dwLastActivity[iDirIndex] = dwNow;
LeaveCriticalSection(&csWheelLock);
return (isVertical ? -iLines : iLines);
}
Notes:
- First of all, remember the word "line" in the function name and in names of some variables refers rather to general "scrolling units" and not necessarily any real lines in this context. This naming comes from the standard symbolic names for scrolling one scrolling unit up or down (
SB_LINEUP
and SB_LINEDOWN
), or left or right (SB_LINELEFT
and SB_LINERIGHT
). Sorry for the terminology mess, but "scrolling units" is simply too much typing for someone as lazy as me... - If the function is used in an application where different
HWND
s are living in multiple threads, it has to be thread-safe to protect the state described by the multiple static variables. Hence the use of CRITICAL_SECTION
. - We reset the accumulators in certain situations: When
HWND
changes, when some longer time expires without the wheel activity or when user starts scrolling to the opposite direction. You may notice the period of inactivity is compared to a time period based on GetDoubleClickTime()
. I chose to use that because the double-click time is used in Windows as a measure how good your reflexes are. - For historic reasons, the delta value for the vertical wheel is provided with opposite sign then most people expect, and in the opposite sense in comparison to the horizontal wheel. To simplify the code we deal with that on the single spot: the last line of the function.
The function WheelScrollLines()
is quite generic and reusable. Actually one could even think such function should be implemented in some standard Win32API library. That would at least provide better guaranty that mouse wheels are used consistently by default among applications. But AFAIK it is not, at least not a publicly exported one.
Usage of the function is very straightforward:
static void
CustomHandleMouseWheel(HWND hwnd, int iDelta, BOOL isVertical)
{
SCROLLINFO si;
int nPos;
int nOldPos;
si.cbSize = sizeof(SCROLLINFO);
si.fMask = SIF_PAGE | SIF_POS;
GetScrollInfo(hwnd, (isVertical ? SB_VERT : SB_HORZ), &si);
nOldPos = si.nPos;
nPos = nOldPos + WheelScrollLines(pData->hwnd, iDelta, si.nPage, isVertical);
nPos = SetScrollPos(hwnd, (isVertical ? SB_VERT : SB_HORZ), nPos);
ScrollWindowEx(hwnd,
(isVertical ? 0 : (nOldPos - nPos) * scrollUnit),
(isVertical ? (nOldPos - nPos) * scrollUnit, 0),
NULL, NULL, NULL, NULL, SW_ERASE | SW_INVALIDATE);
}
static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch(uMsg) {
...
case WM_MOUSEWHEEL:
CustomHandleMouseWheel(hwnd, HIWORD(wParam), TRUE);
return 0;
case WM_MOUSEHWHEEL:
CustomHandleMouseWheel(hwnd, HIWORD(wParam), FALSE);
return 0;
...
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
Examples for Download
This time, there are two example projects available for download. You may find links to both of them at the very top of this article.
The simpler allows only vertical scrolling, but otherwise corresponds roughly to all the code provided in the article.
Screenshot of the simple demo
The 2nd (and more complex) example does scrolling in vertical as well as horizontal direction, it has a dynamically changing contents so that nMin
and nMax
change throughout lifetime of the control, it presents usage of non-trivial scrolling units and last but not least, it shows more advanced use of the function ScrollWindowEx()
which scrolls only part of the window to keep the headers of columns and rows always visible.
Screenshot of the more complex demo
Real World Code
In this series, it's already tradition to provide also some links to real-life code demonstrating the topic of the article.
To get better insight, you might find very useful to study the (re)implementation of the scrollbar support in Wine. I especially recommend to pay attention to the function SCROLL_SetScrollInfo()
which implements core of the SetScrollInfo()
:
And, of course, some Wine controls using the implicit scrollbars:
Finally, also few mCtrl controls using it:
Next Time: More About Non-Client Area
Today, we discussed how to implement scrolling support in a control. During the journey, we have lightly touched the topic of non-client area as that's where the scrollbars are living. Next time, we will take a look how to customize a little bit the non-client area, and how to paint in it.