WPF 滚动条高级指南或如何显示数百万种颜色组合






3.83/5 (7投票s)
深入研究在控件中组合滚动和缩放的复杂性。
引言
WPF ScrollBar
背后的概念看起来足够简单,但在实践中使用它却出奇地具有挑战性。通常,开发人员使用 ScrollViewer
为控件添加滚动功能,很少直接使用 ScrollBar
。如果控件的所有内容都可以绘制,但只显示其中一部分,那么 Scrollviewer
非常有用。然而,当需要呈现大量数据时,最好只将可见部分直接渲染到屏幕上,并使用滚动条让用户控制滚动。
ScrollBar
时必须了解的许多技术细节。如果您对细节不感兴趣,本文不适合您。但是,您可以直接跳到示例应用程序,它精美地展示了当任意两种完全饱和的 WPF 颜色混合时会发生什么。用例
当文档或图形或...的全部内容无法在 WPF 控件中显示时,会使用 ScrollBar
。它允许用户查看不同的部分。假设有一个包含 49 行的文档,但控件只能显示 10 行。
在这里,用户已滚动到第 10 行,屏幕显示第 10 到 19 行。
如何设置滚动条
到目前为止,听起来简单明了。但是你必须如何设置 Scrollbar
?ScrollBar
的主要属性是它的 double
类型 Value
属性,用户可以通过鼠标上下移动深色矩形(=Viewport
)来改变它。
还有一个 Maximum
和 Minimum
属性,它们限制了 Value
可以拥有的值。还有一个属性是 ViewportSize
,它显然定义了 Viewport
的高度。
最重要的决定之一是:scrollbar
值应该意味着什么?
诱人的是
最低
= 0Maximum
= 显示所有内容所需的垂直方向上的总像素数Value
= 从控件内容开始的像素偏移量ViewportSize
= 控件中可用于显示内容的像素数
然而,如果同时实现缩放,这是一个糟糕的主意,因为当按因子 2
放大时
- 需要两倍的像素来显示内容
- 偏移量需要加倍才能显示相同的行
ViewportSize
缩小一半
我学到的一个惨痛教训是:如果你想避免大麻烦,那么不要将滚动条的值基于缩放时会变化的像素!
一个更好的主意是赋予 scrollbar
的值一个相对于内容长度的意义
最低
= 0Maximum
= 内容的总“行数”Value
= 从控件内容开始的行偏移量ViewportSize
= 控件可以显示的行数
通过这种设置,放大 2 倍只会使 ViewportSize
减半,而其他 scrollbar
值保持不变。
直观地看,上述示例用例的 scrollbar
设置可能看起来像这样
Minimum = 0;
ViewportSize= 10;
Maximum = Document.LineCount;
然而,那样行不通。:-(
因为当用户完全向下滚动时,控件会尝试显示第 49-58 行,这可能会在它尝试从文档中获取这些行时抛出异常。
一旦用户滚动到末尾,情况就会是这样
Maximum
值必须是 40
,即使文档有 49 行!
Minimum = 0;
ViewportSize= 10;
Maximum = Document.LineCount - ViewPort;
就是这样吗?显然不是,否则就不值得为此写一篇文章了。
ViewportSize
不是常数。它取决于屏幕上可以显示多少行。
Minimum = 0;
ViewportSize= ActualHeight / PixelPerLine;
Maximum = Document.LineCount - ViewportSize;
即使我们假设 PixelPerLine
是常数(即没有缩放),ActualHeight
也会改变。这种改变会在控件的 SizeChanged
事件中报告给 Control
。
private void MyControl_SizeChanged(object sender, SizeChangedEventArgs e) {
Minimum = 0;
ViewportSize= ActualHeight / PixelPerLine;
Maximum = Document.LineCount - ViewportSize;
}
如果 ViewportSize
和 Maximum
获得新值,Value
本身是否也需要改变?幸运的是不需要,因为偏移量没有改变。控件仍然首先显示相同的行,它只是显示不同数量的行。
控件如何知道它需要重新绘制?当可用大小改变时,WPF 会自动调用 MyControl.OnRender()
,这会重新绘制控件内容。但是如果用户只是滚动,代码必须通过调用 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;
到目前为止一切顺利。但是添加缩放会使难度增加一个数量级。
缩放
如果内容很大,用户如果能缩小以便看到更多内容(尽管字体更小),可能会有所帮助。当然,他还需要能够反向操作,即放大,以撤消缩小或查看更多细节。
可以使用另一个 ScrollBar
进行缩放,但更方便的可能只是一个 ZoomIn
和 ZoomOut
按钮。许多应用程序提供线性缩放(100%、200%、300%、400%),但实际上(100%、200%、400%、800%)更有意义。按下 ZoomIn
按钮会使 ZoomFactor
增加 2 倍,ZoomOut
会使 ZoomFactor
减少 2 倍。
当用户更改 ZoomFactor
时,PixelPerLine
也需要更改。由于 ScrollBar
属性在可用屏幕尺寸或缩放因子更改时需要更改,我们移除了 MyControl_SizeChanged
并将 scrollBar
设置代码放入 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;
}
内容的实际绘制是在 ArrangeOverride
方法中完成的。此代码可能很复杂,因为它应该只绘制用户选择的内容,这因应用程序而异,不属于本文的范围,但您可以在下面示例应用程序的可下载代码中看到它是如何完成的。
重新审视使用像素作为滚动条属性
上面我写道:“不要将滚动条的值基于缩放时会变化的像素!”。我的意思是:可以将像素用于 ScrollBar
属性,但在放大或缩小时,不要更改 ScrollBar.Value
。原因在于当用户滚动到内容长度的一半时,Value
变为 50%。当放大 2 倍时,显示的内容仍应从完全相同的位置(50%)开始。ScrollBar.Value
不应因缩放而改变。但 ViewportSize
和 Maximum
会改变。
ViewportSize= ActualHeight / ZoomFactor;
Maximum = Document.PixelCount - ViewportSize;
当放大 2 倍时,ViewportSize
会缩小一半,因为现在只能显示更少但更大的内容。请注意,Maximum
是基于在 ZoomFactor 1
时显示所有内容所需的像素数。
将内容绘制到屏幕上时,像素偏移量必须计算为 ScrollBar.Value * ZoomFactor
。
示例应用程序
ScrollBar
示例应用程序展示了任意两种完全饱和的 WPF 颜色的所有组合。完全饱和的颜色类似于红色或绿色,其中未添加任何白色(有关更多详细信息,请参阅我的文章WPF 颜色、颜色空间、颜色选择器以及为普通人创建自己颜色的权威指南)。
将所有饱和颜色绘制成一条线看起来像一道彩虹。示例应用程序有一个水平彩虹和一个垂直彩虹。图形中 x,y 处的任何像素都是水平彩虹中 x 处的颜色和垂直彩虹中 y 处的颜色混合的结果。
有六种 * 0x100 种完全饱和的颜色,这意味着在缩放因子 = 1 时,垂直彩虹需要 1536 像素。由于控件小于此尺寸,它有一个垂直滚动条,允许滚动垂直彩虹。添加一个水平滚动条会更有意义,但这会不必要地使示例代码过于复杂。
您可以从此处下载应用程序。
有趣的是我如何在一个 Control
中将直接写入屏幕与托管 ScrollBar
和两个按钮结合起来。
奖励章节:深入探讨滚动条的两个陷阱
如果您已读到此处,您可能真正对 Scrollbar
的工作原理的更多细节感兴趣。如果是,请继续阅读。
更改 Maximum 或 Minimum 也可能更改 Value
让我惊讶的是,更改 Maximum
值也可以更改 Value
,原因是 ScrollBar
始终保证
Minimum <= Value <= Maximum.
当 ScrollBar
值基于显示像素而不是遵循上述建议时,这会带来困难。当用户希望在 ZoomFactor = 1
的情况下滚动到 xxx 位置时,ScrollBar
属性可以这样计算:
Minimum = 0;
ViewportSize= ActualHeight;
Maximum = Document.LineCount * PixelPerLine - ViewportSize;
Value = xxx;
当放大 2 倍时,属性变为
Minimum = 0;
ViewPort = ActualHeight;
Maximum = Document.LineCount * PixelPerLine * 2 - ViewPort;
Value *= 2;
然而,以下代码行不适用于缩小 2 倍
Minimum = 0;
ViewportSize= ActualHeight;
Maximum = Document.LineCount * PixelPerLine /2 - ViewportSize;
Value /= 2;
你能找出问题吗?假设 Maximum
是 100
,Value
是 80
。将 Maximum
更改为略小于 50
的新值,比如 48
,这也会将 Value
更改为 48
!然后 Value
再次除以 2
,变为 24
,而不是预期的 40
!
通过这种设置,对于放大,必须先分配 Maximum
,但对于缩小,必须先分配 Value
。
细心的读者现在可能会反对说,如果使用推荐的代码,Maximum
也可以改变 Value
。
ViewportSize= ActualHeight / PixelPerLine;
Maximum = Document.LineCount - ViewportSize;
假设用户已放大到 ZoomFactor
4 并滚动到末尾。
ViewportSize: 25%;
Maximum: 75%;
Value: 75%;
如果用户缩小到 ZoomFactor
2,代码会计算以下滚动条值:
ViewportSize: 50%;
Maximum: 50%;
Value: ??
代码没有明确改变 Value
,但将 Maximum
改变为 50% 会自动将 Value
改变为 50%。这是幸运的,因为如果它不是自动发生的,我们就必须编写额外的代码将 Value
设置为 50%。当一半的内容适合控件时,偏移量不可能保持 75%。
实际上,它变得更加奇怪。假设 Minimum
是 0,Value
是 5
,Maximum
是 9
。更改 Maximum
将自动更改 Value
如下:
Maximum: 9, Value: 5
Maximum: 3, Value: 3
Maximum: 4, Value: 4
Maximum: 5, Value: 5
Maximum: 6, Value: 5
Maximum: 7, Value: 5
似乎 Value
记住其旧值,当 Maximum
再次增加时,它会尝试返回其旧值。
OnRender 控制 ScrollBar 的问题
我当时正在编写一个应用程序,它应该显示包含数兆字节格式化文本的文件。起初,我尝试使用 RichTextBox
,它花费了很长时间才显示内容,我不得不结束调试会话。RichTextBox
想要处理整个文件,这显然太耗时了。由于这些文件只需要一些简单的格式,如粗体,并且只有 1 种字体大小,所以我决定编写自己的控件。每个回车符的位置显示新行的开始位置,我使用行号作为垂直滚动的偏移量。到目前为止一切顺利。但是行宽也可能随机变化,这非常耗时,因为每个字符的宽度都不同,而且如果它是粗体,宽度也会改变。
所以我想到使用与其他应用程序相同的方法,即水平 ScrollBar
开始时较窄,但当显示越来越宽的行时会变宽。
由于计算正确宽度很耗时,我只想计算一次。在 MeasureOverride
或 ArrangeOverride
中不需要,因为控件需要所有可用空间,无论内容如何,所以我将代码放在 OnRender
中,因为渲染无论如何都需要计算每个字符的位置。在那里,代码发现了最宽的行并相应地调整了 horizontalScrollBar.Maximum
。代码看起来像这样:
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;
}
代码似乎大部分都有效,但有时控件应该滚动却没滚动。控件显示的内容部分与预期不同。当然,我认为这是 ScrollBar.Value
的问题。所以我调试并发现 VerticalScrollbar.Maximum
在某些情况下变得小于 VerticalScrollbar.Value
,这正确地触发了 VerticalScrollbar_ValueChanged
。但是 OnRender
没有再次运行。我花了很长时间才弄清楚为什么。调用链看起来像这样:
OnRender()
setupVerticalScrollbar()
VerticalScrollbar_ValueChanged()
InvalidateVisual()
InvalidateVisual()
设置一个标志,告诉 WPF 调用 control.OnRender()
。一旦 OnRender()
完成,WPF 会重置该标志,这意味着在 VerticalScrollbar_ValueChanged()
中调用 InvalidateVisual()
没有效果,因为该标志已经设置并在 OnRender()
完成后被清除。之后,WPF 不知道它应该再次调用 OnRender()
。
经验教训:最好不要将控制 ScrollBar
的代码放在 OnRender()
中,因为 ScrollBar
代码可能需要启动新的渲染。
历史
- 2021年8月4日:初始版本