避免 WPF DataGrid 的限制,用几行您自己的代码替换它






4.95/5 (8投票s)
深入探讨当 DataGrid 不够用时,如何轻松地以您想要的方式显示数据。
引言
我需要编写一个财务资产负债表窗口,显示几年的财务数据,允许用户水平和垂直滚动,同时最左侧列和最顶部的行的标签始终显示,类似于这样:
正如您所见,网格是“转置”的,它有可变的列数和固定的行数。随着年份的增加,会添加一列。WPF 的 DataGrid
开箱即用并不支持这一点,尽管 StackOverflow 上有一些建议,可以通过旋转 DataGrid
然后将每个单元格旋转回来。我更喜欢一种更简单的方法。在屏幕上显示一堆数字有多难?事实证明,这非常简单,我发现仅用 30 行 C# 代码就可以显示数据,再多几行就可以处理高级用户交互。
- 固定标题行和列,即只有财务数据会滚动
- 数据格式不同
- 调整大小,即网格最大限度地利用可用屏幕空间
- 行分组,用户可以折叠
- 用户可以深入展开一个新
Window
,显示该账户和年份的财务明细。
本文详细介绍了设计和实现此类布局时所采取的各种步骤。
XAML 代码
我知道,有些人认为 GUI 主要应该由 XAML 代码组成,而 C# 代码应尽可能少。我的看法不同。XAML 缺少任何编程语言都具备的基本功能,它甚至无法进行 1 + 1 的加法。我仅使用 XAML 来定义内容结构,例如包含 StackPanels
的 Grids
,以及图形设计相关事项,如字体和颜色。通常,我也会在 XAML 中定义 TextBlocks
,但在本项目中,我是在代码隐藏中创建它们的。
本项目的 XAML 代码很短,仅包含 Balance Sheet Window 的主要容器。
<Window x:Class="TransposedDataGrid.MainWindow"
Title="Balance Sheet" Height="300" Width="500"
Background="SlateGray" FontSize="12">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="auto"/>
</Grid.ColumnDefinitions>
<ScrollViewer Grid.Row="0" Grid.Column="1" x:Name="LabelsTopScrollViewer"
HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Disabled">
<StackPanel x:Name="LabelsTopStackPanel" Orientation="Horizontal"
Background="LightGray"/>
</ScrollViewer>
<ScrollViewer Grid.Row="1" Grid.Column="0" x:Name="LabelsLeftScrollViewer"
VerticalScrollBarVisibility="Hidden" VerticalAlignment="Top">
<StackPanel x:Name="LabelsLeftStackPanel" Background="LightGray"/>
</ScrollViewer>
<ScrollViewer Grid.Row="1" Grid.Column="1" x:Name="DataScrollViewer"
VerticalAlignment="Top" VerticalScrollBarVisibility="Hidden"
HorizontalScrollBarVisibility="Hidden" Background="White">
<StackPanel x:Name="DataStackPanel"
Orientation="Horizontal" Background="white"/>
</ScrollViewer>
<ScrollBar Grid.Row="1" Grid.Column="2" x:Name="VerticalScrollBar"/>
<ScrollBar Grid.Row="2" Grid.Column="1" x:Name="HorizontalScrollBar"
Orientation="Horizontal"/>
</Grid>
</Window>
滚动需求决定了容器结构
容器结构由窗口中需要不同滚动方式的区域定义。基本上,有五个区域的滚动方式不同。
- 年份标签行:水平滚动
- 账户标签行:垂直滚动
- 财务数据单元格:水平和垂直滚动
- 水平和垂直滚动条不滚动
在 WPF 中,使用 ScrollViewer
可以最轻松地实现滚动。ScrollViewer
为其子元素提供无限空间,并具有水平和垂直 ScrollBar
来控制显示其内容的哪个部分。很容易只使用一个 ScrollViewer
来处理所有内容,但这样标签也会滚动出视图。然而,它们必须保持在原位,否则很难确定财务数字属于哪个账户和年份。
因此,我为每个可滚动区域使用了三个 ScrollViewers
和两个 ScrollBars
,用户可以使用它们来滚动财务数据。
下一个问题是,应该使用哪个 WPF 容器来容纳显示数据的 TextBlocks
。令我惊讶的是,简单的 StackPanel
配合 Orientation.Vertical
实际上可以很好地对齐显示每一行,只要每个数据单元格使用相同数量的文本行。由于 ScrollViewer
提供无限空间,每个单元格的内容都可以显示在一行上。当然,带有 Orientation.Vertical
的 StackPanel
会自动将数据垂直对齐(=列)。
创建网格内容变得非常简单。这是伪代码:
private void fillGrid() {
loop over all account names //i.e. row headers
for each account name, add a TextBlock to LabelsLeftStackPanel
loop over every year //i.e. column
for each year, add a TextBlock to LabelsTopStackPanel //i.e. column headers
for each year, add a YearStackPanel, add it to DataStackPanel
for each financial figure of that year add a TextBlock to YearStackPanel
这不是很简单吗?TextBlocks
的格式设置也很简单,不像 XAML 中那样需要更多行,而且很难理解发生了什么。
(var padding, var fontWeight) = getFormatting(accountsIndex);
var dataTextBlock = new TextBlock {
Text = data.Accounts[yearIndex, accountsIndex].ToString("0.00"),
Padding = padding,
FontWeight = fontWeight,
TextAlignment = TextAlignment.Right,
Tag = yearIndex*1000 + accountsIndex
};
dataTextBlock.MouseLeftButtonUp += DataTextBlock_MouseLeftButtonUp;
yearStackPanel.Children.Add(dataTextBlock);
使用 C# 对象初始化器语法设置 TextBlock
的属性,使语法看起来类似于 XAML,但具有作为完整编程语言的所有功能的巨大优势。请注意 getFormatting()
方法的使用,它是 XAML Style
的一种替代,基于某些行数据分配 Padding
和 FontWeight
。
事件处理程序不能作为初始化器语法的一部分添加,因此它位于单独的代码行上。DataTextBlock_MouseLeftButtonUp
可用于为用户提供获取单个单元格更多信息的 (尽管我更喜欢使用 ToolTip
),或者用于“钻取”,即向用户显示该账户和年份的所有详细数据(财务分类账条目)在一个新的 Window
中,这意味着他可以看到该账户和年份的所有财务报表。
Tag
在 DataTextBlock_MouseLeftButtonUp
中用于标识点击了哪个数据单元格。然后可以在 data.Accounts[yearIndex, accountsIndex]
中轻松找到实际数据。
用方法替换样式
您可能已经注意到,行的格式根据以下条件不同:
- 粗体显示,如果它是汇总账户行,例如资产
- 第三个明细账户行之后有一个较大的边距,但仅当它后面不是汇总账户行时
如果您尝试使用 XAML 中的样式、触发器等来实现这一点,祝您好运。在代码隐藏中,这变得非常容易。我将代码放在自己的方法中,因为行标签和行数据使用相同的格式,这类似于在 Resources
中声明一个 Style
。
private (Thickness padding, FontWeight fontWeight) getFormatting(int accountsIndex) {
return Data.RowTypes[accountsIndex] switch {
RowTypeEnum.normal => (new Thickness(2, 1, 2, 1), FontWeights.Normal),
RowTypeEnum.normalLarge => (new Thickness(2, 1, 2, 5), FontWeights.Normal),
RowTypeEnum.total => (new Thickness(2, 1, 2, 7), FontWeights.Bold),
_ => throw new NotSupportedException(),
};
}
在此应用程序中,数据层(类 Data
)和表示层(类 MainWindow
)是分离的。数据层通过每个行的 RowTypes
属性告知表示层该行是普通行、需要与下一行拉开距离的普通行,还是显示总计账户的行。
使列标题的宽度与同一列中的数据单元格相同
起初,我认为要让所有标签和数据在屏幕上很好地对齐将是一个挑战,但实际上我只需要解决一个问题:显示四位数字的列标题比可以显示更长数字的数据单元格要窄。由于滚动方式不同,我无法为标签和数据使用一个 StackPanel
,这意味着我必须强制 YearStackPanel
的宽度等于显示该年份标签的 TextBox
的宽度。
private void YearStackPanel_SizeChanged(object sender, SizeChangedEventArgs e) {
var yearStackPanel = (StackPanel)sender;
var textBlock = (TextBlock)yearStackPanel.Tag;
textBlock.Width = yearStackPanel.ActualWidth;
}
现在想象一下在 XAML 中这样做,我不知道该怎么做。当然,可以将 YearStackPanel
的 width
绑定到年份标签的 TextBlock
,但需要许多这样的绑定,而且随着年份的增长,会增加一个。
协调滚动
还需要解决另一个问题。垂直 ScrollBar
的滚动需要在 LabelsLeftScrollViewer
和 DataScrollViewer
中执行。这听起来可能很容易,但有一个挑战:如果用户将鼠标悬停在账户列上并使用鼠标滚轮滚动 LabelsLeftScrollViewer
,则 DataScrollViewer
和 VerticalScrollBar
需要执行相同的滚动。同样,使用事件处理程序可以轻松实现这一点。
private void VerticalScrollBar_ValueChanged
(object sender, RoutedPropertyChangedEventArgs<double> e) {
LabelsLeftScrollViewer.ScrollToVerticalOffset(VerticalScrollBar.Value);
DataScrollViewer.ScrollToVerticalOffset(VerticalScrollBar.Value);
}
private void LabelsLeftScrollViewer_ScrollChanged(object sender, ScrollChangedEventArgs e) {
VerticalScrollBar.Value = e.VerticalOffset;
}
private void DataScrollViewer_ScrollChanged(object sender, ScrollChangedEventArgs e) {
VerticalScrollBar.Value = e.VerticalOffset;
}
注意:幸运的是,WPF 会阻止一个无限循环。
1) VerticalScrollBar_ValueChanged ->
2) DataScrollViewer_ScrollChanged ->
3) VerticalScrollBar_ValueChanged ->
4) ...
在 2) 中,分配给 VerticalScrollBar.Value
的新值与现有值相同,WPF 不会再次引发 VerticalScrollBar_ValueChanged
。
还有一个问题:ScrollBar
应该使用哪些值?ScrollBar
的工作原理很难解释,我写了一整篇文章来介绍它。
最简单的解决方案是,如果 VerticalScrollBar
设置如下:
private void DataStackPanel_SizeChanged(object sender, SizeChangedEventArgs e) {
setScrollBars();
}
private void setScrollBars() {
VerticalScrollBar.LargeChange =
VerticalScrollBar.ViewportSize = DataScrollViewer.ActualHeight;
VerticalScrollBar.Maximum =
DataStackPanel.ActualHeight - LabelsLeftScrollViewer.ActualHeight;
}
Value
:DataScrollViewer
的偏移量,即财务数据网格顶部有多少像素不显示。MinValue
:恒定值 0。垂直滚动从财务数据网格的顶部开始,即DataScrollViewer
的像素 0。ViewPortSize
:是VerticalScrollBar
中用户上下移动以滚动显示的灰色矩形的高度。我将其值设置为与LargeCharge
相同,即用户“翻页”时财务数据网格上下移动的像素数。1 页 = 显示在屏幕上的财务数据网格的垂直像素数,即DataScrollViewer
的高度。
最令人困惑的是 Maximum
的计算。人们会认为这是显示所有财务数据网格行所需的像素数。但事实并非如此。执行 DataScrollViewer.ScrollToVerticalOffset(VerticalScrollBar.Value)
只会显示像素的最后一行!而 DataScrollViewer
的大部分将是空白的。要正确设置 VerticalScrollBar.Maximum
,最大值必须是“显示所有内容所需的像素数” - “1 页显示的像素数”。如果您觉得这很令人困惑,请阅读我关于 ScrollBars
的文章以获得更详细的解释。
折叠行
可以有数百个账户。窗口能够显示所有账户很好,但如果用户可以折叠一些明细行,可能也会很有帮助,例如下面的截图,属于 Assets 的明细行。
仅为了展示添加高级功能的简便性,我编写了两个将数据写入网格的方法:
fillSimpleGrid()
,截图位于本文开头fillGrid()
,截图在上方
我将标签列中的控件从简单的 TextBlocks
更改为包含两个 TextBlocks
的水平 StackPanel
,第一个仅显示 + 或 -,第二个显示实际的账户名称。明细行不显示 +-,但 TextBlock
仍然不可见地存在,以确保账户名称显示在正确的位置。
当用户单击粗体行的 +- 时,该行的财务数据单元格 TextBoxes
的 Visibility
将设置为 Collapsed
。同样,我对自己能如此轻松地实现这一点感到惊讶。
不使用 DataGrid 时的缺点
- 在我看来,所提出解决方案的最大缺点是您无法像复制粘贴那样简单地选中某些行并将其复制到另一个应用程序。如果数据导出很重要,我会创建一个按钮,将所有数据导出到 Windows
Clipboard
。或者只需捕获 “Ctrl C” 并将所有内容复制到Clipboard
。 - 一个不太明显的缺点是此解决方案不支持虚拟化。对于巨大的网格,为每个单元格(包括未显示的单元格)创建自己的 WPF 控件将消耗太多 RAM。当
DataGrid
滚动时,DataGrid
可以使用虚拟化来重用少量 WPF 控件用于显示目的。另一方面,DataGrid
需要大量的 WPF 控件来显示一个单元格:TextBlock
、ContentPresenter
、Border
、DataGridCell
,以及许多用于显示行的控件:DataGridCellsPanel
、ItemsPresenter
、DataGridCellsPresenter
、SelectiveScrollingGrid
、Border
、DataGridRow
,以及用于网格的更多Controls
。这种复杂的结构和虚拟化也是为什么格式化DataGrid
或访问单元格数据如此复杂的原因。此处提出的解决方案更加精简,因此需要更少的 RAM,可以非常轻松地访问网格中的任何数据,并且格式设置非常简单。因此,即使没有虚拟化,它可能也可以显示大量数据。 - 当前代码不支持排序。但这可以通过使行标题可单击,使用 Linq 对数据进行排序并再次调用创建网格内容的
fillGrid()
方法来轻松实现。或者,如果您担心性能(请参阅虚拟化),您可以直接覆盖已创建TextBlocks
的属性。 - 我建议将此解决方案用于只读数据,但添加任何 WPF 控件非常容易,而不仅仅是
DataGrid
开箱即用的 4 种列类型。如果您自己做,您可以轻松避免DataGrid
的烦人问题,例如用户需要单击几次才能将数据输入单元格。另一方面,您需要做一些额外的工作,例如从代码隐藏设置TabIndex
,以确保用户可以使用 Tab 键轻松地从一个单元格移动到另一个单元格。
有些人可能会想念数据绑定。我不会!对我来说,数据绑定在运行时执行了太多的魔法,而且会导致代码非常难以理解。用于格式化数据的 XAML 语法与 C# 不同,而且相当复杂。样式、设置器、转换器、触发器的使用使得理解发生了什么变得困难,而在代码隐藏中可能只是一个简单的 if
语句。
DataGrid
的简单格式化如此困难,真是令人难以置信。请阅读我 4.93 星(40 票)的文章,了解原因以及如何克服这些困难。
推荐阅读
我写的其他一些高评分的 CodeProject 文章
历史
- 2022 年 1 月 3 日:初始版本