Day 4 - UserControl 复用
建议用时:240-270 分钟
你将学会什么
- 知道
UserControl是可复用界面块。 - 会创建
ProductCard.axaml和ProductCard.axaml.cs。 - 会用
StyledProperty给 UserControl 暴露输入属性。 - 会在 UserControl 内部绑定自己的属性。
- 会在页面里多次复用同一个 UserControl。
- 会在
DataTemplate中使用 UserControl。
UserControl 适合封装重复出现的一块界面,比如商品卡片、搜索栏、表单区域。它不应该偷偷依赖外部控件,外部需要传什么,就设计成清晰的输入属性。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
学习衔接
上一页学习的是“ControlTemplate”,今天继续学习“UserControl 复用”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。
今天的最低通过线
第一次学习不要求背完整页。完成下面 3 项,就可以继续:
- 能用自己的话说明“UserControl 复用”解决什么问题。
- 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
- 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。
第一部分:先学原理和最小知识
这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。UserControl 的核心是“把重复界面封装起来,并明确输入”。
1. 为什么需要 UserControl
如果多个页面都要显示商品卡片,你可能会反复写:
<Border>
<StackPanel>
<TextBlock Text="{Binding Name}" />
<TextBlock Text="{Binding Price}" />
</StackPanel>
</Border>重复多了以后:
- 卡片结构到处复制。
- 想加一个状态字段,要改很多地方。
- 有的地方漏改后界面不统一。
UserControl 可以把这块结构封装成一个控件。
2. UserControl 是什么
UserControl 是你自己定义的界面控件。
本页定义:
ProductCard它内部有:
BorderStackPanel- 三个
TextBlock
外部只需要使用:
<views:ProductCard Title="Keyboard" Price="199" Status="启用" />3. 为什么需要输入属性
UserControl 不能把商品名字写死。
错误做法:
<TextBlock Text="Keyboard" />这样每张卡片都只能显示 Keyboard。
正确做法是暴露属性:
public string Title
{
get => GetValue(TitleProperty);
set => SetValue(TitleProperty, value);
}外部传不同的值,内部显示不同内容。
4. StyledProperty 是什么
Avalonia 控件属性通常用 StyledProperty 暴露。
public static readonly StyledProperty<string> TitleProperty =
AvaloniaProperty.Register<ProductCard, string>(nameof(Title), "");这句话注册了一个 Avalonia 能识别的属性。注册后,XAML 才能这样写:
<views:ProductCard Title="Keyboard" />5. ElementName=Root 是什么
UserControl 内部要绑定自己的属性,不是外部页面的 ViewModel。
先给 UserControl 起名:
x:Name="Root"再在内部绑定:
Text="{Binding Title, ElementName=Root}"意思是:这个 TextBlock.Text 绑定到当前 ProductCard 的 Title 属性。
6. UserControl 和 DataTemplate 的关系
DataTemplate 负责每条数据怎么显示。
如果模板内部变复杂,可以把模板里的结构换成 UserControl:
<views:ProductCard Title="{Binding Name}"
Price="{Binding Price}"
Status="{Binding EnabledText}" />这样 DataTemplate 负责把数据传给组件,UserControl 负责显示结构。
7. UserControl 不应该做什么
UserControl 不应该偷偷做全局业务逻辑。
不适合放进 ProductCard:
- 保存商品。
- 删除商品。
- 调用数据库。
- 修改外部列表。
适合放进 ProductCard:
- 商品名称怎么显示。
- 价格怎么显示。
- 状态怎么显示。
- 卡片内部布局。
UserControl 复用常用写法速查
| 需求 | 写法 | 说明 |
|---|---|---|
| 创建用户控件 | ProductCard.axaml | 复用一块界面 |
| 代码文件 | ProductCard.axaml.cs | 初始化控件 |
| 引用命名空间 | xmlns:views="using:App.Views" | 在页面使用控件 |
| 使用控件 | <views:ProductCard /> | 像普通控件一样用 |
| 传数据 | DataContext="{Binding SelectedProduct}" | 当前控件绑定指定对象 |
| 拆页面 | 表单、卡片、工具栏 | 降低主窗口复杂度 |
判断是否要拆 UserControl:
一块界面重复出现
一个页面太长
某块界面有明确独立含义第二部分:把知识组合成完整例子
今天要做一个可复用商品卡片 ProductCard。以后任何页面想显示商品卡片,都可以直接写:
<views:ProductCard Title="Keyboard" Price="199" Status="启用" />Views/ProductCard.axaml
新建文件 Views/ProductCard.axaml。
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ProductApp.Views.ProductCard"
x:Name="Root">
<Border BorderBrush="#DDDDDD"
BorderThickness="1"
Padding="12"
Margin="0,0,0,8">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Title, ElementName=Root}"
FontSize="18"
FontWeight="Bold" />
<TextBlock Text="{Binding Price, ElementName=Root}" />
<TextBlock Text="{Binding Status, ElementName=Root}" />
</StackPanel>
</Border>
</UserControl>Views/ProductCard.axaml.cs
新建文件 Views/ProductCard.axaml.cs。
using Avalonia;
using Avalonia.Controls;
namespace ProductApp.Views;
public partial class ProductCard : UserControl
{
public static readonly StyledProperty<string> TitleProperty =
AvaloniaProperty.Register<ProductCard, string>(nameof(Title), "");
public static readonly StyledProperty<decimal> PriceProperty =
AvaloniaProperty.Register<ProductCard, decimal>(nameof(Price));
public static readonly StyledProperty<string> StatusProperty =
AvaloniaProperty.Register<ProductCard, string>(nameof(Status), "");
public string Title
{
get => GetValue(TitleProperty);
set => SetValue(TitleProperty, value);
}
public decimal Price
{
get => GetValue(PriceProperty);
set => SetValue(PriceProperty, value);
}
public string Status
{
get => GetValue(StatusProperty);
set => SetValue(StatusProperty, value);
}
public ProductCard()
{
InitializeComponent();
}
}Views/MainWindow.axaml
把下面内容放到 Views/MainWindow.axaml。
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="using:ProductApp.Views"
x:Class="ProductApp.Views.MainWindow"
Width="560"
Height="420"
Title="UserControl 复用">
<StackPanel Margin="20"
Spacing="8">
<TextBlock Text="商品卡片"
FontSize="24"
FontWeight="Bold" />
<views:ProductCard Title="Keyboard"
Price="199"
Status="启用" />
<views:ProductCard Title="Mouse"
Price="99"
Status="启用" />
</StackPanel>
</Window>先用一句话理解
ProductCard 自己负责内部布局,外部页面只负责把 Title、Price、Status 传给它。
第三部分:跟着敲代码
从这里开始动手。今天会创建新文件。
例子 1:创建 ProductCard
先创建:
Views/ProductCard.axaml
Views/ProductCard.axaml.cs内容使用第一部分的完整代码。
例子 2:在页面直接使用 ProductCard
MainWindow.axaml 顶部要加命名空间:
xmlns:views="using:ProductApp.Views"然后就可以写:
<views:ProductCard Title="Keyboard"
Price="199"
Status="启用" />例子 3:在 DataTemplate 里使用 ProductCard
ViewModels/MainWindowViewModel.cs
using System.Collections.ObjectModel;
namespace ProductApp.ViewModels;
public partial class MainWindowViewModel : ViewModelBase
{
public ObservableCollection<ProductItem> Products { get; } = new()
{
new ProductItem { Name = "Keyboard", Price = 199, Enabled = true },
new ProductItem { Name = "Old Monitor", Price = 399, Enabled = false }
};
}
public sealed class ProductItem
{
public string Name { get; set; } = "";
public decimal Price { get; set; }
public bool Enabled { get; set; }
public string EnabledText => Enabled ? "启用" : "停用";
}Views/MainWindow.axaml
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ProductApp.ViewModels"
xmlns:views="using:ProductApp.Views"
x:Class="ProductApp.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Width="620"
Height="420"
Title="UserControl + DataTemplate">
<ListBox Margin="20"
ItemsSource="{Binding Products}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ProductItem">
<views:ProductCard Title="{Binding Name}"
Price="{Binding Price}"
Status="{Binding EnabledText}" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Window>常见错误和修法
| 错误 | 为什么错 | 修法 |
|---|---|---|
| 复制粘贴一大段界面 | 多处修改容易漏 | 抽成 UserControl |
| UserControl 里写死数据 | 复用不了 | 通过属性或 DataContext 传数据 |
| 控件命名空间引用错 | XAML 找不到控件 | 检查 xmlns 和项目命名空间 |
| 复用控件职责太大 | 一个控件变成整个页面 | 只抽可重复的小块 |
| 事件逻辑留在控件里 | 使用场景受限 | 可复用控件尽量通过绑定和命令交互 |
小白重复敲写训练
用户控件先完成“传入标题并显示”,再增加命令。
训练 1:创建标题控件
<!-- Controls/SectionTitle.axaml -->
<UserControl
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ProductManager.Controls.SectionTitle">
<TextBlock Text="{Binding Title, RelativeSource={RelativeSource AncestorType=UserControl}}"
FontSize="22"
FontWeight="Bold" />
</UserControl>训练 2:定义可绑定属性
public static readonly StyledProperty<string?> TitleProperty =
AvaloniaProperty.Register<SectionTitle, string?>(nameof(Title));
public string? Title
{
get => GetValue(TitleProperty);
set => SetValue(TitleProperty, value);
}训练 3:在窗口复用两次
<StackPanel Spacing="20">
<local:SectionTitle Title="商品信息" />
<local:SectionTitle Title="库存信息" />
</StackPanel>第三遍给控件增加 Subtitle 属性。
每日小测
做完本页后,用这 5 题检查是否真的掌握。
1. 判断题
本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。
答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。
2. 填空题
本页主题是:UserControl 复用。今天至少要掌握的 3 个点是:
1. 知道 `UserControl` 是可复用界面块。
2. 会创建 `ProductCard.axaml` 和 `ProductCard.axaml.cs`。
3. 会用 `StyledProperty` 给 UserControl 暴露输入属性。答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。
3. 流程题
遇到本页相关功能时,先按什么顺序处理?
答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。
4. 找错误题
如果本页代码运行失败,第一步应该做什么?
答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。
5. 改需求题
在本页完整例子跑通后,至少改一个小需求。
可选改法:
- 改一个字段名称。
- 多加一个校验条件。
- 多输出一行结果。
- 把固定数据改成用户输入。
- 把一次处理改成多条数据处理。
答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:知道 UserControl 是可复用界面块。。
上位机专项练习
UserControl 把设备卡、报警条和连接面板封装成可重复使用的界面模块。
下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。
专项例子 1:DeviceCard 用户控件
<UserControl xmlns="https://github.com/avaloniaui">
<Border Padding="14" CornerRadius="8">
<StackPanel>
<TextBlock Text="{Binding Name}" FontWeight="Bold" />
<TextBlock Text="{Binding Status}" />
<TextBlock Text="{Binding ValueText}" FontSize="24" />
</StackPanel>
</Border>
</UserControl>运行结果或界面效果:
一张可复用设备卡改动任务: 增加更新时间。
专项例子 2:在页面复用设备卡
<StackPanel Spacing="12">
<views:DeviceCard DataContext="{Binding Devices[0]}" />
<views:DeviceCard DataContext="{Binding Devices[1]}" />
</StackPanel>运行结果或界面效果:
两台设备使用同一控件改动任务: 改成 ItemsControl 自动生成。
专项例子 3:暴露可绑定属性
public static readonly StyledProperty<string> TitleProperty =
AvaloniaProperty.Register<DeviceCard, string>(nameof(Title), "设备");
public string Title
{
get => GetValue(TitleProperty);
set => SetValue(TitleProperty, value);
}运行结果或界面效果:
外部可以通过 Title 设置卡片标题改动任务: 增加 Unit 属性。
第四部分:作业完整答案
这一部分给出当天作业的完整答案。先照第三部分敲一遍,再用这里检查结果。
作业要求
做一个可复用商品卡片:
- 创建
ProductCard.axaml。 - 创建
ProductCard.axaml.cs。 - 暴露
Title、Price、Status三个属性。 ProductCard.axaml内部使用ElementName=Root绑定自己的属性。MainWindow.axaml至少使用两次ProductCard。- 能在
DataTemplate里用ProductCard显示商品列表。
Views/ProductCard.axaml 完整答案
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ProductApp.Views.ProductCard"
x:Name="Root">
<Border BorderBrush="#DDDDDD"
BorderThickness="1"
Padding="12"
Margin="0,0,0,8">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Title, ElementName=Root}"
FontSize="18"
FontWeight="Bold" />
<TextBlock Text="{Binding Price, ElementName=Root}" />
<TextBlock Text="{Binding Status, ElementName=Root}" />
</StackPanel>
</Border>
</UserControl>Views/ProductCard.axaml.cs 完整答案
using Avalonia;
using Avalonia.Controls;
namespace ProductApp.Views;
public partial class ProductCard : UserControl
{
public static readonly StyledProperty<string> TitleProperty =
AvaloniaProperty.Register<ProductCard, string>(nameof(Title), "");
public static readonly StyledProperty<decimal> PriceProperty =
AvaloniaProperty.Register<ProductCard, decimal>(nameof(Price));
public static readonly StyledProperty<string> StatusProperty =
AvaloniaProperty.Register<ProductCard, string>(nameof(Status), "");
public string Title
{
get => GetValue(TitleProperty);
set => SetValue(TitleProperty, value);
}
public decimal Price
{
get => GetValue(PriceProperty);
set => SetValue(PriceProperty, value);
}
public string Status
{
get => GetValue(StatusProperty);
set => SetValue(StatusProperty, value);
}
public ProductCard()
{
InitializeComponent();
}
}Views/MainWindow.axaml 完整答案
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="using:ProductApp.Views"
x:Class="ProductApp.Views.MainWindow"
Width="560"
Height="420"
Title="UserControl 复用">
<StackPanel Margin="20"
Spacing="8">
<TextBlock Text="商品卡片"
FontSize="24"
FontWeight="Bold" />
<views:ProductCard Title="Keyboard"
Price="199"
Status="启用" />
<views:ProductCard Title="Mouse"
Price="99"
Status="启用" />
</StackPanel>
</Window>必须验证的结果
| 检查项 | 应该看到 |
|---|---|
| ProductCard 单独使用 | 能显示 Keyboard 卡片 |
| ProductCard 复用 | 能显示 Mouse 卡片 |
| 修改 ProductCard.axaml | 两张卡片一起变化 |
| DataTemplate 使用 ProductCard | 列表每一项都按卡片显示 |
如果报错,按这个顺序检查
- 检查
ProductCard.axaml的x:Class是否是ProductApp.Views.ProductCard。 - 检查
ProductCard.axaml.cs是否是partial class ProductCard : UserControl。 - 检查
TitleProperty是否用AvaloniaProperty.Register<ProductCard, string>注册。 - 检查
ProductCard.axaml是否写了x:Name="Root"。 - 检查内部绑定是否写了
ElementName=Root。 - 检查
MainWindow.axaml是否声明xmlns:views="using:ProductApp.Views"。
今天真正要掌握的闭环
UserControl 封装重复界面
-> StyledProperty 暴露输入属性
-> 外部页面给属性传值
-> UserControl 内部用 ElementName=Root 读取自己的属性
-> 同一个组件可以在多个地方复用UserControl 的价值是减少重复,并让界面块的输入边界更清楚。