Skip to Content
Week 08Day 4 - UserControl 复用

Day 4 - UserControl 复用

建议用时:240-270 分钟

你将学会什么

  • 知道 UserControl 是可复用界面块。
  • 会创建 ProductCard.axamlProductCard.axaml.cs
  • 会用 StyledProperty 给 UserControl 暴露输入属性。
  • 会在 UserControl 内部绑定自己的属性。
  • 会在页面里多次复用同一个 UserControl。
  • 会在 DataTemplate 中使用 UserControl。

UserControl 适合封装重复出现的一块界面,比如商品卡片、搜索栏、表单区域。它不应该偷偷依赖外部控件,外部需要传什么,就设计成清晰的输入属性。

本页固定顺序

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

学习衔接

上一页学习的是“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

它内部有:

  • Border
  • StackPanel
  • 三个 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 绑定到当前 ProductCardTitle 属性。

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 自己负责内部布局,外部页面只负责把 TitlePriceStatus 传给它。

第三部分:跟着敲代码

从这里开始动手。今天会创建新文件。

例子 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 属性。

第四部分:作业完整答案

这一部分给出当天作业的完整答案。先照第三部分敲一遍,再用这里检查结果。

作业要求

做一个可复用商品卡片:

  1. 创建 ProductCard.axaml
  2. 创建 ProductCard.axaml.cs
  3. 暴露 TitlePriceStatus 三个属性。
  4. ProductCard.axaml 内部使用 ElementName=Root 绑定自己的属性。
  5. MainWindow.axaml 至少使用两次 ProductCard
  6. 能在 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列表每一项都按卡片显示

如果报错,按这个顺序检查

  1. 检查 ProductCard.axamlx:Class 是否是 ProductApp.Views.ProductCard
  2. 检查 ProductCard.axaml.cs 是否是 partial class ProductCard : UserControl
  3. 检查 TitleProperty 是否用 AvaloniaProperty.Register<ProductCard, string> 注册。
  4. 检查 ProductCard.axaml 是否写了 x:Name="Root"
  5. 检查内部绑定是否写了 ElementName=Root
  6. 检查 MainWindow.axaml 是否声明 xmlns:views="using:ProductApp.Views"

今天真正要掌握的闭环

UserControl 封装重复界面 -> StyledProperty 暴露输入属性 -> 外部页面给属性传值 -> UserControl 内部用 ElementName=Root 读取自己的属性 -> 同一个组件可以在多个地方复用

UserControl 的价值是减少重复,并让界面块的输入边界更清楚。