WPF 详细入门教程(基于 .NET 8)
面向零基础或从 WinForms 转过来的开发者,内容由浅入深,每个知识点都配可运行的小案例。
版本说明:本文全部示例基于 .NET 8 + WPF 编写,使用 C# 12,可空引用类型(Nullable)已开启。文中涉及 string? 标注等语法均以 .NET 8 为准。
版本边界提示:
- 本文不使用 Grid.ColumnDefinitions="…" / RowDefinitions="…" 字符串简写、StackPanel.Spacing 等语法,因为它们是 .NET 9 才加入 WPF 的新特性,在 .NET 8 上会编译/解析失败。
- 如果你使用 .NET 9+,可以额外了解这些简写(见 5.7 节)。
目录
一、WPF 是什么
WPF(Windows Presentation Foundation)是微软推出的桌面 UI 框架,运行在 .NET 之上。
与 WinForms 的核心区别
| UI 描述 | C# 代码拖控件 | XAML 声明式描述 |
| 布局 | 绝对坐标(Left/Top) | 布局容器自动排列 |
| 渲染 | GDI+ | DirectX(矢量、硬件加速) |
| 数据展示 | 手动赋值 label.Text = … | 数据绑定自动同步 |
| 外观定制 | 困难 | 样式/模板任意重写 |
| 分辨率 | 缩放易模糊 | 矢量缩放清晰 |
一句话理解 WPF 的核心思想:UI 和数据分离,用 XAML 描述“长什么样”,用 C# 描述“做什么事”,两者通过数据绑定连接。
二、环境准备与第一个程序
1. 安装
- 下载 Visual Studio 2022(17.8 或更高版本,社区版免费)
- 安装时勾选工作负载:.NET 桌面开发
- 确认已安装 .NET 8 SDK
2. 创建项目
⚠️ 不要选 “WPF 应用 (.NET Framework)”,那是旧的 .NET Framework 版本,语法和项目结构略有不同。
3. 运行
直接按 F5,会看到一个空白窗口。恭喜,第一个 WPF 程序跑起来了。
4. 添加一个按钮
打开 MainWindow.xaml,把 <Grid> … </Grid> 部分改成:
<Grid>
<Button Content="点我" Width="120" Height="40"
HorizontalAlignment="Center" VerticalAlignment="Center"
Click="Button_Click"/>
</Grid>
然后在 MainWindow.xaml.cs 中添加事件方法:
private void Button_Click(object sender, RoutedEventArgs e)
{
MessageBox.Show("你好,WPF!");
}
再按 F5,点击按钮就会弹窗。
小技巧:在 XAML 中写 Click=" 后按 Tab 键,VS 会自动生成事件方法。
三、项目结构解析
创建项目后你会看到这些文件:
WpfDemo/
├── App.xaml 程序入口,配置启动窗口和全局资源
├── App.xaml.cs App 的代码后置
├── MainWindow.xaml 主窗口的 UI 描述
└── MainWindow.xaml.cs 主窗口的逻辑代码
App.xaml
<Application x:Class="WpfDemo.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
<!– 全局资源放这里 –>
</Application.Resources>
</Application>
StartupUri 指定程序启动时显示哪个窗口。
MainWindow.xaml
<Window x:Class="WpfDemo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MainWindow" Height="450" Width="800">
<Grid>
<!– 内容区 –>
</Grid>
</Window>
命名空间说明
| xmlns="…presentation" | WPF 控件(Button、Grid 等) |
| xmlns:x="…xaml" | XAML 语言特性(x:Name、x:Class 等) |
| xmlns:local="clr-namespace:WpfDemo" | 当前项目中的自定义类型(自定义控件、转换器等),命名空间按实际项目名替换 |
代码后置(Code-Behind):x:Class="WpfDemo.MainWindow" 把 XAML 和 MainWindow.xaml.cs 中的类关联起来,XAML 里 x:Name 定义的控件可以在 C# 中直接访问。
.csproj 关键配置
.NET 8 WPF 项目文件大致如下,Nullable 默认开启:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWPF>true</UseWPF>
</PropertyGroup>
</Project>
因为 Nullable 是开启的,后面写代码时字符串、委托等类型需要加 ? 标注,否则会有编译器警告。
四、XAML 基础语法
XAML 本质是 XML,用标签描述对象,用属性描述特征。
4.1 属性赋值
简单属性(字符串、数字):
<Button Content="确定" Width="100"/>
复杂属性(对象、集合)用“属性元素语法”:
<Button Width="100">
<Button.Content>
<StackPanel Orientation="Horizontal">
<TextBlock Text="图标"/>
<TextBlock Text="文字"/>
</StackPanel>
</Button.Content>
</Button>
4.2 特殊字符转义
| < | < |
| > | > |
| & | & |
| " | " |
4.3 x:Name 与 x:Class
<TextBox x:Name="txtName" />
// C# 中可直接使用
string text = txtName.Text;
4.4 常用标记扩展(Markup Extension)
标记扩展用 { } 包裹,是 XAML 里“动态取值”的语法糖。
| {Binding …} | 数据绑定 |
| {StaticResource Key} | 引用资源(加载时一次性查找) |
| {DynamicResource Key} | 引用资源(每次使用查找,可换肤) |
| {x:Static Namespace.Type.Member} | 引用静态字段/属性/常量 |
| {x:Type Namespace.Type} | 引用类型对象 |
| {x:Null} | 显式设置为 null |
| {x:Array Type=…} | 声明数组 |
示例:
<TextBlock Text="{x:Static System:Environment.MachineName}"
Foreground="{StaticResource PrimaryBrush}"/>
4.5 注释
<!– 这是注释 –>
五、布局系统
WPF 布局的核心思想:父容器负责安排子元素的位置和大小,而不是子元素自己定坐标。
5.1 Grid(最常用)
用行和列划分区域:
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="2*"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Button Grid.Row="0" Grid.Column="0" Content="左上"/>
<Button Grid.Row="0" Grid.Column="1" Content="右上"/>
<Button Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2" Content="跨两列"/>
<Button Grid.Row="1" Grid.Column="0" Grid.RowSpan="2" Content="跨两行"/>
</Grid>
Height/Width 的三种取值:
- Auto:根据内容自动
- *:按比例分配剩余空间(2* 表示占两份)
- 固定值:如 100
⚠️ 注意:Grid.Row、Grid.Column、Grid.RowSpan、Grid.ColumnSpan 都是从 0 开始的索引。Grid.RowSpan 不要超出实际行数,否则会静默失效或布局异常。上面示例中 Grid.Row="1" Grid.RowSpan="2" 表示从第 1 行起跨 2 行(即第 1、2 行),共 3 行,正好用满。
5.2 StackPanel
按顺序堆叠,不换行:
<StackPanel Orientation="Vertical">
<Button Content="按钮1" Margin="0,0,0,8"/>
<Button Content="按钮2" Margin="0,0,0,8"/>
<Button Content="按钮3"/>
</StackPanel>
Orientation 可选 Vertical(默认)或 Horizontal。
💡 关于 Spacing 属性:.NET 9 起 WPF 的 StackPanel 增加了 Spacing 属性,用于统一设置子元素间距。但 .NET 8 不支持,请使用 Margin(或用 Grid 的行列间距)实现相同效果。.NET 9+ 的新特性见 5.7 节。
5.3 DockPanel
贴边停靠,最后一个元素填满剩余空间:
<DockPanel>
<Button DockPanel.Dock="Top" Content="顶部"/>
<Button DockPanel.Dock="Left" Content="左侧"/>
<Button DockPanel.Dock="Bottom" Content="底部"/>
<Button Content="填充剩余"/>
</DockPanel>
设置 LastChildFill="False" 可让最后一个也不填充。
5.4 WrapPanel
自动换行的流式布局,适合标签墙、图片列表:
<WrapPanel>
<Button Content="标签1" Margin="4"/>
<Button Content="标签2" Margin="4"/>
<!– 超出宽度会自动换行 –>
</WrapPanel>
5.5 Canvas
绝对坐标定位,类似 WinForms:
<Canvas>
<Button Canvas.Left="50" Canvas.Top="30" Content="坐标(50,30)"/>
</Canvas>
5.6 布局选择建议
| 表单、复杂分区 | Grid |
| 垂直/水平排列 | StackPanel |
| 页面框架(头/侧/底/中) | DockPanel |
| 自动换行列表 | WrapPanel |
| 绘图、精确定位 | Canvas |
5.7 .NET 9+ 新特性(选读)
以下语法 .NET 8 不支持,仅在 .NET 9 及以上可用。如果你的项目已升级到 .NET 9,可以使用它们让 XAML 更简洁。
Grid 行列简写:
<Grid ColumnDefinitions="100,*" RowDefinitions="Auto,*,2*">
<Button Grid.Row="0" Grid.Column="0" Content="左上"/>
<Button Grid.Row="0" Grid.Column="1" Content="右上"/>
</Grid>
StackPanel.Spacing:
<StackPanel Orientation="Vertical" Spacing="8">
<Button Content="按钮1"/>
<Button Content="按钮2"/>
</StackPanel>
.NET 8 项目请使用本文其他章节的完整写法。
六、常用控件
6.1 文本类
<TextBlock Text="只读文本" FontSize="16" Foreground="Gray"/>
<TextBox Text="可编辑文本" Width="200"/>
<PasswordBox Width="200"/>
<RichTextBox Height="100"/>
6.2 按钮类
<Button Content="普通按钮" Click="Button_Click"/>
<RepeatButton Content="长按重复"/>
<ToggleButton Content="开关"/>
<CheckBox Content="复选框" IsChecked="True"/>
<RadioButton Content="单选A" GroupName="g1" IsChecked="True"/>
<RadioButton Content="单选B" GroupName="g1"/>
6.3 列表类
<ListBox x:Name="listBox" Height="150">
<ListBoxItem>选项1</ListBoxItem>
<ListBoxItem>选项2</ListBoxItem>
</ListBox>
<ComboBox x:Name="comboBox" Width="150">
<ComboBoxItem>北京</ComboBoxItem>
<ComboBoxItem>上海</ComboBoxItem>
</ComboBox>
6.4 数值与进度
<Slider Minimum="0" Maximum="100" Value="30" Width="200"/>
<ProgressBar Value="60" Maximum="100" Width="200" Height="20"/>
6.5 图片
<Image Source="/Images/logo.png" Width="100" Height="100" Stretch="Uniform"/>
图片需在项目中设置“生成操作”为 Resource。等价的 pack URI 写法为:
<Image Source="pack://application:,,,/Images/logo.png"/>
6.6 常用通用属性
| Width / Height | 宽高 |
| Margin | 外边距(左,上,右,下) |
| Padding | 内边距 |
| HorizontalAlignment | Left / Center / Right / Stretch |
| VerticalAlignment | Top / Center / Bottom / Stretch |
| Background / Foreground | 背景 / 前景色 |
| FontSize / FontFamily | 字体 |
| IsEnabled | 是否可用 |
| Visibility | Visible / Hidden / Collapsed |
| ToolTip | 悬停提示 |
Hidden 与 Collapsed 的区别:Hidden 占位但不可见,Collapsed 不占位。
七、事件处理
7.1 在 XAML 中绑定事件
<Button Content="点击" Click="MyButton_Click"/>
private void MyButton_Click(object sender, RoutedEventArgs e)
{
var btn = (Button)sender;
btn.Content = "已点击";
}
7.2 在代码中绑定事件
var btn = new Button { Content = "动态按钮" };
btn.Click += (s, e) => MessageBox.Show("动态创建并绑定");
rootPanel.Children.Add(btn);
7.3 常用事件
| Button | Click |
| TextBox | TextChanged、GotFocus、LostFocus、KeyDown |
| Window | Loaded、Closing、Closed |
| ListBox | SelectionChanged |
| CheckBox | Checked、Unchecked |
| 通用 | MouseEnter、MouseLeave、MouseDown |
7.4 路由事件(简要了解)
WPF 事件是路由事件,可以在父容器上统一处理子元素的事件:
<StackPanel Button.Click="AnyButton_Click">
<Button Content="A"/>
<Button Content="B"/>
</StackPanel>
private void AnyButton_Click(object sender, RoutedEventArgs e)
{
// sender 是 StackPanel,e.OriginalSource 才是真正被点的按钮
var btn = e.OriginalSource as Button;
MessageBox.Show($"点击了 {btn?.Content}");
// 若不想让事件继续冒泡到更上层,可设置:
// e.Handled = true;
}
八、数据绑定(重点)
数据绑定是 WPF 的灵魂,掌握它就掌握了 WPF。
8.1 最简单的绑定:元素到元素
<StackPanel Margin="20">
<Slider x:Name="slider" Minimum="0" Maximum="100" Value="30"/>
<TextBlock Text="{Binding ElementName=slider, Path=Value}"
FontSize="24" Margin="0,10"/>
</StackPanel>
拖动滑块,文本自动更新。
语法解析:
- ElementName=slider:绑定源是名为 slider 的元素
- Path=Value:取它的 Value 属性
小提示:不同控件属性的 UpdateSourceTrigger 默认值不同,直接影响绑定回写源的时机:
| TextBox.Text | LostFocus | 失去焦点才回写源,要实时需显式设为 PropertyChanged |
| Slider.Value | PropertyChanged | 拖动时实时回写 |
| CheckBox.IsChecked | PropertyChanged | 勾选时实时回写 |
| RadioButton.IsChecked | PropertyChanged | 选中时实时回写 |
| ComboBox.SelectedItem | PropertyChanged | 选择时实时回写 |
因此,只有 TextBox.Text 这类默认 LostFocus 的属性需要显式加 UpdateSourceTrigger=PropertyChanged;Slider.Value 等默认就是实时更新,无需额外设置。
8.2 绑定到 DataContext(最常用)
DataContext 是控件的默认数据源,会沿可视化树向下传递。
XAML:
<StackPanel Margin="20">
<TextBox Text="{Binding Name}" Width="200"/>
<TextBlock Text="{Binding Name}" FontSize="20"/>
<TextBlock Text="{Binding Age}"/>
</StackPanel>
C# 代码后置:
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = new Person { Name = "张三", Age = 25 };
}
}
public class Person
{
public string Name { get; set; } = "";
public int Age { get; set; }
}
注意 .NET 8 开启了 Nullable,string 属性建议初始化为空字符串或标注为 string?,否则会有 CS8618 警告。
8.3 双向绑定
默认 TextBox.Text 是双向的,但更新时机是失去焦点时。改成实时更新:
<TextBox Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"/>
Mode 可选值:
| OneWay | 源 → 目标(默认多数场景) |
| TwoWay | 双向(TextBox、CheckBox 等默认) |
| OneTime | 只绑定一次 |
| OneWayToSource | 目标 → 源 |
8.4 INotifyPropertyChanged(数据变化通知)
上面的 Person 如果在代码里改了 Name,界面不会更新。要实现属性变更通知,需实现 INotifyPropertyChanged:
using System.ComponentModel;
using System.Runtime.CompilerServices;
public class Person : INotifyPropertyChanged
{
private string _name = "";
public string Name
{
get => _name;
set
{
if (_name == value) return;
_name = value;
OnPropertyChanged();
}
}
private int _age;
public int Age
{
get => _age;
set { _age = value; OnPropertyChanged(); }
}
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
现在代码里 person.Name = "李四" 界面也会跟着变。
注意:PropertyChanged 事件在 .NET 8 下需要写成 PropertyChangedEventHandler?(可空),OnPropertyChanged 的参数也建议标 string?,以符合 Nullable 约定。
8.5 集合绑定
用 ObservableCollection<T> 代替 List<T>,集合增删会自动刷新 UI:
public ObservableCollection<string> Items { get; }
= new ObservableCollection<string>();
<ListBox ItemsSource="{Binding Items}"/>
Items.Add("新项目"); // ListBox 自动多一行
Items.RemoveAt(0); // 自动少一行
8.6 值转换器(Converter)
当源类型和目标类型不一致时(如 bool → Visibility),需要转换器。
💡 提示:WPF 自带 BooleanToVisibilityConverter,日常 True→Visible / False→Collapsed 的场景直接用它即可,无需自己写。只有需要反向逻辑(True→Collapsed)或其它复杂规则时才自定义。注意它对 null 也会处理为 Collapsed。
系统自带用法:
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
</Window.Resources>
<TextBlock Text="我出现了!"
Visibility="{Binding IsChecked, ElementName=chk,
Converter={StaticResource BoolToVis}}"/>
自定义转换器(例如反向),注意 .NET 8 Nullable 下接口签名带 ?:
using System.Globalization;
using System.Windows;
using System.Windows.Data;
public class InverseBoolToVisibilityConverter : IValueConverter
{
public object? Convert(object? value, Type targetType,
object? parameter, CultureInfo culture)
{
return (value is bool b && b) ? Visibility.Collapsed : Visibility.Visible;
}
public object? ConvertBack(object? value, Type targetType,
object? parameter, CultureInfo culture)
{
return value is Visibility v && v == Visibility.Collapsed;
}
}
使用(注意在根元素声明 xmlns:local="clr-namespace:WpfDemo"):
<Window x:Class="WpfDemo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:WpfDemo"
Title="MainWindow" Height="450" Width="800">
<Window.Resources>
<local:InverseBoolToVisibilityConverter x:Key="InvBoolToVis"/>
</Window.Resources>
<StackPanel>
<CheckBox x:Name="chk" Content="隐藏下方文本"/>
<TextBlock Text="我被隐藏了!"
Visibility="{Binding ElementName=chk, Path=IsChecked,
Converter={StaticResource InvBoolToVis}}"/>
</StackPanel>
</Window>
8.7 常用 Binding 参数速查
| Path | 绑定属性路径,支持 A.B.C |
| ElementName | 绑定到指定元素 |
| RelativeSource | 相对源,如 AncestorType=Window |
| Source | 直接指定源对象 |
| Mode | 绑定方向 |
| UpdateSourceTrigger | 更新时机(Default/PropertyChanged/LostFocus) |
| Converter | 值转换器 |
| StringFormat | 格式化 |
| FallbackValue | 绑定失败时的默认值 |
| TargetNullValue | 源为 null 时显示的值 |
⚠️ StringFormat 中的花括号必须转义,否则 XAML 会解析失败:
<!– 方式一:用单引号包裹整个格式串 –>
<TextBlock Text="{Binding Age, StringFormat='年龄:{0}岁'}"/>
<!– 方式二:用 {} 前缀转义(格式串以 { 开头时必用) –>
<TextBlock Text="{Binding Age, StringFormat={}{0}岁}"/>
九、样式、触发器与模板
9.1 Style(样式)
把重复的属性设置抽出来复用:
<Window.Resources>
<Style x:Key="PrimaryButton" TargetType="Button">
<Setter Property="Background" Value="#007ACC"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="Padding" Value="16,8"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
</Window.Resources>
<StackPanel Margin="20">
<Button Content="主要按钮" Margin="0,0,0,10"
Style="{StaticResource PrimaryButton}"/>
<Button Content="另一个" Style="{StaticResource PrimaryButton}"/>
</StackPanel>
隐式样式:不写 x:Key,则该类型的所有控件自动应用:
<Style TargetType="Button">
<Setter Property="Margin" Value="4"/>
</Style>
样式继承 BasedOn:把公共部分抽成基础样式,再派生出具体样式。
<Style x:Key="BaseButton" TargetType="Button">
<Setter Property="Padding" Value="12,6"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
<Style x:Key="PrimaryButton" TargetType="Button"
BasedOn="{StaticResource BaseButton}">
<Setter Property="Background" Value="#007ACC"/>
<Setter Property="Foreground" Value="White"/>
</Style>
9.2 触发器(Trigger)
根据条件自动改变样式:
<Style x:Key="HoverButton" TargetType="Button">
<Setter Property="Background" Value="#007ACC"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="border" Background="{TemplateBinding Background}"
CornerRadius="4" Padding="16,8">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="border" Property="Background" Value="#005A9E"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="border" Property="Background" Value="#003F6E"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="border" Property="Background" Value="#CCCCCC"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
多条件触发器 MultiTrigger:只有多个属性同时满足条件才触发。
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsMouseOver" Value="True"/>
<Condition Property="IsEnabled" Value="True"/>
</MultiTrigger.Conditions>
<Setter Property="Background" Value="#005A9E"/>
</MultiTrigger>
9.3 DataTrigger(数据触发器)
根据绑定数据的值改变外观:
<TextBlock Text="{Binding Name}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Style.Triggers>
<DataTrigger Binding="{Binding IsVip}" Value="True">
<Setter Property="Foreground" Value="Gold"/>
<Setter Property="FontWeight" Value="Bold"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
小提示:在 Style.Triggers 的 DataTrigger 中,Binding 默认会绑定到应用了该 Style 的元素的数据上下文(通常是数据项本身),而不是父级 ViewModel。如果需要绑定到 ViewModel 的属性,需要使用 RelativeSource:
<DataTrigger Binding="{Binding DataContext.IsVip,
RelativeSource={RelativeSource AncestorType=Window}}"
Value="True">
<Setter Property="Foreground" Value="Gold"/>
</DataTrigger>
多数据条件 MultiDataTrigger:
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsVip}" Value="True"/>
<Condition Binding="{Binding Age}" Value="18"/>
</MultiDataTrigger.Conditions>
<Setter Property="Foreground" Value="Gold"/>
</MultiDataTrigger>
9.4 DataTemplate(数据模板)
定义“数据对象长什么样”:
<ListBox ItemsSource="{Binding Persons}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="4">
<Border Width="36" Height="36" CornerRadius="18"
Background="#007ACC" Margin="0,0,10,0">
<TextBlock Text="{Binding Name[0]}"
FallbackValue="?" Foreground="White"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<StackPanel>
<TextBlock Text="{Binding Name}" FontWeight="Bold"/>
<TextBlock Text="{Binding Age, StringFormat='年龄:{0}岁'}"
Foreground="Gray" FontSize="12"/>
</StackPanel>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
上面 Name[0] 加了 FallbackValue="?",防止 Name 为空字符串时索引失败。
9.5 ControlTemplate(控件模板)
彻底重写控件的外观结构:
<Button Content="圆角按钮">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="#28A745" CornerRadius="20" Padding="20,10">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Button.Template>
</Button>
Style vs Template
- Style:设置属性值(颜色、边距等)
- Template:重写控件的内部结构
十、资源字典
10.1 定义与使用
<Window x:Class="WpfDemo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=System.Runtime">
<Window.Resources>
<SolidColorBrush x:Key="PrimaryBrush" Color="#007ACC"/>
<sys:Double x:Key="TitleSize">24</sys:Double>
</Window.Resources>
<StackPanel Margin="20">
<TextBlock Foreground="{StaticResource PrimaryBrush}"
FontSize="{StaticResource TitleSize}"
Text="标题"/>
</StackPanel>
</Window>
⚠️ 注意 xmlns:sys 的程序集名:.NET Framework 时代写 mscorlib,.NET 8 应写 System.Runtime。xmlns:sys 习惯放在根元素上统一声明。
10.2 StaticResource vs DynamicResource
| 查找时机 | 加载时一次 | 每次使用时 |
| 性能 | 高 | 略低 |
| 支持运行时更换 | 否 | 是 |
需要换肤时用 DynamicResource。
10.3 独立资源字典文件
新建 Styles/Colors.xaml:
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="PrimaryBrush" Color="#007ACC"/>
<SolidColorBrush x:Key="DangerBrush" Color="#DC3545"/>
</ResourceDictionary>
在 App.xaml 中合并:
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Styles/Colors.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
跨程序集 / 类库引用资源时,用 pack URI:
<ResourceDictionary Source="pack://application:,,,/YourAssembly;component/Styles/Colors.xaml"/>
十一、MVVM 模式
MVVM 是 WPF 最推荐的架构:Model – View – ViewModel。
View (XAML) ←绑定→ ViewModel ←操作→ Model
界面 状态与逻辑 数据实体
- View:只负责显示,几乎不写逻辑
- ViewModel:持有数据、命令,实现 INotifyPropertyChanged
- Model:纯数据类
11.1 为什么用 MVVM
- UI 与逻辑解耦,方便测试
- 不用在代码后置里写一堆 xxx.Text = …
- 团队协作:设计师改 XAML,程序员写 VM
11.2 ViewModel 基类(减少重复)
using System.ComponentModel;
using System.Runtime.CompilerServices;
public abstract class ViewModelBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
protected bool SetProperty<T>(ref T field, T value,
[CallerMemberName] string? name = null)
{
if (Equals(field, value)) return false;
field = value;
OnPropertyChanged(name);
return true;
}
}
使用:
private string _name = "";
public string Name
{
get => _name;
set => SetProperty(ref _name, value);
}
11.3 ICommand 与 RelayCommand
WPF 的 Button.Command 可以绑定到 ICommand,替代 Click 事件。
.NET 8 下推荐的手动刷新版本:
using System;
using System.Windows.Input;
public class RelayCommand : ICommand
{
private readonly Action<object?> _execute;
private readonly Predicate<object?>? _canExecute;
public RelayCommand(Action<object?> execute, Predicate<object?>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
public void Execute(object? parameter) => _execute(parameter);
public event EventHandler? CanExecuteChanged;
public void RaiseCanExecuteChanged()
=> CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
两种实现方式的对比:
| CommandManager.RequerySuggested 版本 | 在 CanExecuteChanged 的 add/remove 中挂钩 CommandManager | 自动刷新,代码简单 | 每次鼠标/键盘等 UI 交互都会触发所有命令的 CanExecute 重查,性能开销大;异步逻辑改了状态后不会主动刷新 |
| 手动 RaiseCanExecuteChanged 版本(本文所用) | 显式调用 RaiseCanExecuteChanged() | 性能可控,只在需要时刷新 | 需要在属性 setter 中手动调用,代码稍多 |
如果使用 CommandManager.RequerySuggested 版本,在异步操作(如 await Task.Run(…))完成后,即使状态已改变,按钮可能仍保持禁用状态,此时需要手动调用 CommandManager.InvalidateRequerySuggested() 来强制刷新。
11.4 可选:直接用 CommunityToolkit.Mvvm
如果嫌手写 INotifyPropertyChanged 和 RelayCommand 太啰嗦,可以在 .NET 8 项目中安装 NuGet 包 CommunityToolkit.Mvvm(微软官方推荐),用源生成器一行搞定:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
// 注意:使用源生成器特性的类必须是 partial
public partial class MainViewModel : ObservableObject
{
[ObservableProperty]
private string _name = "";
[RelayCommand]
private void SayHello() => System.Windows.MessageBox.Show($"Hello {Name}");
// 带 CanExecute 的异步命令示例
[RelayCommand(CanExecute = nameof(CanSave))]
private async Task SaveAsync()
{
await Task.Delay(1000); // 模拟耗时操作
}
private bool CanSave() => !string.IsNullOrWhiteSpace(Name);
}
生成的 Name 属性、SayHelloCommand、SaveCommand 都会自动处理通知与刷新。生产项目里用得很多。
命名约定:[ObservableProperty] 特性的源生成器会基于字段名自动生成首字母大写的属性。它支持 _lowerCamel 和 m_lowerCamel 两种命名约定,例如 _name 会生成 Name,m_name 也会生成 Name。请务必把类声明为 partial,否则源生成器无法工作。
十二、依赖属性与附加属性
本章对新手偏难,建议先掌握 MVVM,等到需要自己写自定义控件时再回头看。
12.1 什么是依赖属性
WPF 控件的属性大多是依赖属性(DependencyProperty)。它的特点是:
- 支持数据绑定、样式、动画、默认值继承
- 值不直接存在对象字段中,而是由 WPF 属性系统管理
12.2 自定义依赖属性
下面用 UserControl 派生类演示一个带 Title 属性的简单控件。假设 MyCard.xaml 内部有一个名为 TitleTextBlock 的 TextBlock:
using System.Windows;
using System.Windows.Controls;
public partial class MyCard : UserControl
{
public MyCard()
{
InitializeComponent();
}
public static readonly DependencyProperty TitleProperty =
DependencyProperty.Register(
nameof(Title),
typeof(string),
typeof(MyCard),
new PropertyMetadata("默认标题", OnTitleChanged));
public string Title
{
get => (string)GetValue(TitleProperty);
set => SetValue(TitleProperty, value);
}
private static void OnTitleChanged(DependencyObject d,
DependencyPropertyChangedEventArgs e)
{
var card = (MyCard)d;
card.TitleTextBlock.Text = e.NewValue as string ?? "";
}
}
XAML 中使用(同样需要 xmlns:local="clr-namespace:WpfDemo"):
<local:MyCard Title="我的卡片" Width="200" Height="80"/>
如果非要 class MyCard : Control,则必须同时在 Themes/Generic.xaml 里提供默认 Template,否则控件不显示任何内容——新手容易踩坑,所以这里用 UserControl 演示。
12.3 附加属性
附加属性是“定义在 A 类,却用在 B 对象上”的属性,比如 Grid.Row、Canvas.Left:
using System.Windows;
public static class MyAttached
{
public static readonly DependencyProperty TagProperty =
DependencyProperty.RegisterAttached(
"Tag", typeof(string), typeof(MyAttached),
new PropertyMetadata(string.Empty));
public static string? GetTag(DependencyObject obj)
=> (string?)obj.GetValue(TagProperty);
public static void SetTag(DependencyObject obj, string? value)
=> obj.SetValue(TagProperty, value);
}
新手提示:日常开发中,依赖属性更多是“使用”而非“定义”。了解即可,需要做自定义控件时再深入。
十三、综合案例:待办事项
把前面的知识串起来,做一个完整的 TodoList。
13.1 Model
Models/TodoItem.cs:
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace WpfDemo.Models
{
public class TodoItem : INotifyPropertyChanged
{
private string _title = "";
public string Title
{
get => _title;
set { _title = value; OnPropertyChanged(); }
}
private bool _isDone;
public bool IsDone
{
get => _isDone;
set { _isDone = value; OnPropertyChanged(); }
}
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}
13.2 Command(复用第 11.3 节的 RelayCommand)
Commands/RelayCommand.cs:直接使用第 11.3 节的实现。
13.3 ViewModel
ViewModels/MainViewModel.cs:
using System.Collections.ObjectModel;
using System.Windows.Input;
using WpfDemo.Models;
using WpfDemo.Commands;
namespace WpfDemo.ViewModels
{
public class MainViewModel : ViewModelBase
{
public ObservableCollection<TodoItem> Todos { get; }
= new ObservableCollection<TodoItem>();
private string _newTodoText = "";
public string NewTodoText
{
get => _newTodoText;
set
{
if (SetProperty(ref _newTodoText, value))
(AddCommand as RelayCommand)?.RaiseCanExecuteChanged();
}
}
public ICommand AddCommand { get; }
public ICommand DeleteCommand { get; }
public ICommand ClearCommand { get; }
public MainViewModel()
{
AddCommand = new RelayCommand(
_ => Add(),
_ => !string.IsNullOrWhiteSpace(NewTodoText));
DeleteCommand = new RelayCommand(
p => Delete(p as TodoItem));
ClearCommand = new RelayCommand(
_ => Clear(),
_ => Todos.Count > 0);
// 集合内容变化时,主动刷新 ClearCommand 的可用状态。
// 这是一种常见模式:当集合内容影响命令可用性时,
// 在 CollectionChanged 事件中触发 RaiseCanExecuteChanged。
Todos.CollectionChanged += (_, _) =>
(ClearCommand as RelayCommand)?.RaiseCanExecuteChanged();
}
private void Add()
{
Todos.Add(new TodoItem { Title = NewTodoText.Trim() });
NewTodoText = string.Empty;
}
private void Delete(TodoItem? item)
{
if (item != null) Todos.Remove(item);
}
private void Clear()
{
Todos.Clear();
}
}
}
关键点:
- NewTodoText 变化时手动刷新 AddCommand(因为按钮的可用性依赖输入)
- Todos 集合变化时刷新 ClearCommand(清空按钮要有内容才可用)
13.4 View
MainWindow.xaml(注意全部使用 .NET 8 兼容的完整写法):
<Window x:Class="WpfDemo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="待办事项" Height="500" Width="520"
WindowStartupLocation="CenterScreen">
<Window.Resources>
<Style TargetType="Button">
<Setter Property="Padding" Value="12,6"/>
<Setter Property="Margin" Value="4,0,0,0"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
<Style TargetType="TextBox">
<Setter Property="Padding" Value="6,4"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
</Style>
</Window.Resources>
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!– 输入区 –>
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Text="{Binding NewTodoText, UpdateSourceTrigger=PropertyChanged}"
FontSize="14"/>
<Button Grid.Column="1" Content="添加"
Command="{Binding AddCommand}"
Background="#007ACC" Foreground="White" BorderThickness="0"/>
</Grid>
<!– 列表区 –>
<ListBox Grid.Row="1" Margin="0,12" ItemsSource="{Binding Todos}"
HorizontalContentAlignment="Stretch">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid Margin="2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<CheckBox Grid.Column="0" IsChecked="{Binding IsDone}"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding Title}"
Margin="8,0" FontSize="14"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Style.Triggers>
<DataTrigger Binding="{Binding IsDone}" Value="True">
<Setter Property="TextDecorations" Value="Strikethrough"/>
<Setter Property="Foreground" Value="Gray"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<Button Grid.Column="2" Content="删除"
Command="{Binding DataContext.DeleteCommand,
RelativeSource={RelativeSource AncestorType={x:Type ListBox}}}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!– 底部 –>
<Button Grid.Row="2" Content="清空全部"
HorizontalAlignment="Right"
Command="{Binding ClearCommand}"/>
</Grid>
</Window>
13.5 代码后置
MainWindow.xaml.cs:
using System.Windows;
using WpfDemo.ViewModels;
namespace WpfDemo
{
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = new MainViewModel();
}
}
}
13.6 关键点解释
为什么 DeleteCommand 要用 RelativeSource?
DataTemplate 内部的 DataContext 已经变成了 TodoItem(列表的每一项),而 DeleteCommand 定义在 MainViewModel 上。所以要通过 RelativeSource={RelativeSource AncestorType={x:Type ListBox}} 向上找到 ListBox,再取它的 DataContext(也就是 MainViewModel),从而访问 DeleteCommand。
这是 WPF 中解决 DataTemplate 内 DataContext 隔离问题的标准模式,在 ListBox、ListView、DataGrid 等控件中会频繁使用,建议读者熟悉。
为什么 NewTodoText 的 setter 里要手动 RaiseCanExecuteChanged?
因为按钮的 CanExecute 依赖输入内容的非空性。默认的 RelayCommand 不监听属性变化,只有当输入变化时主动刷新,按钮才会由灰变亮。
按 F5 运行:输入文字 → 添加 → 勾选划线 → 删除 → 清空。
十四、动画入门
⚠️ 重要前提:WPF 动画只能作用于依赖属性(Opacity、Width、RenderTransform、Margin 等)。依赖属性提供了值表达式、属性失效、默认值、继承、数据绑定、动画和属性变更通知等支持,普通 CLR 属性无法被动画驱动。
WPF 动画基于属性,用 Storyboard 描述。
14.1 简单淡入
<Button Content="淡入按钮" Width="120" Height="40" Opacity="0">
<Button.Triggers>
<EventTrigger RoutedEvent="Loaded">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Opacity"
From="0" To="1" Duration="0:0:1"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Button.Triggers>
</Button>
14.2 鼠标悬停放大
<Button Content="悬停放大" Width="120" Height="40"
RenderTransformOrigin="0.5,0.5">
<Button.RenderTransform>
<ScaleTransform ScaleX="1" ScaleY="1"/>
</Button.RenderTransform>
<Button.Triggers>
<EventTrigger RoutedEvent="MouseEnter">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="RenderTransform.ScaleX"
To="1.15" Duration="0:0:0.2"/>
<DoubleAnimation Storyboard.TargetProperty="RenderTransform.ScaleY"
To="1.15" Duration="0:0:0.2"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
<EventTrigger RoutedEvent="MouseLeave">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="RenderTransform.ScaleX"
To="1" Duration="0:0:0.2"/>
<DoubleAnimation Storyboard.TargetProperty="RenderTransform.ScaleY"
To="1" Duration="0:0:0.2"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Button.Triggers>
</Button>
14.3 常用动画类型
| DoubleAnimation | 数值属性(Opacity、Width、角度) |
| ColorAnimation | 颜色 |
| ThicknessAnimation | 边距 |
| PointAnimation | 点坐标 |
| DoubleAnimationUsingKeyFrames | 关键帧动画 |
十五、学习建议与常见坑
学习路线
XAML 语法 → 布局 → 常用控件 → 事件
→ 数据绑定 → 样式模板 → 资源字典 → MVVM
→ 依赖属性/附加属性 → 动画 → 自定义控件
建议顺序:
常见坑
| 绑定不生效,输出窗口有 Binding 错误 | 打开 VS 的“输出”窗口查看,通常 Path 写错或 DataContext 未设置 |
| 改了数据界面不更新 | 属性没实现 INotifyPropertyChanged |
| 集合增删界面不刷新 | 用了 List<T>,应改用 ObservableCollection<T> |
| TextBox 输入时按钮状态不刷新 | 加 UpdateSourceTrigger=PropertyChanged,并在 setter 里 RaiseCanExecuteChanged |
| DataTemplate 里绑定命令失败 | DataContext 已变成数据项,需用 RelativeSource 找父级 |
| DataTrigger 中 Binding 绑定到了数据项而非 ViewModel | Style.Triggers 内的 DataTrigger 默认绑定到应用该 Style 的元素的数据上下文;如需绑定 ViewModel 属性,同样需要用 RelativeSource |
| 异步更新后命令未刷新 | 如果使用 CommandManager.RequerySuggested 版本,在 await 完成后需手动调用 CommandManager.InvalidateRequerySuggested() |
| 控件被“吃掉”看不见 | 多个元素放同一 Grid 单元格会重叠,需设置 Grid.Row/Grid.Column |
| 窗口缩放时布局乱 | 少用固定 Width/Height,多用 * 和 Auto |
| StaticResource 找不到 | 资源定义在使用之后,或未合并资源字典;改用 DynamicResource 可延后查找 |
| 图片不显示 | 图片“生成操作”要设为 Resource |
| Grid.ColumnDefinitions="…" / StackPanel.Spacing 报错 | 这两个是 .NET 9+ 才加入 WPF 的新特性,.NET 8 请用完整写法(<Grid.RowDefinitions> 与 Margin) |
| StringFormat={0} 解析失败 | 花括号需转义:用 '…' 包裹或用 {} 前缀 |
| xmlns:sys="…mscorlib" 报错 | .NET 8 应写 assembly=System.Runtime |
| 界面卡顿 | 耗时操作放后台线程(Task.Run),UI 更新回到 UI 线程 |
| 大数据列表卡顿 | ListBox/ListView/DataGrid 默认启用 UI 虚拟化;若把它们放进外层 ScrollViewer 或 StackPanel,虚拟化会失效,需改用 Grid 或设置受限高度 |
后台线程更新 UI
在 UI 线程中 await 默认会回到 UI 线程,可以直接更新控件:
private async void Button_Click(object sender, RoutedEventArgs e)
{
var result = await Task.Run(() => Compute());
MyTextBlock.Text = result; // 回到 UI 线程,可直接更新
}
如果在 Task.Run 内直接更新 UI(非 UI 线程),需要切回 UI 线程:
Application.Current.Dispatcher.Invoke(() =>
{
MyTextBlock.Text = "更新";
});
// 或异步等待
await Application.Current.Dispatcher.InvokeAsync(() =>
{
MyTextBlock.Text = "更新";
});
设计时数据(让 VS 设计器显示绑定效果)
MVVM 项目里 View 在设计器里常常看不到效果,可加设计时 DataContext:
<Window x:Class="WpfDemo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:WpfDemo.ViewModels"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance Type=vm:MainViewModel, IsDesignTimeCreatable=True}"
Title="待办事项" Height="500" Width="520">
<!– 内容 –>
</Window>
d: 前缀的属性只在设计器生效,运行时会被忽略。
调试绑定的技巧
在 VS 中:工具 → 选项 → 调试 → 输出窗口 → WPF 跟踪设置,把“数据绑定”设为“警告”或“所有”,绑定失败会在输出窗口明确提示。
也可以对单个绑定开启追踪:
<TextBlock Text="{Binding Name,
PresentationTraceSources.TraceLevel=High}"/>
推荐的进阶方向
- CollectionViewSource:对集合做排序、筛选、分组,不改动原集合
- CommunityToolkit.Mvvm:官方推荐的 MVVM 库,省去大量样板代码,支持 AsyncRelayCommand
- Microsoft.Xaml.Behaviors.Wpf:把交互逻辑变成可复用组件
- Prism:成熟的模块化 MVVM 框架
- 自定义控件(ControlTemplate + 依赖属性)
- 多线程与 async/await 更新 UI
CollectionViewSource 简单示例(对 TodoList 做过滤):
<Window.Resources>
<CollectionViewSource x:Key="FilteredTodos"
Source="{Binding Todos}"
Filter="FilteredTodos_Filter"/>
</Window.Resources>
<ListBox ItemsSource="{StaticResource FilteredTodos}"/>
private void FilteredTodos_Filter(object sender, FilterEventArgs e)
{
if (e.Item is TodoItem t)
e.Accepted = string.IsNullOrEmpty(_keyword)
|| t.Title.Contains(_keyword);
}
结语
WPF 的学习曲线主要在数据绑定和 MVVM 上,布局和控件其实很快就能上手。建议的做法是:
网硕互联帮助中心





评论前必须登录!
注册