Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / desktop / WPF

Advanced Guide to WPF ScrollBar or How to Display Millions of Colour Combinations

3.83/5 (7 votes)
3 Aug 2021CPOL10 min read 14.4K   199  
Deep dive into the intricacies of combining scrolling and zooming in your control
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. Scrollviewers 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:

Image 1

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:

C#
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:

Image 2

The Maximum value must be 40, even the document has 49 lines !

C#
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.

C#
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:

C#
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().

C#
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.

Image 3

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:

C#
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:

C#
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.

Image 4

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:

C#
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:

C#
Minimum = 0;
ViewportSize= ActualHeight; 
Maximum = Document.LineCount * PixelPerLine - ViewportSize;
Value = xxx;

When zooming in by 2, the properties become:

C#
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:

C#
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:

C#
ViewportSize= ActualHeight / PixelPerLine; 
Maximum = Document.LineCount - ViewportSize;

Let's assume the user has scrolled in to ZoomFactor 4 and scrolled to the end:

C#
ViewportSize: 25%;
Maximum: 75%; 
Value: 75%;

If the user zooms out to ZoomFactor 2, the code calculates the following scrollbar values:

C#
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:

C#
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:

C#
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

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)