本文通过 10 个小案例强化 WPF 开发入门的基础。这 10 个案例是我从实际项目中抽象出来的,具有较强的实践意义,同时也能在很大程度上反映出 WPF 项目开发的一些基本概念。
1. 在新建的 WPF 项目中添加 ico 图标
- 在 csproj 文件中添加如下代码:
<Project Sdk="Microsoft.NET.Sdk">
...
</PropertyGroup>
<PropertyGroup>
<ApplicationIcon>my.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<Resource Include="my.ico" />
</ItemGroup>
</Project>
- 然后将名为 my.ico 的图标直接放在项目根目录下就可以了。
2. 在新建的 WPF 项目中增加一个 View Model 层
这个小节实现一个点击按钮之后按钮文本改变的功能。
- 创建合理的文件结构
mkdir Models ViewModels
mkdir Models/core
touch Models/core/RelayCommand.cs ViewModels/MainViewModel.cs
说明:文件夹名称 ViewModels 和 Models 是固定的,不可改变。文件 RelayCommand.cs 是一个自定义文件,但是也已经基本标准化:
- RelayCommand 的手动实现
using System.Windows.Input;
namespace wpfdemo.Models.Core
{
public class RelayCommand : ICommand
{
private Action mAction;
public event EventHandler CanExecuteChanged = (sender, e) => { };
public RelayCommand(Action action)
{
mAction = action;
}
public bool CanExecute(object parameter)
{
return true;
}
public void Execute(object parameter)
{
mAction();
}
}
public class RelayCommand<T> : ICommand
{
private Action<T> mAction;
public event EventHandler CanExecuteChanged
{
add
{
CommandManager.RequerySuggested += value;
}
remove
{
CommandManager.RequerySuggested -= value;
}
}
public RelayCommand(Action<T> action)
{
mAction = action;
}
public bool CanExecute(object parameter)
{
return true;
}
public void Execute(object parameter)
{
mAction((T)parameter);
}
}
}
本质上是实现了两个构造函数的重载,每个重载实现的内容相同:
private Action
public event EventHandler
public RelayCommand(Action action)
public bool CanExecute(object parameter)
public void Execute(object parameter)
- ViewModel 文件的实现
ViewModel 层实现两个主要内容:一个是属性的实现;另外一个是 Command 的实现。
using System.ComponentModel;
using System.Windows.Input;
using wpfdemo.Models.Core;
using System.Runtime.CompilerServices;
namespace wpfdemo.ViewModels
{
public class MainViewModel : INotifyPropertyChanged
{
// 属性示例
private string _message = "你好 WPF";
public string Message
{
get => _message;
set
{
if (_message != value)
{
_message = value;
OnPropertyChanged(nameof(Message));
}
}
}
// 命令示例
public ICommand ShowMessageCommand { get; }
public MainViewModel()
{
// 初始化命令
ShowMessageCommand = new RelayCommand(ShowMessage);
}
// 命令执行逻辑
private void ShowMessage()
{
Message = "Hello, MVVM!";
}
// 实现 INotifyPropertyChanged 接口
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
}
注意到上述代码中的 using wpfdemo.Models.Core; 实际上就是将上面实现的 RelayCommand 引入进来。因为:
namespace wpfdemo.Models.Core
---
using wpfdemo.Models.Core;
- 在 “隐藏文件” 中操作 ViewModel 中的属性
一般来说,在 InitializeComponent(); 调用之后将 ViewModel 的实例赋值给 DataContext 变量:
public MainWindow()
{
InitializeComponent();
DataContext = new MainViewModel();
}
上面的代码保证了在 xaml 文件中可以直接使用 ViewModel 实例中的属性。
然后继续在 “隐藏文件” 中定义一个 private 修饰的方法,用于改变 ViewModel 中属性的值:
private void Button_Click(object sender, RoutedEventArgs e)
{
// 错误的写法:DataContext.Message = "你好 C#";
var viewModel = DataContext as MainViewModel;
if (viewModel != null)
{
viewModel.Message = "你好 C#";
}
}
注意不能使用 DataContext.Message = "你好 C#"; 这样的方式修改属性值。这里不是 Javascript.
- xaml 文件中实现 Button 然后绑定回调函数
<Button x:Name="my_button" Content="{Binding Message}" HorizontalAlignment="Left" Margin="103,67,0,0" VerticalAlignment="Top" Click="Button_Click" />
3. 使用 Command 修改 ViewModel 层数据
和回调的方式相比,使用 Command 的方式更加的直接,也就是显式的绕过了 VM 实例。
<Button x:Name="my_button" Content="{Binding Message}" HorizontalAlignment="Left" Margin="103,67,0,0" VerticalAlignment="Top" Command="{Binding ShowMessageCommand}" />
这里一定要注意,Click="Button_Click" 和 Command="{Binding ShowMessageCommand}" 是不能共存的,否则会报错。
4. 选择文件并获取选择的路径
这一小节,实现一个点击按钮选择文件,然后将选择文件的路径展示在文本框中的功能。
- 首先,构造视图层
<Button x:Name="my_file_button" Content="打开文件选择对话框" HorizontalAlignment="Left" Margin="103,115,0,0" VerticalAlignment="Top" Click="OpenFileDiabtnSelectFile_Clicklog" />
<TextBox x:Name="txtFilePath"
Grid.Column="0"
Margin="220,116.5,300,0"
VerticalAlignment="Top"
Text="{Binding SelectedPath, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="txtFilePath_TextChanged"/>
这里使用了两个组件,一个是 Button 用来打开对话框;一个是 TextBox 用来和路径双向绑定。即:
Click=""
Text="{Binding SelectedPath, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
- 然后就是在 ViewModel 层设置对应的属性
private string _selectedPath = "";
public string SelectedPath
{
get => _selectedPath;
set
{
if (_selectedPath != value)
{
_selectedPath = value;
OnPropertyChanged(nameof(SelectedPath));
}
}
}
- 最后在 “隐藏文件” 中添加按钮的执行逻辑
private void OpenFileDiabtnSelectFile_Clicklog(object sender, RoutedEventArgs e)
{
var dialog = new Microsoft.Win32.OpenFileDialog
{
Title = "选择文件",
InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
Filter = "所有文件 (*.*)|*.*"
};
if (dialog.ShowDialog() == true)
{
string selectedFilePath = dialog.FileName;
if (!string.IsNullOrEmpty(selectedFilePath))
{
if (DataContext is MainViewModel viewModel)
{
viewModel.SelectedPath = selectedFilePath;
}
}
}
}
private void txtFilePath_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
{
}
注意 OpenFileDialog 方法中传入的三个参数 Title InitialDirectory 以及 Filter. 同时还有诸多放错和异常排除机制。
最后,还需要注意的是 TextBox 组件的 TextChanged 事件。
5. 将 ViewModel 中的数据有选择性的保存到硬盘中去
- 安装 Newtonsoft.Json 并封装成 api
在 “项目” -> “管理 NuGet 程序包” 中 -> 搜索 “Newtonsoft.Json” -> 安装
- 然后在 Models/core 中创建一个名为
FileExtensions.cs的文件,其内容如下:
using Newtonsoft.Json;
using System.IO;
namespace wpfdemo.Models.Core
{
public static class FileExtensions
{
// 将对象序列化为 JSON 并保存到文件
public static void WriteObject<T>(string filePath, T obj)
{
string json = JsonConvert.SerializeObject(obj, Formatting.Indented);
File.WriteAllText(filePath, json);
}
}
}
这一步的目的是为了封装 JsonConvert.SerializeObject 方法。
- 然后在 ViewModel 中新增保存路径和保存方法
private string _savePath = "./setting.json";
public string SavePath
{
get => _savePath;
set {
_savePath = value;
OnPropertyChanged($"{nameof(SavePath)}");
}
}
...
public ICommand SaveCommand { get; }
public MainViewModel()
{
// 初始化命令
...
SaveCommand = new RelayCommand(SaveSettings);
}
...
private void SaveSettings()
{
try
{
FileExtensions.WriteObject(_savePath, this);
MessageBox.Show("参数保存成功");
}
catch (Exception ex)
{
MessageBox.Show($"参数保存异常,具体原因{ex}");
}
}
这一步将保存配置的路径设置到了 .\bin\Debug\net8.0-windows 下面。
- 最后在视图层构建元素并使用
<Button x:Name="my_save_button" Content="保存" HorizontalAlignment="Left" Margin="103,145,0,0" VerticalAlignment="Top" Command="{Binding SaveCommand}" />
6. 选择图片并加载
需要注意的点是打开对话框的逻辑应该写在 View 层而不是 ViewModel 层,因为 VM 层无法获取到展示图片的控件的变量。
- 使用一个 Button 组件和一个 Image 组件完成这个任务
<Button x:Name="my_img_button" Content="选择图片" HorizontalAlignment="Left" Margin="103,177,0,0" VerticalAlignment="Top" Click="SelectImg" />
<Image x:Name="imageDisplayer"
Grid.Row="1"
Margin="103,217,300,50"
Stretch="UniformToFill"/>
- 选择图片并展示的逻辑就是,从对话框得到图片的地址然后实例化一个 BitmapImage 对象,将此对象的 UriSource 属性值设置为选择路径映射得到的 Uri 即可。
private void SelectImg(object sender, RoutedEventArgs e)
{
var dialog = new Microsoft.Win32.OpenFileDialog
{
Title = "选择图片",
InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.Desktop),
Filter = "选择图片 (*.png;*.jpg;*.jpeg)|*.png;*.jpg;*.jpeg"
};
if (dialog.ShowDialog() == true)
{
string selectedPath = dialog.FileName;
if (!string.IsNullOrEmpty(selectedPath))
{
// 构造要显示的图片对象
BitmapImage bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.CreateOptions = BitmapCreateOptions.IgnoreImageCache;
bitmap.UriSource = new System.Uri(selectedPath);
bitmap.EndInit();
imageDisplayer.Source = bitmap;
}
}
}
注意变量 imageDisplayer 的来源。
7. 点击不同按钮加载不同画面的功能
1. 在视图层完成功能
逻辑就是使用按钮的回调函数,点击之后导航到对应的页面上去。
- 创建视图层文件结构
mkdir Views Pages
cd Views/Pages
touch Page1.xaml Page1.xaml.cs Page2.xaml Page2.xaml.cs Page3.xaml Page3.xaml.cs
其中,xaml 和 xaml.cs 文件成套出现,其内容分别为
<Page x:Class="wpfdemo.Pages.Page1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Page1">
<Grid Background="LightBlue">
<TextBlock Text="这是第一个页面"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="24"/>
</Grid>
</Page>
using System.Windows.Controls;
namespace wpfdemo.Pages
{
public partial class Page1: Page
{
public Page1 ()
{
InitializeComponent();
}
}
}
- 然后需要在主视图中定义一组按钮,结合 Frame 组件构建 UI
<!-- 按钮 -->
<StackPanel Orientation="Horizontal" Margin="98,201,0,196">
<Button x:Name="btnPage1"
Content="页面1"
Margin="5"
Width="100"
Click="btnPage1_Click"/>
<Button x:Name="btnPage2"
Content="页面2"
Margin="5"
Width="100"
Click="btnPage2_Click"/>
<Button x:Name="btnPage3"
Content="页面3"
Margin="5"
Width="100"
Click="btnPage3_Click"/>
</StackPanel>
<!-- Frame 组件 -->
<Frame x:Name="mainFrame"
Margin="103,271,100,10"
NavigationUIVisibility="Hidden" Navigated="mainFrame_Navigated"/>
在对应的 “隐藏文件” 中:
public MainWindow()
{
...
mainFrame.Navigate(new Pages.Page1());
}
...
private void btnPage1_Click(object sender, RoutedEventArgs e)
{
mainFrame.Navigate(new Pages.Page1());
}
private void btnPage2_Click(object sender, RoutedEventArgs e)
{
mainFrame.Navigate(new Pages.Page2());
}
private void btnPage3_Click(object sender, RoutedEventArgs e)
{
mainFrame.Navigate(new Pages.Page3());
}
private void mainFrame_Navigated(object sender, System.Windows.Navigation.NavigationEventArgs e)
{
}
不难看出来,我们主要是使用 mainFrame.Navigate(new Pages.Page1()); 来完成页面跳转的。
2. 在 VM 层完成
和上面的做法不同,在 VM 层我们主要是将 Frame 的 Source 属性绑定到一个属性上,然后通过改变这个属性的值就可以实现页面的切换了。需要注意的是这里面涉及到 Command 传参这个过程。
- 修改按钮回调为 Command
<!-- 按钮 -->
<StackPanel Orientation="Horizontal" Margin="98,201,0,196">
<Button x:Name="btnPage1"
Content="页面1"
Margin="5"
Width="100"
Command="{Binding ShowPageCommand}"
CommandParameter="/Views/Pages/Page1.xaml"/>
<Button x:Name="btnPage2"
Content="页面2"
Margin="5"
Width="100"
Command="{Binding ShowPageCommand}"
CommandParameter="/Views/Pages/Page2.xaml"/>
<Button x:Name="btnPage3"
Content="页面3"
Margin="5"
Width="100"
Command="{Binding ShowPageCommand}"
CommandParameter="/Views/Pages/Page3.xaml"/>
</StackPanel>
<!-- Frame 组件 -->
<Frame x:Name="mainFrame"
Margin="103,271,100,10"
Source="{Binding PageUri}"
NavigationUIVisibility="Hidden" Navigated="mainFrame_Navigated"/>
- 在 VM 层增加一个属性和一个改变此属性的命令
private string _pageUri = "/Views/Pages/Page1.xaml";
public string PageUri
{
get => _pageUri;
set
{
if (_pageUri != value)
{
_pageUri = value;
OnPropertyChanged(nameof(PageUri));
}
}
}
...
[Newtonsoft.Json.JsonIgnore]
public ICommand ShowPageCommand { get; }
...
public MainViewModel()
{
// 初始化命令
...
ShowPageCommand = new RelayCommand<string>(ShowPage);
}
...
private void ShowPage(string pageUri)
{
PageUri = pageUri;
return;
}
需要注意的点在于页面路径相对于项目的根路径来计算的。
8. 完成一个变色进度条
使用一个 ProgressBar 组件和一个 Button 组件完成这个功能。
- 修改 progressBar.Value 的值让进度条的百分比发生变化。
- 修改 progressBar.Foreground 的值让进度条的颜色发生变化
- 使用 Color.FromRgb(b1, b2, b3) 方法生成一个 rgb 颜色
- 实例化一个定时器,并设置定时器的回调函数
timer = new DispatcherTimer();
timer.Interval = TimeSpan.FromMilliseconds(Duration * 1000 / interval); // 50
timer.Tick += Timer_Tick;
- 使用
timer.Start();启动定时器 - 使用
imer.Stop()关闭定时器
private DispatcherTimer timer; // 定时器
private double progressValue; // 当前进度值
private const double Duration = 10; // 总时长(秒)
int interval = 200;
public MainWindow()
{
InitializeComponent();
DataContext = new MainViewModel();
// mainFrame.Navigate(new Pages.Page1());
// 初始化定时器
timer = new DispatcherTimer();
timer.Interval = TimeSpan.FromMilliseconds(Duration * 1000 / interval); // 50
timer.Tick += Timer_Tick;
}
// 定时器触发事件
private void Timer_Tick(object sender, EventArgs e)
{
progressValue += 100.0 / interval; // 每秒增加 10%
progressBar.Value = progressValue;
progressBar.Foreground = new SolidColorBrush(Color.FromRgb((byte)(progressValue * 255 / 100), (byte)(255 - progressValue * 255 / 100), 0));
// 如果进度达到 100%,停止定时器
if (progressValue >= 100)
{
timer.Stop();
MessageBox.Show("Progress completed!");
}
}
// 按钮点击事件
private void StartButton_Click(object sender, RoutedEventArgs e)
{
if (!timer.IsEnabled) // 如果定时器未启动
{
progressValue = 0; // 重置进度
progressBar.Value = 0;
timer.Start(); // 启动定时器
}
}
9. 完成一个多级 checkbox
逻辑就是将子级 checkbox 的 IsEnabled 属性和父级 checkbox 的 IsChecked 属性绑定到同一个 VM 属性上去。
- 视图层
<StackPanel Orientation="Horizontal" Height="100" VerticalAlignment="Top">
<CheckBox Content="父级选择框" IsChecked="{Binding FatherValid}" FontWeight="Bold" RenderTransformOrigin="-1.007,-4.34" />
<CheckBox Content="子级选择框2" IsChecked="True">
<CheckBox.Style>
<Style TargetType="{x:Type CheckBox}">
<Setter Property="FontSize" Value="13"/>
<Style.Triggers>
<DataTrigger Binding="{Binding SonValid}" Value="True">
<Setter Property="IsChecked" Value="False"/>
</DataTrigger>
<DataTrigger Binding="{Binding SonValid}" Value="False">
<Setter Property="IsChecked" Value="True"/>
</DataTrigger>
<DataTrigger Binding="{Binding FatherValid}" Value="True">
<Setter Property="IsEnabled" Value="True"/>
</DataTrigger>
<DataTrigger Binding="{Binding FatherValid}" Value="False">
<Setter Property="IsEnabled" Value="False"/>
</DataTrigger>
</Style.Triggers>
</Style>
</CheckBox.Style>
</CheckBox>
<CheckBox Content="子级选择框1" IsChecked="False">
<CheckBox.Style>
<Style TargetType="{x:Type CheckBox}">
<Setter Property="FontSize" Value="13"/>
<Style.Triggers>
<DataTrigger Binding="{Binding SonValid}" Value="True">
<Setter Property="IsChecked" Value="True"/>
</DataTrigger>
<DataTrigger Binding="{Binding SonValid}" Value="False">
<Setter Property="IsChecked" Value="False"/>
</DataTrigger>
<DataTrigger Binding="{Binding FatherValid}" Value="True">
<Setter Property="IsEnabled" Value="True"/>
</DataTrigger>
<DataTrigger Binding="{Binding FatherValid}" Value="False">
<Setter Property="IsEnabled" Value="False"/>
</DataTrigger>
</Style.Triggers>
</Style>
</CheckBox.Style>
</CheckBox>
</StackPanel>
- VM 层
private bool _fatherValid = false;
public bool FatherValid
{
get => _fatherValid;
set
{
_fatherValid = value;
OnPropertyChanged($"{nameof(FatherValid)}");
}
}
private bool _sonValid = false;
public bool SonValid
{
get => _sonValid;
set
{
_sonValid = value;
OnPropertyChanged($"{nameof(SonValid)}");
}
}
10. 引入外部组件库
本小节以 handycontrol 组件库为例。
- 通过 NuGet 安装 handycontrol
- 在 App.xaml 中引入样式文件
<Application.Resources>
...
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/HandyControl;component/Themes/SkinDefault.xaml"/>
<ResourceDictionary Source="pack://application:,,,/HandyControl;component/Themes/Theme.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
- 最后使用这个组件库中的组件 -- DatePicker
<StackPanel>
<hc:DatePicker x:Name="startTime" ShowClearButton="False" Margin="27,31,547,369"/>
</StackPanel>
需要注意的是,如果这样做了,那么当前 page 需要在 Windows 标签上引入 hc 命名控件
<Window x:Class="wpfdemo.MainWindow"
...
xmlns:hc="https://handyorg.github.io/handycontrol">
...