WPF 练手小案例

397 阅读9分钟

本文通过 10 个小案例强化 WPF 开发入门的基础。这 10 个案例是我从实际项目中抽象出来的,具有较强的实践意义,同时也能在很大程度上反映出 WPF 项目开发的一些基本概念。

1. 在新建的 WPF 项目中添加 ico 图标

  1. 在 csproj 文件中添加如下代码:
<Project Sdk="Microsoft.NET.Sdk">
  ...
  </PropertyGroup>
    <PropertyGroup>
    <ApplicationIcon>my.ico</ApplicationIcon>
  </PropertyGroup>
  <ItemGroup>
    <Resource Include="my.ico" />
  </ItemGroup>
</Project>
  1. 然后将名为 my.ico 的图标直接放在项目根目录下就可以了。

2. 在新建的 WPF 项目中增加一个 View Model 层

这个小节实现一个点击按钮之后按钮文本改变的功能。

  1. 创建合理的文件结构
mkdir Models ViewModels
mkdir Models/core
touch Models/core/RelayCommand.cs ViewModels/MainViewModel.cs 

说明:文件夹名称 ViewModels 和 Models 是固定的,不可改变。文件 RelayCommand.cs 是一个自定义文件,但是也已经基本标准化:

  1. 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)
  1. 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;
  1. 在 “隐藏文件” 中操作 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.

  1. 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. 选择文件并获取选择的路径

这一小节,实现一个点击按钮选择文件,然后将选择文件的路径展示在文本框中的功能。

  1. 首先,构造视图层
<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}"
  1. 然后就是在 ViewModel 层设置对应的属性
private string _selectedPath = "";

public string SelectedPath
{
    get => _selectedPath;
    set
    {
        if (_selectedPath != value)
        {
            _selectedPath = value;
            OnPropertyChanged(nameof(SelectedPath));
        }
    }
}
  1. 最后在 “隐藏文件” 中添加按钮的执行逻辑
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 中的数据有选择性的保存到硬盘中去

  1. 安装 Newtonsoft.Json 并封装成 api

在 “项目” -> “管理 NuGet 程序包” 中 -> 搜索 “Newtonsoft.Json” -> 安装

  1. 然后在 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 方法。

  1. 然后在 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 下面。

  1. 最后在视图层构建元素并使用
<Button x:Name="my_save_button" Content="保存" HorizontalAlignment="Left" Margin="103,145,0,0" VerticalAlignment="Top" Command="{Binding SaveCommand}" />

6. 选择图片并加载

需要注意的点是打开对话框的逻辑应该写在 View 层而不是 ViewModel 层,因为 VM 层无法获取到展示图片的控件的变量。

  1. 使用一个 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"/>
  1. 选择图片并展示的逻辑就是,从对话框得到图片的地址然后实例化一个 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. 在视图层完成功能

逻辑就是使用按钮的回调函数,点击之后导航到对应的页面上去。

  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();
        }
    }
}
  1. 然后需要在主视图中定义一组按钮,结合 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 传参这个过程。

  1. 修改按钮回调为 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"/>
  1. 在 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 属性上去。

  1. 视图层
<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>
  1. 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 组件库为例。

  1. 通过 NuGet 安装 handycontrol
  2. 在 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>
  1. 最后使用这个组件库中的组件 -- 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">
...