Day 4 - 主列表页面
建议用时:180-210 分钟
你将学会什么
- 主列表页面应该分成哪些区域
ObservableCollection为什么适合绑定列表SelectedProduct是什么- 为什么选中项变化后要同步详情区
- 新增后为什么列表会自动刷新
- 删除后为什么要重新处理选中项
今天开始把 Week 10 的项目放到 Avalonia 页面里。先做主列表页:左边列表,右边详情,上方按钮。今天不做复杂编辑,先把“显示多条、选中一条、操作一条”的主线跑通。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
学习衔接
上一页学习的是“服务层”,今天继续学习“主列表页面”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。
今天的最低通过线
第一次学习不要求背完整页。完成下面 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.csusing 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 步:运行后检查
运行后按顺序检查:
- 左侧有三条商品。
- 右侧默认显示第一条商品详情。
- 点击
Mouse,右侧变成 Mouse。 - 点击“新增示例商品”,列表多一条,并自动选中新商品。
- 点击“删除选中商品”,当前商品被删除。
- 删除到空列表后,右侧显示“未选择商品”。
常见错误和修法
| 错误 | 为什么错 | 修法 |
|---|---|---|
列表直接绑定普通 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>验收结果
运行后必须看到:
- 左侧列表显示
Keyboard、Mouse、Monitor。 - 启动后右侧显示第一条商品详情。
- 点击不同商品,右侧详情跟着变化。
- 点击新增按钮,列表增加一条商品。
- 新增后自动选中新商品。
- 点击删除按钮,当前选中商品被删除。
- 没有选中商品时删除会显示提示。
为什么这个答案是对的
这个答案把主列表页的数据流写清楚了:
| 界面动作 | ViewModel 状态 | 结果 |
|---|---|---|
| 显示列表 | Products | ListBox 显示多条商品 |
| 点击商品 | SelectedProduct | 当前选中项变化 |
| 选中变化 | OnSelectedProductChanged | 右侧详情刷新 |
| 新增商品 | Products.Add | 列表自动增加 |
| 删除商品 | Products.Remove | 列表自动减少 |
主列表页的核心不是控件,而是这条数据流:集合显示多条,选中项代表一条,命令操作当前状态。