65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.83/5 (7投票s)

2021年8月4日

CPOL

10分钟阅读

viewsIcon

15465

downloadIcon

355

深入研究在控件中组合滚动和缩放的复杂性。

引言

WPF ScrollBar 背后的概念看起来足够简单,但在实践中使用它却出奇地具有挑战性。通常,开发人员使用 ScrollViewer 为控件添加滚动功能,很少直接使用 ScrollBar。如果控件的所有内容都可以绘制,但只显示其中一部分,那么 Scrollviewer 非常有用。然而,当需要呈现大量数据时,最好只将可见部分直接渲染到屏幕上,并使用滚动条让用户控制滚动。

友情提示:本文涵盖了使用 ScrollBar 时必须了解的许多技术细节。如果您对细节不感兴趣,本文不适合您。但是,您可以直接跳到示例应用程序,它精美地展示了当任意两种完全饱和的 WPF 颜色混合时会发生什么。

用例

当文档或图形或...的全部内容无法在 WPF 控件中显示时,会使用 ScrollBar。它允许用户查看不同的部分。假设有一个包含 49 行的文档,但控件只能显示 10 行。

在这里,用户已滚动到第 10 行,屏幕显示第 10 到 19 行。

如何设置滚动条

到目前为止,听起来简单明了。但是你必须如何设置 ScrollbarScrollBar 的主要属性是它的 double 类型 Value 属性,用户可以通过鼠标上下移动深色矩形(=Viewport)来改变它。

还有一个 MaximumMinimum 属性,它们限制了 Value 可以拥有的值。还有一个属性是 ViewportSize,它显然定义了 Viewport 的高度。

最重要的决定之一是:scrollbar 值应该意味着什么?

诱人的是

  • 最低 = 0
  • Maximum = 显示所有内容所需的垂直方向上的总像素数
  • Value = 从控件内容开始的像素偏移量
  • ViewportSize = 控件中可用于显示内容的像素数

然而,如果同时实现缩放,这是一个糟糕的主意,因为当按因子 2 放大时

  • 需要两倍的像素来显示内容
  • 偏移量需要加倍才能显示相同的行
  • ViewportSize 缩小一半

我学到的一个惨痛教训是:如果你想避免大麻烦,那么不要将滚动条的值基于缩放时会变化的像素

一个更好的主意是赋予 scrollbar 的值一个相对于内容长度的意义

  • 最低 = 0
  • Maximum = 内容的总“行数”
  • 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;
}

如果 ViewportSizeMaximum 获得新值,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 进行缩放,但更方便的可能只是一个 ZoomInZoomOut 按钮。许多应用程序提供线性缩放(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 不应因缩放而改变。但 ViewportSizeMaximum 会改变。

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;

你能找出问题吗?假设 Maximum100Value80。将 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,Value5Maximum9。更改 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 开始时较窄,但当显示越来越宽的行时会变宽。

由于计算正确宽度很耗时,我只想计算一次。在 MeasureOverrideArrangeOverride 中不需要,因为控件需要所有可用空间,无论内容如何,所以我将代码放在 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日:初始版本
© . All rights reserved.