If you write your own WPF control and its content is big, you need to implement scrolling and zooming. Normally, it is quite convenient to use ScrollViewer for this, but when the content is truly big, it is more efficient to use ScrollBar and write the selected content directly to the screen by overwriting the control's OnRender(). When I started using ScrollBar, I struggled for weeks to get it right. In this article, I like to share some best practices so that you can get your ScrollBar up and running perfectly in no time.
Introduction
The concept behind a WPF ScrollBar
seems easy enough, but using it in practice is surprisingly challenging. Normally, developers use a ScrollViewer
to add scrolling to a control and have little experience using a ScrollBar
directly. Scrollviewer
s are great if all the content of the control can be drawn, but only a part of it is shown. However, when huge amount of data needs to be presented, it might be better to render directly to the screen only what can be seen and using scrollbars to let the user control the scrolling.
Fair warning: This article covers many technical details, which are crucial to know when using a
ScrollBar
. If you are not interested in details, this article is not for you. However, you might jump directly to the
Sample Application, which displays beautifully what happens when any two fully saturated WPF colours get mixed.
UseCase
A ScrollBar
is used when not all content of a document or graphic or ... can be displayed in a WPF control. It allows the user to see different parts. Let's assume there is a document with 49 lines, but the control can display only 10 lines:
Here, the user has scrolled to the 10th line and the screen shows lines 10..19.
How to Setup a Scrollbar
So far, it sounds simple and obvious. But how do you have to set up the Scrollbar
? The main property of the ScrollBar
is its double
Value
property, which the user can change with the mouse by moving the dark rectangle (=Viewport
) up and down.
There is also a Maximum
and a Minimum
property, which limit which value Value
can have. One more property is ViewportSize
, which, obviously, defines the height of the Viewport
.
One of the most important decisions is: What should the scrollbar
values mean?
Tempting would be:
Minimum
= 0 Maximum
= the total number of pixels in the vertical direction needed to display all content Value
= the pixel offset from the start of the control's content ViewportSize
= the number of pixels available in the control to display the content
However, this is a bad idea, if also zooming gets implemented, because when zooming in by the factor of 2
:
- twice as many pixels are needed to display the content
- the offset needs to get doubled to show the same line
ViewportSize
shrinks by half
A painful lesson I learned: If you want to avoid a major headache, then do not base the scrollbar's value on pixels which change when zooming !
A better idea is to give the scrollbar
's value a meaning relative to the length of the content:
Minimum
= 0 Maximum
= the total number of "lines" of the content Value
= the line offset from the start of the control's content ViewportSize
= the number of lines the control can show
With this setup, zooming in by 2 will only half the ViewportSize
, the other scrollbar
values stay the same.
Intuitively, the scrollbar
setup for the sample use case above might look like this:
Minimum = 0;
ViewportSize= 10;
Maximum = Document.LineCount;
However, that would not work. :-(
Because when the user scrolls down completely, the control wants to display lines 49-58, which might throw an exception when it tries to fetch those lines from the document.
Once the user scrolls to the end, the situation looks like this:
The Maximum
value must be 40
, even the document has 49 lines !
Minimum = 0;
ViewportSize= 10;
Maximum = Document.LineCount - ViewPort;
That's it? Obviously not, otherwise it would not be worth writing an article about it.
ViewportSize
is not constant. It depends on how many lines can be displayed on the screen.
Minimum = 0;
ViewportSize= ActualHeight / PixelPerLine;
Maximum = Document.LineCount - ViewportSize;
Even if we assume that PixelPerLine
is constant (i.e., no zooming), ActualHeight
can change. This change gets reported to the Control
in its SizeChanged
event:
private void MyControl_SizeChanged(object sender, SizeChangedEventArgs e) {
Minimum = 0;
ViewportSize= ActualHeight / PixelPerLine;
Maximum = Document.LineCount - ViewportSize;
}
If ViewportSize
and Maximum
get new values, does Value
itself need to change too? Luckily not, because the offset does not change. The control still displays the same line first, it just displays a different number of lines.
How does the control know that it needs to redraw itself ? When the available size changes, WPF calls MyControl.OnRender()
automatically, which redraws the control content. But if the user just scrolls, the code has to tell that a redraw is necessary by calling MyControl.InvalidateVisual().
private void ScrollBar_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) {
Offset = VerticalScrollBar.Value;
}
public int Offset {
get {
return offset;
}
set {
if (offset!=value) {
offset = value;
VerticalScrollBar.Value = offset;
InvalidateVisual();
}
}
}
int offset;
So far so good. But adding Zoom increases the difficulties by a magnitude.
Zoom
If the content is big, it might be helpful for the user if he can zoom out so that he can see more of the content, although in a smaller font. Of course, he also needs to be able to do the reverse, to zoom in, to undo zooming out or to see more details.
One could use another ScrollBar
for zooming, but more convenient might be just a ZoomIn
and Zoomout
button. Many applications provide a linear zoom (100%, 200%, 300%, 400%). but actually (100%, 200%, 400%, 800%) would make more sense. Pressing the ZoomIn
button keeps increasing the ZoomFactor
by 2, ZoomOut
decreases by 2.
When the user changes the ZoomFactor
, PixelPerLine
needs to change too. Since the ScrollBar
properties need to change when the available screen size or the zoom factor change, we remove MyControl_SizeChanged
and put the scrollBar
setup code into ArrangeOverride
:
private void ZoomInButton_Click(object sender, RoutedEventArgs e) {
ZoomFactor *= 2;
}
private void ZoomOutButton_Click(object sender, RoutedEventArgs e) {
ZoomFactor /= 2;
}
public double ZoomFactor {
get { return zoomFactor; }
set {
if (zoomFactor!=value) {
zoomFactor=value;
InvalidateVisual();
}
}
}
double zoomFactor;
protected override Size ArrangeOverride(Size arrangeBounds) {
scrollBar.ViewportSize = scrollBar.LargeChange = ActualHeight / PixelPerLine / zoomFactor;
scrollBar.SmallChange = scrollBar.LargeChange / 10;
scrollBar.Maximum = maxColorIndex - scrollBar.ViewportSize;
return arrangeBounds;
}
The actual painting of the content is done in the ArrangeOverride
method. This code can be complicated because it should paint only what the user has selected, which is different from application to application and not part of this article, but you can see how it is done in the downloadable code of the sample application below.
Using Pixels for ScrollBar Properties Revisited
Above I wrote: "do not base the scrollbar's value on pixels which change when zooming!". What I meant is: One can use pixels for the ScrollBar
properties, but when zooming in or out, do not change ScrollBar.Value
. Reason being that when the user scrolls to half the length of the content Value
becomes 50%. When zooming in by 2, the content displayed should start still at exactly the same place, 50%. The ScrollBar.Value
should not change because of zooming. But ViewportSize
and Maximum
change:
ViewportSize= ActualHeight / ZoomFactor;
Maximum = Document.PixelCount - ViewportSize;
When zooming in by 2, the ViewportSize
shrinks by half, because now only fewer content can be displayed, but bigger. Note that Maximum
is based on how many pixels would be needed to display all the content at ZoomFactor 1
.
When painting the content to the screen, the pixel offset must be calculated as ScrollBar.Value * ZoomFactor
.
Sample Application
The ScrollBar
Sample Application shows all combinations of any two fully saturated WPF colors. Fully saturated colors are like red or green without any white added to it (for more details, see my article Definitive Guide to WPF Colors, Color Spaces, Color Pickers and Creating Your Own Colors for Mere Mortals.
Drawing all saturated colours in a line looks like a rainbow. The sample app has a horizontal rainbow and a vertical rainbow. Any pixel at x, y in the graphic is the result from mixing the color at x of the horizontal rainbow and the color at y of the vertical rainbow.
There are six * 0x100 fully saturated colors, meaning the vertical rainbow needs 1536 pixels with zoom factor = 1. Since the control is smaller than that, it has a vertical scrollbar which allows to scroll the vertical rainbow. It would make sense to add also a horizontal scrollbar, but this would overcomplicate the sample code unnecessarily.
You can download the application from here.
Interesting is how I combined in one Control
writing directly to the screen and hosting a ScrollBar
and two buttons.
Bonus Chapter: Deep Dive into 2 ScrollBar Gotchas
If you have read until here, you might be genuinely interested in more details how the Scrollbar
works. If you are, continue reading.
Changing Maximum or Minimum Might Also Change Value
A surprise to me was that changing the Maximum
value can also change Value
, reason being that the ScrollBar
always guarantees that:
Minimum <= Value <= Maximum.
This poses a difficulty when the ScrollBar
values are based on display pixels instead following the recommendation above. When the user wants to scroll to position xxx with ZoomFactor = 1
, the ScrollBar
properties can be calculated like this:
Minimum = 0;
ViewportSize= ActualHeight;
Maximum = Document.LineCount * PixelPerLine - ViewportSize;
Value = xxx;
When zooming in by 2, the properties become:
Minimum = 0;
ViewPort = ActualHeight;
Maximum = Document.LineCount * PixelPerLine * 2 - ViewPort;
Value *= 2;
However, the following lines of code will not work for zooming out by 2:
Minimum = 0;
ViewportSize= ActualHeight;
Maximum = Document.LineCount * PixelPerLine /2 - ViewportSize;
Value /= 2;
Can you figure out the problem ? Let's say Maximum
is 100
and Value
is 80
. Changing Maximum
to the new value which will be slightly less than 50
, let's say 48
, which also changes Value
to 48
! Then Value
gets divided by 2
again and becomes 24
instead of the expected 40
!
With this setup, for zooming in one has to assign first Maximum
, but for zooming out one has to assign first Value
.
The attentive reader might object now that Maximum
can also change Value
if the recommended code is used:
ViewportSize= ActualHeight / PixelPerLine;
Maximum = Document.LineCount - ViewportSize;
Let's assume the user has scrolled in to ZoomFactor
4 and scrolled to the end:
ViewportSize: 25%;
Maximum: 75%;
Value: 75%;
If the user zooms out to ZoomFactor
2, the code calculates the following scrollbar values:
ViewportSize: 50%;
Maximum: 50%;
Value: ??
The code does not explicitly change Value
, but changing Maximum
to 50% will change Value
automatically to 50%. This is lucky, because if it would not happen automatically, we would have to write additional code to set Value
to 50%. It is not possible that the offset stays 75%, when half the content fits into the control.
Actually, it gets even stranger. Assuming Minimum
is 0, Value
is 5
and Maximum
is 9
. Changing Maximum
will change Value
automatically as follows:
Maximum: 9, Value: 5
Maximum: 3, Value: 3
Maximum: 4, Value: 4
Maximum: 5, Value: 5
Maximum: 6, Value: 5
Maximum: 7, Value: 5
It seems Value
remembers its old value and when Maximum
increases again, it tries to go back to its old value.
Problems with OnRender Controlling the ScrollBar
I was writing an application which was supposed to display files containing megabytes of formatted text. First, I tried to use a RichTextBox
, which took literally forever to display the content and I had to end the debugging session. A RichTextBox
wants to process the whole file, which obviously takes too long. Since the files needed only some simple formatting like bold and only 1 fontsize, I decided to write my own control. The location of each Carriage Returns showed where a new line starts and I used the line number as offset for my vertical scrolling. So far so good. But the lines could also be randomly wide and this was very time consuming to determine, because each character has a different width and it also changes if it is bold.
So I thought I use the same approach like other applications where the horizontal ScrollBar
is narrow in the beginning, but then grows when wider and wider lines get displayed.
Since calculating the correct width is time consuming, I wanted to do it only once. It was not needed in MeasureOverride
or ArrangeOverride
, because the Control wanted all the space available regardless of the content, so I placed the code in OnRender
, where rendering requires that the position of each character gets calculated anyway. There the code discovered the widest line and adjusted the horizontalScrollBar.Maximum
accordingly. The code looked something like that:
private void VerticalScrollbar_ValueChanged
(object sender, RoutedPropertyChangedEventArgs<double> e) {
InvalidateVisual();
}
double maxLineWidth;
protected override void OnRender(DrawingContext drawingContext) {
var maxWidth = 0.0;
for (int line = offset; line < offset+displayLines; line++) {
maxWidth = Math.Max(maxWidth, drawLine(Document[line]));
}
if (maxLineWidth<maxWidth) {
maxLineWidth = maxWidth;
setupVerticalScrollbar();
}
}
private void setupVerticalScrollbar() {
VerticalScrollbar.Maximum = maxLineWidth - VerticalScrollbar.ViewportSize;
}
The code seemed to work mostly, but sometimes the control should scroll but didn't. The control showed a different part of the content than it was supposed to. Of course, I thought this is a problem of ScrollBar.Value
. So I debugged and found that VerticalScrollbar.Maximum
became in some cases smaller than VerticalScrollbar.Value
, which correctly raised VerticalScrollbar_ValueChanged
. But OnRender
did not run again. It took me quite long to figure out why. The call chain looked something like that:
OnRender()
setupVerticalScrollbar()
VerticalScrollbar_ValueChanged()
InvalidateVisual()
InvalidateVisual()
sets a flag which tells WPF to call control.OnRender()
. Once OnRender()
completes, WPF resets that flag, meaning calling InvalidateVisual()
in VerticalScrollbar_ValueChanged()
had no effect, because that flag was already set and got cleared once OnRender()
finished. After that, WPF didn't know it should call OnRender()
again.
Lesson to be learned: Better not put code controlling ScrollBar
in OnRender()
, because the ScrollBar
code might need to initiate a new rendering.
History
- 4th August, 2021: Initial version