Day 6 - 界面打磨
建议用时:240-270 分钟
你将学会什么
- 知道界面打磨不只是“好看”,更是让状态清楚。
- 会显示空状态:没有商品时不要一片空白。
- 会显示保存中状态:让用户知道程序正在处理。
- 会禁用按钮:防止重复点击。
- 会显示错误消息:告诉用户怎么继续。
- 会用 MVVM 属性控制这些状态。
本页不接真实后端,用 Task.Delay 模拟保存耗时。重点是掌握状态管理:空、加载、错误、禁用、成功。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
今天只抓住 3 件事
- 界面打磨优先让状态清楚,不是先加颜色。
- 空、加载、错误、禁用、成功都要由 ViewModel 属性控制。
- 按钮、提示、列表显示都要能从状态变量推导出来。
学习衔接
上一页学习的是“主题结构”,今天继续学习“界面打磨”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。
第一部分:先学原理和最小知识
这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。界面打磨的目标是让用户知道“现在发生了什么、还能做什么、下一步怎么做”。
1. 空状态是什么
空状态是没有数据时的提示。
没有空状态时,用户只看到空白,会疑惑:
是没加载出来?
是程序坏了?
还是本来就没有数据?本页使用:
<TextBlock Text="暂无商品,请输入名称后保存。"
IsVisible="{Binding IsEmpty}" />IsEmpty 为 true 时显示提示。
2. 加载状态是什么
加载状态表示程序正在处理。
<ProgressBar IsIndeterminate="True"
IsVisible="{Binding IsSaving}" />IsIndeterminate="True" 表示不显示具体百分比,只告诉用户正在处理。
3. 禁用状态是什么
保存时不能重复点保存按钮。
本页没有直接写:
IsEnabled="{Binding ...}"而是用命令的 CanExecute:
private bool CanSave()
{
return !IsSaving && !string.IsNullOrWhiteSpace(ProductName);
}当 CanSave() 返回 false 时,绑定 SaveCommand 的按钮会不可执行。
4. 为什么 IsSaving 要通知 SaveCommand
保存开始后:
IsSaving = true;按钮状态应该重新判断。
所以字段上写:
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
private bool isSaving;否则保存中按钮可能还能点。
5. 为什么 ProductName 要通知 SaveCommand
输入框为空时不能保存,输入内容后可以保存。
所以:
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
private string productName = "";ProductName 变化时,保存按钮重新判断 CanSave()。
6. SaveButtonText 是什么
按钮文字也可以由状态决定。
public string SaveButtonText => IsSaving ? "保存中..." : "保存商品";当 IsSaving 变化时,要通知它刷新:
[NotifyPropertyChangedFor(nameof(SaveButtonText))]7. 错误消息应该放哪里
错误消息要有固定位置。
<TextBlock Text="{Binding ErrorMessage}"
Foreground="Red" />不要只写“失败”。应该告诉用户:
保存失败,请稍后重试。8. finally 的作用
保存开始时:
IsSaving = true;无论保存成功还是失败,最后都要恢复:
finally
{
IsSaving = false;
}如果没有 finally,一旦出错,界面可能一直停在保存中状态。
界面打磨常用操作速查
| 需求 | 做法 | 关键绑定 |
|---|---|---|
| 空列表不空白 | 显示空状态提示 | IsVisible="{Binding IsEmpty}" |
| 保存中有反馈 | 显示进度条或提示 | IsVisible="{Binding IsSaving}" |
| 错误可理解 | ErrorMessage 写具体原因 | Text="{Binding ErrorMessage}" |
| 按钮不重复点 | 用命令 CanExecute 控制 | [RelayCommand(CanExecute = ...)] |
| 输入提示 | 给输入框加占位文字 | PlaceholderText="请输入商品名称" |
| 按钮文字变化 | 根据状态返回不同文字 | Content="{Binding SaveButtonText}" |
| 列表可读 | DataTemplate 显示关键字段 | ItemsSource="{Binding Products}" |
| 页面不乱 | 用 Grid 分区,用 Spacing 控制间距 | RowDefinitions、Spacing |
优先级:
状态清楚 > 操作不误点 > 信息可读 > 外观统一常见错误和修法
| 错误 | 为什么错 | 修法 |
|---|---|---|
| 只改外观,不显示状态 | 用户不知道现在发生了什么 | 先补 IsEmpty、IsSaving、ErrorMessage |
| 保存中按钮还能点 | 会产生重复保存 | 用 CanSave 或命令 CanExecute 控制 |
IsSaving 改了但按钮没刷新 | 命令不知道状态变了 | 加 [NotifyCanExecuteChangedFor(nameof(SaveCommand))] |
| 按钮文字不更新 | 计算属性没有通知刷新 | 加 [NotifyPropertyChangedFor(nameof(SaveButtonText))] |
| 保存失败后一直加载 | 没有恢复状态 | 在 finally 里设置 IsSaving = false |
第二部分:把知识组合成完整例子
今天做一个商品新增页面:
- 商品列表为空时显示空状态。
- 输入名称后才能点击保存。
- 点击保存后按钮变成
保存中...。 - 保存时显示进度条。
- 保存完成后商品出现在列表里。
- 清空列表后空状态再次出现。
ViewModels/MainWindowViewModel.cs
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ProductApp.ViewModels;
public partial class MainWindowViewModel : ViewModelBase
{
public ObservableCollection<string> Products { get; } = new();
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
private string productName = "";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(SaveButtonText))]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
private bool isSaving;
[ObservableProperty]
private string errorMessage = "";
[ObservableProperty]
private string message = "请输入商品名称。";
public bool IsEmpty => Products.Count == 0 && !IsSaving;
public string SaveButtonText => IsSaving ? "保存中..." : "保存商品";
private bool CanSave()
{
return !IsSaving && !string.IsNullOrWhiteSpace(ProductName);
}
[RelayCommand(CanExecute = nameof(CanSave))]
private async Task Save()
{
IsSaving = true;
ErrorMessage = "";
Message = "正在保存,请稍等。";
OnPropertyChanged(nameof(IsEmpty));
try
{
await Task.Delay(600);
Products.Add(ProductName.Trim());
ProductName = "";
Message = "保存成功。";
}
catch
{
ErrorMessage = "保存失败,请稍后重试。";
}
finally
{
IsSaving = false;
OnPropertyChanged(nameof(IsEmpty));
}
}
[RelayCommand]
private void ClearProducts()
{
Products.Clear();
ErrorMessage = "";
Message = "列表已清空。";
OnPropertyChanged(nameof(IsEmpty));
}
}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="620"
Height="460"
Title="界面打磨">
<StackPanel Margin="20"
Spacing="10">
<TextBlock Text="商品列表"
FontSize="24"
FontWeight="Bold" />
<TextBlock Text="暂无商品,请输入名称后保存。"
IsVisible="{Binding IsEmpty}" />
<ProgressBar IsIndeterminate="True"
IsVisible="{Binding IsSaving}" />
<TextBlock Text="商品名称" />
<TextBox Text="{Binding ProductName, Mode=TwoWay}"
PlaceholderText="请输入商品名称" />
<StackPanel Orientation="Horizontal"
Spacing="8">
<Button Content="{Binding SaveButtonText}"
Command="{Binding SaveCommand}" />
<Button Content="清空列表"
Command="{Binding ClearProductsCommand}" />
</StackPanel>
<TextBlock Text="{Binding ErrorMessage}"
Foreground="Red"
TextWrapping="Wrap" />
<TextBlock Text="{Binding Message}"
TextWrapping="Wrap" />
<ListBox ItemsSource="{Binding Products}" />
</StackPanel>
</Window>先用一句话理解
界面状态不是靠猜,全部由 ViewModel 属性控制:IsEmpty 控制空提示,IsSaving 控制进度条,CanSave 控制保存按钮能不能执行。
第三部分:跟着敲代码
从这里开始动手。
步骤 1:先写状态属性
先在 ViewModel 里写:
public ObservableCollection<string> Products { get; } = new();
[ObservableProperty]
private string productName = "";
[ObservableProperty]
private bool isSaving;步骤 2:写空状态和按钮文字
public bool IsEmpty => Products.Count == 0 && !IsSaving;
public string SaveButtonText => IsSaving ? "保存中..." : "保存商品";步骤 3:写保存命令
使用第一部分的完整 Save 方法。重点看:
try
{
await Task.Delay(600);
Products.Add(ProductName.Trim());
}
finally
{
IsSaving = false;
}步骤 4:写界面
使用第一部分的完整 Views/MainWindow.axaml。重点看:
IsVisible="{Binding IsEmpty}"
IsVisible="{Binding IsSaving}"
Command="{Binding SaveCommand}"小白重复敲写训练
界面打磨一次只改一种问题:间距、层级、反馈。
训练 1:统一间距
<StackPanel Margin="24" Spacing="12">
<TextBlock Text="商品信息" FontSize="24" FontWeight="Bold" />
<TextBox Watermark="名称" />
<TextBox Watermark="价格" />
<Button HorizontalAlignment="Right" Content="保存" />
</StackPanel>分别把 Spacing 改成 2 和 24,比较哪一个更易读。
训练 2:增加空状态
<TextBlock Text="暂无商品"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsVisible="{Binding IsEmpty}" />训练 3:增加保存反馈
[RelayCommand]
private async Task SaveAsync()
{
StatusMessage = "正在保存...";
await Task.Delay(500);
StatusMessage = "保存成功";
}第三遍增加保存按钮的禁用状态。
每日小测
做完本页后,用这 5 题检查是否真的掌握。
1. 判断题
本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。
答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。
2. 填空题
本页主题是:界面打磨。今天至少要掌握的 3 个点是:
1. 知道界面打磨不只是“好看”,更是让状态清楚。
2. 会显示空状态:没有商品时不要一片空白。
3. 会显示保存中状态:让用户知道程序正在处理。答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。
3. 流程题
遇到本页相关功能时,先按什么顺序处理?
答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。
4. 找错误题
如果本页代码运行失败,第一步应该做什么?
答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。
5. 改需求题
在本页完整例子跑通后,至少改一个小需求。
可选改法:
- 改一个字段名称。
- 多加一个校验条件。
- 多输出一行结果。
- 把固定数据改成用户输入。
- 把一次处理改成多条数据处理。
答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:知道界面打磨不只是“好看”,更是让状态清楚。。
上位机专项练习
界面精修不是装饰堆叠,而是让层级、状态、操作和异常都更容易读懂。
下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。
专项例子 1:统一页面间距
<Grid Margin="24" RowDefinitions="Auto,16,Auto,24,*">
<TextBlock Grid.Row="0" Text="设备总览" FontSize="28" />
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="12" />
<DataGrid Grid.Row="4" />
</Grid>运行结果或界面效果:
标题、摘要和表格层级清楚改动任务: 把所有随意 Margin 改成统一间距。
专项例子 2:空状态提示
<StackPanel IsVisible="{Binding !HasDevices}"
HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="还没有设备" FontSize="22" />
<Button Content="添加第一台设备" Command="{Binding AddDeviceCommand}" />
</StackPanel>运行结果或界面效果:
无设备时告诉用户下一步做什么改动任务: 为无报警增加空状态。
专项例子 3:加载状态反馈
<StackPanel IsVisible="{Binding IsLoading}" Spacing="8">
<ProgressBar IsIndeterminate="True" />
<TextBlock Text="正在连接 PLC-01..." />
</StackPanel>运行结果或界面效果:
等待通信时不是空白界面改动任务: 连接失败后切换成错误提示和重试按钮。
第四部分:作业完整答案
这一部分给出当天作业的完整答案。完整代码就是第一部分两个文件,下面给出检查答案。
作业要求
做一个带状态反馈的商品新增页面:
- 没有商品时显示空状态。
- 保存时显示进度条。
- 保存时按钮文字变成
保存中...。 - 商品名称为空时保存按钮不可执行。
- 保存中保存按钮不可重复点击。
- 保存成功后商品出现在列表。
- 清空列表后空状态重新显示。
必须验证的操作
| 操作 | 应该看到 |
|---|---|
| 启动窗口 | 显示空状态,保存按钮不可点击 |
输入 Keyboard | 保存按钮可以点击 |
| 点击保存 | 进度条显示,按钮文字变成 保存中... |
| 保存完成 | 列表出现 Keyboard,空状态消失 |
| 点击清空列表 | 列表清空,空状态重新显示 |
如果状态不对,按这个顺序检查
- 空状态不显示:检查
IsEmpty是否是Products.Count == 0 && !IsSaving。 - 保存中不显示:检查
ProgressBar是否绑定IsSaving。 - 按钮文字不变:检查
isSaving是否有[NotifyPropertyChangedFor(nameof(SaveButtonText))]。 - 按钮状态不变:检查
productName和isSaving是否都有[NotifyCanExecuteChangedFor(nameof(SaveCommand))]。 - 清空后空状态不恢复:检查
ClearProducts里是否调用OnPropertyChanged(nameof(IsEmpty))。
今天真正要掌握的闭环
ViewModel 保存界面状态
-> IsEmpty 控制空状态
-> IsSaving 控制加载状态
-> CanSave 控制按钮是否可执行
-> ErrorMessage 固定显示错误
-> Products 控制列表内容界面打磨的重点是状态清楚,不是堆更多控件。