Skip to Content
Week 10Day 4 - 主列表页面

Day 4 - 主列表页面

建议用时:180-210 分钟

你将学会什么

  • 主列表页面应该分成哪些区域
  • ObservableCollection 为什么适合绑定列表
  • SelectedProduct 是什么
  • 为什么选中项变化后要同步详情区
  • 新增后为什么列表会自动刷新
  • 删除后为什么要重新处理选中项

今天开始把 Week 10 的项目放到 Avalonia 页面里。先做主列表页:左边列表,右边详情,上方按钮。今天不做复杂编辑,先把“显示多条、选中一条、操作一条”的主线跑通。

本页固定顺序

  1. 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
  2. 再学第二部分:把刚学的知识组合成一个完整例子。
  3. 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
  4. 最后做第四部分:先独立完成作业,再用完整答案检查。

学习衔接

上一页学习的是“服务层”,今天继续学习“主列表页面”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。

今天的最低通过线

第一次学习不要求背完整页。完成下面 3 项,就可以继续:

  • 能用自己的话说明“主列表页面”解决什么问题。
  • 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
  • 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。

第一部分:先学原理和最小知识

这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。主列表页的关键不是控件多,而是数据流清楚。

主列表页分成三块

主列表页通常有三块:

区域作用
顶部操作区放新增、删除、保存、提示
左侧列表区显示多条商品
右侧详情区显示当前选中的一条商品

这样布局的原因是:用户先看多条,再选择一条,再操作这一条。

Products 是什么

public ObservableCollection<ProductItem> Products { get; } = new();

Products 是界面要显示的商品集合。

它用 ObservableCollection,因为:

  • Add 后界面会更新。
  • Remove 后界面会更新。
  • 列表绑定它很方便。

如果用普通 List<ProductItem>,新增和删除后界面不一定能自动刷新。

SelectedProduct 是什么

[ObservableProperty] private ProductItem? selectedProduct;

SelectedProduct 表示当前选中的商品。

XAML 里这样绑定:

<ListBox ItemsSource="{Binding Products}" SelectedItem="{Binding SelectedProduct, Mode=TwoWay}" />

含义是:

  • 用户在列表里点哪一条,SelectedProduct 就变成哪一条。
  • 代码里改 SelectedProduct,列表选中项也会跟着变化。

为什么要处理选中项变化

本页右侧详情不是直接绑定 SelectedProduct.Name,而是绑定:

DetailName DetailPrice DetailStock

当选中项变化时,执行:

partial void OnSelectedProductChanged(ProductItem? value)

这样做的好处是:

  • 没有选中商品时能显示“未选择商品”。
  • 详情文字可以带前缀,例如 价格:199
  • 后面扩展详情格式更方便。

新增后为什么要选中新商品

新增商品后执行:

Products.Add(product); SelectedProduct = product;

这样用户马上能看到刚新增的商品详情。

如果只 Add,列表会出现新商品,但右侧可能还停留在旧商品,体验不清楚。

删除后为什么要重新选择

删除商品后,原来的 SelectedProduct 已经从列表里移除了。

所以要执行:

SelectedProduct = Products.FirstOrDefault();

如果列表还有商品,就选中第一条。

如果列表空了,就变成 null,详情区显示“未选择商品”。

今天先不接服务层

Day 3 已经写了服务层。

今天先把主列表页面的显示、选中、新增、删除动作跑通。等 Day 5 和 Day 6 再逐步把编辑、搜索和服务层规则接回来。

这样做是为了减少一次同时变化的东西。

主列表页面常用绑定速查

需求XAML / ViewModel 写法
商品列表ItemsSource="{Binding Products}"
当前选中SelectedItem="{Binding SelectedProduct}"
新增按钮Command="{Binding AddCommand}"
删除按钮Command="{Binding DeleteCommand}"
空状态IsVisible="{Binding IsEmpty}"
列表集合ObservableCollection<ProductItem>
删除前判断if (SelectedProduct is null) return;

列表页主线:

加载 Products -> ListBox 显示 -> SelectedProduct 保存当前项 -> 命令新增或删除 -> ObservableCollection 通知界面刷新

第二部分:把知识组合成完整例子

今天做一个主列表页面:

  • 左侧显示商品列表。
  • 点击列表中的商品,右侧显示详情。
  • 点击“新增示例商品”,列表增加一条。
  • 点击“删除选中商品”,删除当前选中项。
  • 没有选中商品时,删除按钮会给出提示。

ViewModel

文件位置:

ViewModels/MainWindowViewModel.cs
using System; using System.Collections.ObjectModel; using System.Linq; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ProductApp.ViewModels; public partial class MainWindowViewModel : ViewModelBase { public ObservableCollection<ProductItem> Products { get; } = new(); [ObservableProperty] private ProductItem? selectedProduct; [ObservableProperty] private string detailName = "未选择商品"; [ObservableProperty] private string detailPrice = ""; [ObservableProperty] private string detailStock = ""; [ObservableProperty] private string message = "请选择左侧商品查看详情。"; public MainWindowViewModel() { Products.Add(new ProductItem { Id = Guid.NewGuid(), Name = "Keyboard", Price = 199m, Stock = 10 }); Products.Add(new ProductItem { Id = Guid.NewGuid(), Name = "Mouse", Price = 59m, Stock = 30 }); Products.Add(new ProductItem { Id = Guid.NewGuid(), Name = "Monitor", Price = 899m, Stock = 5 }); SelectedProduct = Products.FirstOrDefault(); } partial void OnSelectedProductChanged(ProductItem? value) { if (value is null) { DetailName = "未选择商品"; DetailPrice = ""; DetailStock = ""; return; } DetailName = value.Name; DetailPrice = $"价格:{value.Price}"; DetailStock = $"库存:{value.Stock}"; Message = $"当前选中:{value.Name}"; } [RelayCommand] private void AddSampleProduct() { var product = new ProductItem { Id = Guid.NewGuid(), Name = $"Product {Products.Count + 1}", Price = 99m + Products.Count, Stock = 10 }; Products.Add(product); SelectedProduct = product; Message = $"已新增:{product.Name}"; } [RelayCommand] private void DeleteSelectedProduct() { if (SelectedProduct is null) { Message = "请先选择要删除的商品。"; return; } string deletedName = SelectedProduct.Name; Products.Remove(SelectedProduct); SelectedProduct = Products.FirstOrDefault(); Message = $"已删除:{deletedName}"; } } public sealed class ProductItem { public Guid Id { get; set; } public string Name { get; set; } = ""; public decimal Price { get; set; } public int Stock { get; set; } }

窗口

文件位置:

Views/MainWindow.axaml
<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ProductApp.ViewModels" x:Class="ProductApp.Views.MainWindow" x:DataType="vm:MainWindowViewModel" Width="820" Height="560" Title="Product List"> <Grid RowDefinitions="Auto,Auto,*" Margin="20" RowSpacing="12"> <TextBlock Text="商品管理" FontSize="24" FontWeight="Bold" /> <StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8"> <Button Content="新增示例商品" Command="{Binding AddSampleProductCommand}" /> <Button Content="删除选中商品" Command="{Binding DeleteSelectedProductCommand}" /> <TextBlock Text="{Binding Message}" VerticalAlignment="Center" /> </StackPanel> <Grid Grid.Row="2" ColumnDefinitions="320,*" ColumnSpacing="16"> <ListBox ItemsSource="{Binding Products}" SelectedItem="{Binding SelectedProduct, Mode=TwoWay}"> <ListBox.ItemTemplate> <DataTemplate x:DataType="vm:ProductItem"> <Grid ColumnDefinitions="160,80,60" Margin="4"> <TextBlock Text="{Binding Name}" /> <TextBlock Grid.Column="1" Text="{Binding Price}" /> <TextBlock Grid.Column="2" Text="{Binding Stock}" /> </Grid> </DataTemplate> </ListBox.ItemTemplate> </ListBox> <StackPanel Grid.Column="1" Spacing="8"> <TextBlock Text="商品详情" FontSize="20" FontWeight="Bold" /> <TextBlock Text="{Binding DetailName}" /> <TextBlock Text="{Binding DetailPrice}" /> <TextBlock Text="{Binding DetailStock}" /> </StackPanel> </Grid> </Grid> </Window>

第三部分:跟着敲代码

从这里开始动手。打开 Avalonia 项目。

第 1 步:写 ProductItem

先在 MainWindowViewModel.cs 下面写商品类型。

public sealed class ProductItem { public Guid Id { get; set; } public string Name { get; set; } = ""; public decimal Price { get; set; } public int Stock { get; set; } }

解释:

  • 这里先把 ProductItem 放在同一个文件里,方便练习。
  • 后面项目变大,可以移动到 Models/ProductItem.cs

第 2 步:写 Products 和 SelectedProduct

public ObservableCollection<ProductItem> Products { get; } = new(); [ObservableProperty] private ProductItem? selectedProduct;

解释:

  • Products 给列表显示。
  • SelectedProduct 保存当前选中的商品。

第 3 步:写详情字段

[ObservableProperty] private string detailName = "未选择商品"; [ObservableProperty] private string detailPrice = ""; [ObservableProperty] private string detailStock = ""; [ObservableProperty] private string message = "请选择左侧商品查看详情。";

解释:

  • 右侧详情区绑定这些字段。
  • Message 显示顶部状态提示。

第 4 步:构造函数里准备初始商品

public MainWindowViewModel() { Products.Add(new ProductItem { Id = Guid.NewGuid(), Name = "Keyboard", Price = 199m, Stock = 10 }); Products.Add(new ProductItem { Id = Guid.NewGuid(), Name = "Mouse", Price = 59m, Stock = 30 }); Products.Add(new ProductItem { Id = Guid.NewGuid(), Name = "Monitor", Price = 899m, Stock = 5 }); SelectedProduct = Products.FirstOrDefault(); }

解释:

  • 先放三条数据。
  • 启动后自动选中第一条。

第 5 步:处理选中项变化

partial void OnSelectedProductChanged(ProductItem? value) { if (value is null) { DetailName = "未选择商品"; DetailPrice = ""; DetailStock = ""; return; } DetailName = value.Name; DetailPrice = $"价格:{value.Price}"; DetailStock = $"库存:{value.Stock}"; Message = $"当前选中:{value.Name}"; }

解释:

  • 选中商品时,右侧显示详情。
  • 没有选中商品时,右侧显示未选择。

第 6 步:写新增命令

[RelayCommand] private void AddSampleProduct() { var product = new ProductItem { Id = Guid.NewGuid(), Name = $"Product {Products.Count + 1}", Price = 99m + Products.Count, Stock = 10 }; Products.Add(product); SelectedProduct = product; Message = $"已新增:{product.Name}"; }

解释:

  • 新建一条商品。
  • 加入列表。
  • 自动选中新商品。

第 7 步:写删除命令

[RelayCommand] private void DeleteSelectedProduct() { if (SelectedProduct is null) { Message = "请先选择要删除的商品。"; return; } string deletedName = SelectedProduct.Name; Products.Remove(SelectedProduct); SelectedProduct = Products.FirstOrDefault(); Message = $"已删除:{deletedName}"; }

解释:

  • 没选中时不删除。
  • 删除后重新选择第一条。

第 8 步:写顶部操作区

<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8"> <Button Content="新增示例商品" Command="{Binding AddSampleProductCommand}" /> <Button Content="删除选中商品" Command="{Binding DeleteSelectedProductCommand}" /> <TextBlock Text="{Binding Message}" VerticalAlignment="Center" /> </StackPanel>

解释:

  • 按钮绑定 ViewModel 命令。
  • Message 显示操作结果。

第 9 步:写左侧列表

<ListBox ItemsSource="{Binding Products}" SelectedItem="{Binding SelectedProduct, Mode=TwoWay}"> <ListBox.ItemTemplate> <DataTemplate x:DataType="vm:ProductItem"> <Grid ColumnDefinitions="160,80,60" Margin="4"> <TextBlock Text="{Binding Name}" /> <TextBlock Grid.Column="1" Text="{Binding Price}" /> <TextBlock Grid.Column="2" Text="{Binding Stock}" /> </Grid> </DataTemplate> </ListBox.ItemTemplate> </ListBox>

解释:

  • ItemsSource 绑定商品集合。
  • SelectedItem 绑定选中商品。
  • 每一行显示名称、价格、库存。

第 10 步:写右侧详情

<StackPanel Grid.Column="1" Spacing="8"> <TextBlock Text="商品详情" FontSize="20" FontWeight="Bold" /> <TextBlock Text="{Binding DetailName}" /> <TextBlock Text="{Binding DetailPrice}" /> <TextBlock Text="{Binding DetailStock}" /> </StackPanel>

解释:

  • 详情区只显示当前选中商品的信息。
  • 选中项变化时,ViewModel 会更新这些字段。

第 11 步:运行后检查

运行后按顺序检查:

  1. 左侧有三条商品。
  2. 右侧默认显示第一条商品详情。
  3. 点击 Mouse,右侧变成 Mouse。
  4. 点击“新增示例商品”,列表多一条,并自动选中新商品。
  5. 点击“删除选中商品”,当前商品被删除。
  6. 删除到空列表后,右侧显示“未选择商品”。

常见错误和修法

错误为什么错修法
列表直接绑定普通 List新增删除后界面不会自动刷新ObservableCollection<T>
加载数据时不显示状态用户不知道是否正在读取增加 IsLoading 和提示文字
空列表没有提示页面像没加载出来增加 IsEmpty 或空状态文字
选中项没有属性保存编辑和删除不知道操作哪一条增加 SelectedProduct
列表页面直接写保存规则页面职责过重规则放 Service,页面只触发命令

小白重复敲写训练

主列表页面先只显示数据,再加选中和命令。

训练 1:绑定商品列表

public ObservableCollection<Product> Products { get; } = new();
<ListBox ItemsSource="{Binding Products}" />

先在构造函数中加入两条固定数据。

训练 2:显示商品模板

<ListBox.ItemTemplate> <DataTemplate> <Grid ColumnDefinitions="*,Auto"> <TextBlock Text="{Binding Name}" /> <TextBlock Grid.Column="1" Text="{Binding Price}" /> </Grid> </DataTemplate> </ListBox.ItemTemplate>

改动任务:增加库存列。

训练 3:刷新列表

[RelayCommand] private async Task LoadAsync() { Products.Clear(); foreach (Product product in await _service.GetAllAsync()) { Products.Add(product); } }

第三遍增加“加载中”和“加载完成”状态文字。

每日小测

做完本页后,用这 5 题检查是否真的掌握。

1. 判断题

本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。

答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。

2. 填空题

本页主题是:主列表页面。今天至少要掌握的 3 个点是:

1. 主列表页面应该分成哪些区域 2. `ObservableCollection` 为什么适合绑定列表 3. `SelectedProduct` 是什么

答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。

3. 流程题

遇到本页相关功能时,先按什么顺序处理?

答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。

4. 找错误题

如果本页代码运行失败,第一步应该做什么?

答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。

5. 改需求题

在本页完整例子跑通后,至少改一个小需求。

可选改法:

  • 改一个字段名称。
  • 多加一个校验条件。
  • 多输出一行结果。
  • 把固定数据改成用户输入。
  • 把一次处理改成多条数据处理。

答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:主列表页面应该分成哪些区域。

上位机专项练习

仪表盘只展示最重要的信息:在线数量、报警数量、关键数值和最近异常。

下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。

专项例子 1:摘要卡绑定

<UniformGrid Columns="3"> <views:SummaryCard Title="在线设备" Value="{Binding OnlineCount}" /> <views:SummaryCard Title="离线设备" Value="{Binding OfflineCount}" /> <views:SummaryCard Title="未确认报警" Value="{Binding ActiveAlarmCount}" /> </UniformGrid>

运行结果或界面效果:

首页先看到三个关键数量

改动任务: 增加最后刷新时间。

专项例子 2:计算摘要值

OnlineCount = Devices.Count(x => x.IsOnline); OfflineCount = Devices.Count - OnlineCount; ActiveAlarmCount = Alarms.Count(x => !x.IsAcknowledged);

运行结果或界面效果:

集合变化后得到最新摘要

改动任务: 增加 HighPriorityAlarmCount。

专项例子 3:显示最近报警

<ItemsControl ItemsSource="{Binding RecentAlarms}"> <ItemsControl.ItemTemplate> <DataTemplate> <TextBlock Text="{Binding Message}" /> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl>

运行结果或界面效果:

首页显示最近几条报警

改动任务: 限制只显示 5 条。

第四部分:作业完整答案

作业要求:完成一个主列表页面,左侧显示商品列表,右侧显示选中商品详情,支持新增示例商品和删除选中商品。

答案文件 1:ViewModels/MainWindowViewModel.cs

using System; using System.Collections.ObjectModel; using System.Linq; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ProductApp.ViewModels; public partial class MainWindowViewModel : ViewModelBase { public ObservableCollection<ProductItem> Products { get; } = new(); [ObservableProperty] private ProductItem? selectedProduct; [ObservableProperty] private string detailName = "未选择商品"; [ObservableProperty] private string detailPrice = ""; [ObservableProperty] private string detailStock = ""; [ObservableProperty] private string message = "请选择左侧商品查看详情。"; public MainWindowViewModel() { Products.Add(new ProductItem { Id = Guid.NewGuid(), Name = "Keyboard", Price = 199m, Stock = 10 }); Products.Add(new ProductItem { Id = Guid.NewGuid(), Name = "Mouse", Price = 59m, Stock = 30 }); Products.Add(new ProductItem { Id = Guid.NewGuid(), Name = "Monitor", Price = 899m, Stock = 5 }); SelectedProduct = Products.FirstOrDefault(); } partial void OnSelectedProductChanged(ProductItem? value) { if (value is null) { DetailName = "未选择商品"; DetailPrice = ""; DetailStock = ""; return; } DetailName = value.Name; DetailPrice = $"价格:{value.Price}"; DetailStock = $"库存:{value.Stock}"; Message = $"当前选中:{value.Name}"; } [RelayCommand] private void AddSampleProduct() { var product = new ProductItem { Id = Guid.NewGuid(), Name = $"Product {Products.Count + 1}", Price = 99m + Products.Count, Stock = 10 }; Products.Add(product); SelectedProduct = product; Message = $"已新增:{product.Name}"; } [RelayCommand] private void DeleteSelectedProduct() { if (SelectedProduct is null) { Message = "请先选择要删除的商品。"; return; } string deletedName = SelectedProduct.Name; Products.Remove(SelectedProduct); SelectedProduct = Products.FirstOrDefault(); Message = $"已删除:{deletedName}"; } } public sealed class ProductItem { public Guid Id { get; set; } public string Name { get; set; } = ""; public decimal Price { get; set; } public int Stock { get; set; } }

答案文件 2:Views/MainWindow.axaml

<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ProductApp.ViewModels" x:Class="ProductApp.Views.MainWindow" x:DataType="vm:MainWindowViewModel" Width="820" Height="560" Title="Product List"> <Grid RowDefinitions="Auto,Auto,*" Margin="20" RowSpacing="12"> <TextBlock Text="商品管理" FontSize="24" FontWeight="Bold" /> <StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8"> <Button Content="新增示例商品" Command="{Binding AddSampleProductCommand}" /> <Button Content="删除选中商品" Command="{Binding DeleteSelectedProductCommand}" /> <TextBlock Text="{Binding Message}" VerticalAlignment="Center" /> </StackPanel> <Grid Grid.Row="2" ColumnDefinitions="320,*" ColumnSpacing="16"> <ListBox ItemsSource="{Binding Products}" SelectedItem="{Binding SelectedProduct, Mode=TwoWay}"> <ListBox.ItemTemplate> <DataTemplate x:DataType="vm:ProductItem"> <Grid ColumnDefinitions="160,80,60" Margin="4"> <TextBlock Text="{Binding Name}" /> <TextBlock Grid.Column="1" Text="{Binding Price}" /> <TextBlock Grid.Column="2" Text="{Binding Stock}" /> </Grid> </DataTemplate> </ListBox.ItemTemplate> </ListBox> <StackPanel Grid.Column="1" Spacing="8"> <TextBlock Text="商品详情" FontSize="20" FontWeight="Bold" /> <TextBlock Text="{Binding DetailName}" /> <TextBlock Text="{Binding DetailPrice}" /> <TextBlock Text="{Binding DetailStock}" /> </StackPanel> </Grid> </Grid> </Window>

验收结果

运行后必须看到:

  1. 左侧列表显示 KeyboardMouseMonitor
  2. 启动后右侧显示第一条商品详情。
  3. 点击不同商品,右侧详情跟着变化。
  4. 点击新增按钮,列表增加一条商品。
  5. 新增后自动选中新商品。
  6. 点击删除按钮,当前选中商品被删除。
  7. 没有选中商品时删除会显示提示。

为什么这个答案是对的

这个答案把主列表页的数据流写清楚了:

界面动作ViewModel 状态结果
显示列表ProductsListBox 显示多条商品
点击商品SelectedProduct当前选中项变化
选中变化OnSelectedProductChanged右侧详情刷新
新增商品Products.Add列表自动增加
删除商品Products.Remove列表自动减少

主列表页的核心不是控件,而是这条数据流:集合显示多条,选中项代表一条,命令操作当前状态。