目录
介绍
XAML代码
滚动需要决定包含结构
用方法替换样式
使列标题与同一列中的数据单元格具有相同的宽度
协调滚动
折叠行
不使用DataGrid时的缺点
- 下载源代码 - 6.2 KB
我需要编写一个财务资产负债表窗口,显示几年的财务数字,允许用户水平和垂直滚动,而始终显示最左列和最上行的标签,如下所示:
如您所见,网格是“转置”的,它具有可变数量的列和固定数量的行。每过一年,就会增加一列。WPF DataGrid不支持开箱即用,尽管StackOverflow上有关于如何通过旋转DataGrid然后旋转每个单元格来实现这一点的建议。我更喜欢更简单的方法。在屏幕上写一堆数字有多难?这非常简单,正如我发现的那样,只需要30行C#代码来显示数据,并用几行代码来涵盖高级用户交互:
- 固定标题行和列,即仅滚动财务数据
- 数据格式不同
- 调整大小,即网格充分利用了可用的屏幕空间
- 行分组,用户可以折叠
- 用户可以向下钻取以打开新的Window显示该帐户和年份的财务详细信息。
本文详细描述了设计和实现此类布局时所采取的不同步骤。
XAML代码我知道,有些人认为GUI应该主要由XAML代码和尽可能少的C#代码组成。我对此有不同的看法。XAML缺乏任何编程语言所具有的许多基本功能,它甚至无法添加1 + 1。我使用XAML仅用于定义内容结构,例如Grids包含StackPanels包含...以及与图形设计相关的内容,例如字体和颜色。通常,我 也会在XAML中定义TextBlocks,但在这个项目中,我从后面的代码中创建它们。
该项目的 XAML 代码很短,仅包含资产负债表窗口中的主要容器:
包含结构由窗口中需要以不同方式滚动的区域定义。基本上,有五个区域滚动不同:
- 年份标签行:水平滚动
- 帐户标签行:垂直滚动
- 财务数据单元格:水平和垂直滚动
- Horizontal-和VerticalScrollBar不会滚动
在WPF中使用ScrollViewer。ScrollViewer给它的子控件无限的空间,并有一个水平和垂直的ScrollBar控制其内容的哪一部分被显示。对所有事情都只使用一个ScrollViewer很容易,但是标签也会滚动到视野之外。但是,它们必须留在原地,否则很难判断财务数据属于哪个帐户和年份。
所以我为每个可滚动区域使用了三个ScrollViewers,用户可以使用两个ScrollBars来滚动财务数据。
下一个问题是,应该使用哪个WPF容器来保存TextBlocks显示数据。令我惊讶的是,只要每个数据单元格使用相同数量的文本行,简单的带有Orientation.Vertical的StackPanel实际上可以很好地对齐显示每一行。由于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不同,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中向用户显示该帐户的所有详细数据(财务分类账条目),这意味着他可以查看该帐户和年份的所有财务报表。
在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 中这样做,我不知道该怎么做。当然,可以将 的 绑定width到YearStackPanel年份标签的TextBlock,但是需要许多这样的绑定,并且每年都会增加一个。
协调滚动还有一个问题必须解决。 需要在LabelsLeftScrollViewer和DataScrollViewer中执行使用垂直ScrollBar的滚动。这听起来很简单,但存在一个挑战:如果用户将鼠标悬停在帐户列上并使用鼠标滚轮滚动LabelsLeftScrollViewer,则DataScrollViewer和VerticalScrollBar需要进行相同的滚动。同样,这可以使用事件处理程序轻松实现:
private void VerticalScrollBar_ValueChanged
(object sender, RoutedPropertyChangedEventArgs 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再次引发。
最简单的解决方案是如果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,最大值必须是“显示所有内容所需的像素数”-“一页显示的像素数”。
折叠行可以有数百个帐户。如果窗口可以全部显示它们很好,但如果用户可以折叠一些详细信息行也可能会有所帮助,如下面的屏幕截图所示,属于Assets的详细信息行:
为了展示添加一些高级功能是多么容易,我编写了两种将数据写入网格的方法:
- fillSimpleGrid(),本文开头的截图
- fillGrid(), 上面的截图
我改变了标签列的控件从简单的TextBlocks到一个水平的StackPanel包含两个TextBlocks,第一个只是显示+或-和第二个,实际帐户名称。详细信息行不显示+-,但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控件非常容易,而不仅仅是开箱即用的4列DataGrid类型支持。如果你自己做,你可以很容易地避免恼人的DataGrid问题,比如用户必须点击几次才能将数据输入到单元格中。另一方面,您必须做一些额外的工作,例如从后面的代码中进行TabIndex设置,以确保用户可以使用Tab 按钮轻松地从一个单元格移动到另一个单元格。
有些人可能会错过数据绑定的使用。我不!!!对我来说,数据绑定在运行时执行了太多的魔法,导致代码非常难以理解。格式化数据的XAML语法与C#不同,而且相当复杂。Styles、Setters、Converters、Triggers的使用让人很难理解发生了什么,这在后面的代码中可能只是一个简单的if语句。
令人难以置信的是,简单格式化 DataGrid是多么困难。请阅读以下文章,了解为什么会这样以及如何克服这些困难:
- 使用绑定的WPF DataGrid格式化指南
推荐阅读
- WPF DataGrid:解决排序、ScrollIntoView、刷新和焦点问题
- 用于数据输入的基本WPF窗口功能
https://www.codeproject.com/Articles/5321486/Avoid-the-WPF-DataGrid-Limitations-Replace-It-with