数据分页栏是很常用的功能,基本上只要系统涉及增删改查就少不了它。当然,这是一个非常成熟的功能,网上实现方法很多,实现起来也都大同小异,无非就是一张列表加一堆按钮(和其他常用控件)。那么我选了一个相对来说功能较全的分页栏,实现一下它的大部分功能。下面是示例图:
整个界面分上下两大部分,第一部分是个数据视图(View),第二部分是各种控件组合在一起的复合框。虽然看起来数据视图部分占比较大,但由于View这个东西系统往往已经帮我们实现了大部分功能,所以其实复合框部分才是实现的大头。
为了减少不必要的代码,我对上表进行简化,表中的对象简化为以下类,
由于使用MVVM模式实现(稍微提一下MVVM),所以整个工程分三个部分:
工程结构如下:
我这边使用了MVVM Toolkit框架(你也可以使用MVVMLight或者Prism甚至原生态,无非是写法不同罢了)。
对于这种已经明确目标效果的实现,我们可以从目标效果/画面入手,先修改XAML中代码,使目标画面的轮廓显现:
<Window x:Class="WPFDataPageDemo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WPFDataPageDemo"
mc:Ignorable="d"
Title="数据分页示例" Height="450" Width="800">
<Grid>
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition Height="Auto"/>
Grid.RowDefinitions>
<ListView ItemsSource="{Binding EmployeeList}">
<ListView.View>
<GridView>
<GridView.Columns>
<GridViewColumn DisplayMemberBinding="{Binding Name}" Header="姓名"/>
<GridViewColumn DisplayMemberBinding="{Binding Age}" Header="年龄"/>
<GridViewColumn DisplayMemberBinding="{Binding PhoneNumber}" Header="号码"/>
GridView.Columns>
GridView>
ListView.View>
ListView>
Grid>
Window>
代码非常简单,大意就是使Employee分三列分别呈现三个成员的数据。
既然用到了绑定,绑定的源就是ViewModel中暴露的属性,我们先将Employee定义下(这部分代码无关业务逻辑,可以写在Model中):
internal class Employee
{
public string Name { get; set; }
public int Age { get; set; }
public string PhoneNumber { get; set; }
}
然后我们在ViewModel中暴露数据源:
// 这边的ObservableObject和SetProperty用了MVVM Toolkit框架,你需要添加MVVM Tookit包,
// 然后using CommunityToolkit.Mvvm.ComponentModel引用之
internal class MainWindowViewModel : ObservableObject
{
private List<Employee> _employeeList;
public List<Employee> EmployeeList
{
get => _employeeList;
set => SetProperty(ref _employeeList, value);
}
}
为了能看到效果,你得初始化EmployeeList,所以在ViewModel的构造函数中初始化之:
public MainWindowViewModel()
{
this.EmployeeList = new List<Employee>();
for (int i = 0; i < 100; i++)
{
this.EmployeeList.Add(new Employee() {
Name = "Name" + i.ToString(),
Age = i,
PhoneNumber = string.Format("110{0}", i)
});
}
}
最后,你只需要在View的后台代码中添加上下文以让XAML中的绑定能找到暴露的属性:
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
this.DataContext = new MainWindowViewModel();
}
}
先分析一下目标效果,实际上它大部分控件是固定的,我们只要摆放控件即可。其中显示条目部分显示的是变化的位置属性(x条到y条,共z条),我们将控件绑定后台位置属性即可。稍微有点麻烦的页数按钮这部分,按钮数是会随记录条数而变化的。但其本质还是绑定了后台的某个属性,只是该属性的呈现方式是按钮,而不是数值(这里会用到一个模板知识点)。
OK,还是按照前面的思路,由于已经有目标效果了,所以先从XAML入手:
<StackPanel Grid.Row="1" Orientation="Horizontal">
<TextBlock Text="每页显示:"/>
<ComboBox/>
<TextBlock Text="第"/>
<TextBlock Text="{Binding StartPos}"/>
<TextBlock Text="到"/>
<TextBlock Text="{Binding EndPos}"/>
<TextBlock Text=",共"/>
<TextBlock Text="{Binding TotalCount}"/>
<TextBlock Text="条"/>
<Button Content="上页"/>
<Button Content="下页"/>
<TextBlock Text="到第"/>
<TextBox Text="{Binding TargetPage}"/>
<TextBlock Text="页"/>
StackPanel>
不能说一模一样吧,只能说完全一致。
注意:
你可能会问,你怎么就知道目标效果对应的控件就是你摆放的那个呢或者这些控件的事件处理器都还没实现呢。
这不重要,第一步的时候我们做到轮廓出来就行,至于怎么实现或原控件究竟是啥并不重要。
毕竟实现方法是多样的,不是只有一种控件能实现这个效果。
而且前期纠结太多大后期的细节问题会使你寸步难行,现阶段把握住大方向即可。
上面是固定控件的摆放代码,页数按钮部分我们单独拿出来,
...
<Button Content="上页"/>
<ItemsControl ItemsSource="{Binding PageList}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Content="{Binding Name}"/>
DataTemplate>
ItemsControl.ItemTemplate>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel/>
ItemsPanelTemplate>
ItemsControl.ItemsPanel>
ItemsControl>
<Button Content="下页"/>
...
思路就是插入一个条目控件,然后将条目的模板定义为按钮,按钮的内容绑定到页名(Name)属性。剩下的那段代码是为了让条目水平摆放,而不是默认垂直。
我在后台个ItemSource绑定的PageList添加了10个元素,先看下效果:
与目标效果越来越接近了,你可能会问省略号部分呢?这是细节问题,涉及一点点的算法设计,后面再说。
接下来,我们将后台部分完整实现下。
我这边不自觉的就用到了后台的这种说法,因为很明显啊,以上部分只是界面,是展示在前面给人看的。
而跑在幕后的业务逻辑、数据操作这些用户不能直接看到的不就是后台么?
按之前的步骤来,先看看有没有数据模型要定义的(前面的Employee),这次的Page页其实就是,但是Page你说是业务逻辑相关的吗?可以说是也可以说不是,说是是因为确实与呈现的界面有直接关系,页面涉及用户的直接体验,说不是是因为它与数据(也是你抽象出来的固定的数据模型)有关。我个人倾向于把它放在ViewModel中,因为MVVM中 VM就是衔接V和M的。当然这并不很重要,实际开发中这种纠结的问题可太多了,只要你有道理,你就可以去这么做,当有更好的道理之后,你可以再改。
所以这次Model中并没有新增东西,ViewModel中新增了以下代码:
internal class MainWindowViewModel : ObservableObject
{
public MainWindowViewModel()
{
// 省略号表示之前的代码
...
this.PageList = new List<Page>();
for (int i = 0; i < 10; i++)
{
this.PageList.Add(new Page() { Name = i.ToString() });
}
this.PageCountList = new List<int> { 10, 20, 50, 100 };
}
...
private List<Page> _pageList;
public List<Page> PageList
{
get => _pageList;
set => SetProperty(ref _pageList, value);
}
private List<int> _pageCountList;
public List <int> PageCountList
{
get => _pageCountList;
set => SetProperty(ref _pageCountList, value);
}
private int _startPos;
public int StartPos
{
get => _startPos;
set => SetProperty(ref _startPos, value);
}
private int _endPos;
public int EndPos
{
get => _endPos;
set => SetProperty(ref _endPos, value);
}
private int _totalCount;
public int TotalCount
{
get => _totalCount;
set => SetProperty(ref _totalCount, value);
}
private int _currentPage;
public int CurrentPage
{
get => _currentPage;
set => SetProperty(ref _currentPage, value);
}
}
public class Page
{
public string Name { get; set; }
}
XAML中稍作修改:
<Grid>
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition Height="Auto"/>
Grid.RowDefinitions>
<ListView ItemsSource="{Binding EmployeeList}">
<ListView.View>
<GridView>
<GridView.Columns>
<GridViewColumn DisplayMemberBinding="{Binding Name}" Header="姓名"/>
<GridViewColumn DisplayMemberBinding="{Binding Age}" Header="年龄"/>
<GridViewColumn DisplayMemberBinding="{Binding PhoneNumber}" Header="号码"/>
GridView.Columns>
GridView>
ListView.View>
ListView>
<StackPanel Grid.Row="1" Orientation="Horizontal">
<TextBlock Text="每页显示:"/>
<ComboBox ItemsSource="{Binding PageCountList}" SelectedIndex="1"/>
<TextBlock Text="第"/>
<TextBlock Text="{Binding StartPos}"/>
<TextBlock Text="到"/>
<TextBlock Text="{Binding EndPos}"/>
<TextBlock Text="条"/>
<TextBlock Text=",共"/>
<TextBlock Text="{Binding TotalCount}"/>
<TextBlock Text="条"/>
<Button Content="上页" Command="{Binding LastPageCommand}"/>
<ItemsControl ItemsSource="{Binding PageList}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Content="{Binding Name}" Command="{Binding GoPageCommand}"/>
DataTemplate>
ItemsControl.ItemTemplate>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel/>
ItemsPanelTemplate>
ItemsControl.ItemsPanel>
ItemsControl>
<Button Content="下页" Command="{Binding NextPageCommand}"/>
<TextBlock Text="到第"/>
<TextBox Text="{Binding CurrentPage}"/>
<TextBlock Text="页"/>
StackPanel>
Grid>
运行效果:
到目前为止,前台界面已初具模样,接下来应该处理后台的逻辑了。
后台主要是逻辑的实现,我们究竟要实现哪些逻辑呢?
这边列一下,
其实还有许多细节方面的逻辑要实现,可见后台实现很复杂。
这边我挑其中几个比较重要的功能的核心部分进行分析,这部分不可能面面俱到,因为不仅有许多保护代码,而且有时还与用户的使用习惯相关。
最重要的肯定是数据分页了,且分页功能与其他功能是联系紧密的,比如我选择了一页显示20条数据,那你就得让它显示20条。比如我点击下一页,那就得显示接下来的20条(如果有20条的话)。这些功能都需要用到重新分页,所以我们需要把这个功能单独拿出来,便于其他操作调用。
// 用于前端显示绑定
private List<Employee> _employeeBindingList;
public List<Employee> EmployeeBindingList
{
get => _employeeBindingList;
set => SetProperty(ref _employeeBindingList, value);
}
///
/// 分页函数
///
private void Pager()
{
// 当前页的起始记录位置
this.StartPos = (this.CurrentPage - 1) * this.PageCountList[this.CurrentIndex] + 1;
// 当前页的结束记录位置
this.EndPos = this.CurrentPage * this.PageCountList[this.CurrentIndex];
// 修改前台绑定列表的数据
this.EmployeeBindingList = this.EmployeeList.Take(this.CurrentPage * this.PageCountList[this.CurrentIndex]).
Skip((this.CurrentPage - 1) * this.PageCountList[this.CurrentIndex]).ToList();
}
这边我重新定义并暴露了一个列表属性,这样前端XAML绑定也要修改了,不能是原来的EmployeeList属性了:
<ListView ItemsSource="{Binding EmployeeBindingList}">
...
ListView>
那原来的EmployeeList是不是就不需要了呢?
当然不,如果你对MVVM模式有了解的话,你应该能体会到前端要呈现的数据与后台的原始数据往往不是一个东西。它是需要经过业务逻辑、算法加工的!这里的EmployeeList就相当于是后台的原始数据,EmployeeBindingList相当于加工后给前台显示的数据。
上述代码的大致分页逻辑就是根据当前页号(CurrentPage)和每页显示的页数(PageCountList[CurrentIndex])从EmployeeList中进行筛选,筛选结果填入EmployeeBindingList供前台显示。
由于当前页号和每页显示页数也是与前台操作挂钩的,所以前台绑定为:
<ComboBox ItemsSource="{Binding PageCountList}" SelectedIndex="{Binding CurrentIndex}"/>
...
<Button Content="下页" Command="{Binding NextPageCommand}"/>
<TextBlock Text="到第"/>
<TextBox Text="{Binding CurrentPage, UpdateSourceTrigger=PropertyChanged}" />
...
那现在,我们可以在构造函数中使用分页函数Pager看看效果:
有了分页函数,上一页和下一页按钮也变得简单。
只需要暴露命令,并交给前台绑定即可,ViewModel中代码如下:
private RelayCommand _lastPageCommand;
public RelayCommand LastPageCommand
{
get
{
if (_lastPageCommand == null)
{
_lastPageCommand = new RelayCommand(() =>
{
this.CurrentPage--;
Pager();
});
}
return _lastPageCommand;
}
}
private RelayCommand _nextPageCommand;
public RelayCommand NextPageCommand
{
get
{
if (_nextPageCommand == null)
{
_nextPageCommand = new RelayCommand(() =>
{
this.CurrentPage++;
Pager();
});
}
return _nextPageCommand;
}
}
XAMl中绑定代码如下:
<Button Content="上页" Command="{Binding LastPageCommand}"/>
...
<Button Content="下页" Command="{Binding NextPageCommand}"/>
要怎么动态改变按钮数量呢?
100条记录,每页10条记录,那我就得有10个按钮;每页20条,我就有5个按钮。每页1个我就得有100个按钮?
显然每个人都有自己的实现方法。但通常来说,按钮数量不宜过多,100个按钮的情况是不会有的,中间的按钮都会合并为一个省略号。
那我这边给出一种我的实现方法,先用总记录数和每页显示记录数算出页数,然后根据页数计算要放置多少按钮,
// 初始化页
this.PageList = new List<Page>();
// 计算总页数
int pageCount = this.TotalCount / this.PageCountList[CurrentIndex] +
((this.TotalCount % this.PageCountList[CurrentIndex]) == 0 ? 0 : 1);
// 若页数<=7,那就全显示
if (pageCount <= 7)
{
for (int i = 1; i <= pageCount; i++)
{
this.PageList.Add(new Page() { Name = i.ToString() });
}
}
else if (pageCount > 7)
{
this.PageList.Add(new Page() { Name = "1" });
this.PageList.Add(new Page() { Name = "2" });
this.PageList.Add(new Page() { Name = "3" });
this.PageList.Add(new Page() { Name = "..." });
this.PageList.Add(new Page() { Name = (pageCount - 2).ToString() });
this.PageList.Add(new Page() { Name = (pageCount - 1).ToString() });
this.PageList.Add(new Page() { Name = pageCount.ToString() });
}
如何跳转至指定页?
我相信在实现了前面功能后,再来看这个问题并不难。给TextBox绑定命令,然后在TextBox中修改Text即可。
但是TextBox并不直接拥有命令这个绑定选项,要说为什么,因为它没有实现ICommand接口。
那怎么办,难道要使用TextChanged,给其添加事件处理器么?当然这也是一种方法,但直接使用会带来耦合度的问题。
你可以这么做:
<TextBox Text="{Binding CurrentPage}" >
<TextBox.InputBindings>
<KeyBinding Command="{Binding GotoPageCommand}" Key="Return"/>
TextBox.InputBindings>
TextBox>
TextBox中的InputBindings可以添加命令绑定(这是一个高层类UIElement的属性,所以可以说所有控件都有它)。在其中添加触发按钮为Return(代表回车键),绑定自定义的GotoPageCommand命令,该命令实现如下:
private RelayCommand _gotoPageCommand;
public RelayCommand GotoPageCommand
{
get
{
if (_gotoPageCommand == null)
{
_gotoPageCommand = new RelayCommand(() =>
{
Pager();
});
}
return _gotoPageCommand;
}
}
如果你就这样运行,会发现修改TextBox中的Text后,按下回车,并没有跳转到指定页。
为什么呢?
因为默认情况下,TextBox要失去焦点才会改变后台绑定的值,加上UpdateSourceTrigger=PropertyChanged就好了,
<TextBox Text="{Binding CurrentPage, UpdateSourceTrigger=PropertyChanged}" >
现在只要你改变TextBox中的值,按下回车就会跳转到指定页了,
最后用MaterialDesignThemes稍加修饰,更加像模像样了点了。
它的主要功能已经完成。
当然各种限制不合法输入的保护代码还没加。并且你够细的话,还会发现很多问题,比如我修改了跳转页TextBox中的数字但没有按下回车,其实后台当前页绑定值也已经改变了,如果此时按上页或者下页则是以后台值为基准进行跳转的。又比如,当你切换每页显示记录数,但是按钮数却不会变化。当然有很多办法可以回避这些点,如果你的客户不介意,你也可以不修改。实际使用中,操作员不太会在意这种细节,它们通常只会使用一种能走得通的方式(前提是它不复杂)。
还有很多其它Bug没有暴露出来。并且无论你怎么改它都还会存在。
我想说的很简单,不建议自己实现。如果有成熟的组件,你应该优先使用它。
当然如果时间足够多,你也可以像我一样,自己实现着玩玩,体会一下。