2023-08-16
桌面应用
00
请注意,本文编写于 256 天前,最后修改于 256 天前,其中某些信息可能已经过时。

目录

引言
目标
准备工作
点击功能方案实现
定义点击事件
定义Command
实现点击功能
测试
结论

引言

今天在做一个设置文件夹路径的功能,就是一个文本框,加个按钮,点击按钮,弹出 FolderBrowserDialog 再选择文件夹路径,简单做法,可以直接 StackPanel 横向放置一个 TextBox 和一个 Image Button,然后点击按钮在 后台代码中给 ViewModelFilePath赋值。但是这样属实不够优雅,UI 不够优雅,代码实现也可谓是强耦合,那接下来我分享一下我的实现方案。

目标

做这个设置文件夹路径的功能,我的目标是点击任何地方都可以打开 FolderBrowserDialog,那就需要把文本框,按钮作为一个整体控件,且选择完文件夹路径后就给绑定的 ViewModelFilePath 赋值。

准备工作

首先,既然要设计一个整体控件,那么 UI 如下:

image.png

接下来创建这个整体的控件,不使用 Button ,直接使用 Control,来创建自定义控件 OpenFolderBrowserControl :

Code Behind 代码如下:

csharp
public class OpenFolderBrowserControl : Control, { static OpenFolderBrowserControl() { DefaultStyleKeyProperty.OverrideMetadata(typeof(OpenFolderBrowserControl), new FrameworkPropertyMetadata(typeof(OpenFolderBrowserControl))); } public static readonly DependencyProperty FilePathProperty = DependencyProperty.Register("FilePath", typeof(string), typeof(OpenFolderBrowserControl)); [Description("文件路径")] public string FilePath { get => (string)GetValue(FilePathProperty); set => SetValue(FilePathProperty, value); } }

Themes/Generic.xaml 中的设计代码如下:

xml
<Style TargetType="{x:Type local:OpenFolderBrowserControl}"> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type local:OpenFolderBrowserControl}"> <Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}"> <StackPanel Orientation="Horizontal"> <TextBox Width="{TemplateBinding Width}" Height="56" Padding="0,0,60,0" IsEnabled="False" IsReadOnly="True" Text="{Binding FilePath, RelativeSource={RelativeSource Mode=TemplatedParent}}"> <TextBox.Style> <Style TargetType="{x:Type TextBox}"> <Setter Property="Background" Value="White" /> <Setter Property="BorderBrush" Value="#CAD2DD" /> <Setter Property="Foreground" Value="#313F56" /> <Setter Property="BorderThickness" Value="2" /> <Setter Property="KeyboardNavigation.TabNavigation" Value="None" /> <Setter Property="HorizontalContentAlignment" Value="Left" /> <Setter Property="FocusVisualStyle" Value="{x:Null}" /> <Setter Property="AllowDrop" Value="False" /> <Setter Property="FontSize" Value="22" /> <Setter Property="ScrollViewer.PanningMode" Value="VerticalFirst" /> <Setter Property="Stylus.IsFlicksEnabled" Value="False" /> <Setter Property="HorizontalAlignment" Value="Left" /> <Setter Property="VerticalAlignment" Value="Center" /> <Setter Property="Margin" Value="20,0,0,0" /> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type TextBox}"> <Border x:Name="border" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="8" SnapsToDevicePixels="True"> <Grid> <ScrollViewer x:Name="PART_ContentHost" Margin="20,0,0,0" VerticalAlignment="{TemplateBinding VerticalAlignment}" VerticalContentAlignment="Center" Focusable="False" FontFamily="{TemplateBinding FontFamily}" FontSize="{TemplateBinding FontSize}" HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden" /> <TextBlock x:Name="WARKTEXT" Margin="20,0,0,0" HorizontalAlignment="Left" VerticalAlignment="Center" FontFamily="{TemplateBinding FontFamily}" FontSize="{TemplateBinding FontSize}" Foreground="#A0ADBE" Text="{TemplateBinding Tag}" Visibility="Collapsed" /> </Grid> </Border> <ControlTemplate.Triggers> <Trigger Property="IsEnabled" Value="False"> <Setter TargetName="border" Property="Opacity" Value="0.56" /> </Trigger> <Trigger Property="IsMouseOver" Value="True"> <Setter TargetName="border" Property="BorderBrush" Value="#CAD2DD" /> </Trigger> <Trigger Property="IsKeyboardFocused" Value="True"> <Setter TargetName="border" Property="BorderBrush" Value="#CAD2DD" /> </Trigger> <MultiTrigger> <MultiTrigger.Conditions> <Condition Property="Text" Value="" /> <!--<Condition Property="IsFocused" Value="False"/>--> </MultiTrigger.Conditions> <Setter TargetName="WARKTEXT" Property="Visibility" Value="Visible" /> </MultiTrigger> </ControlTemplate.Triggers> </ControlTemplate> </Setter.Value> </Setter> </Style> </TextBox.Style> </TextBox> <Border Height="56" Margin="-60,0,0,0" Background="White" BorderBrush="#CAD2DD" BorderThickness="2" CornerRadius="0,8,8,0"> <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Orientation="Horizontal"> <Ellipse Width="5" Height="5" Margin="3" Fill="#949494" /> <Ellipse Width="5" Height="5" Margin="3" Fill="#949494" /> <Ellipse Width="5" Height="5" Margin="3" Fill="#949494" /> </StackPanel> </Border> </StackPanel> </Border> </ControlTemplate> </Setter.Value> </Setter> </Style>

这样创建的控件实际上是没有点击功能的。

那么接下来看一下点击功能方案实现。

点击功能方案实现

因为有 MVVM 的存在,所以在 WPF 中 Button 点击功能有两种方案,

  • 第一种是直接注册点击事件,比如 Click="OpenFolderBrowserControl_Click"
  • 第二种是绑定Command、CommandParameter、CommandTarget,比如 Command="{Binding ClickCommand}" CommandParameter="" CommandTarget=""

但是上文中我们定义的是一个 Control ,它既没有 Click 也没有 Command,所以,我们需要给 OpenFolderBrowserControl 定义ClickCommand

定义点击事件

定义点击事件比较简单,直接声明一个 RoutedEventHandler ,命名为 Click 就可以了。

csharp
public event RoutedEventHandler? Click;

定义Command

定义 Command 就需要 ICommandSource 接口,重点介绍一下 ICommandSource 接口。

ICommandSource 接口用于指示控件可以生成和执行命令。该接口定义了三个成员

  • 定义了一个 ICommand 类型的属性 Command
  • 定义了一个表示与控件关联的, IInputElement 类型的 CommandTarget
  • 定义了一个表示命令参数,object 类型的属性 CommandParameter

上述两段的定义如下:

csharp
public class OpenFolderBrowserControl : Control, ICommandSource { //上文中已有代码此处省略... #region 定义点击事件 public event RoutedEventHandler? Click; #endregion #region 定义command public static readonly DependencyProperty CommandProperty = DependencyProperty.Register("Command", typeof(ICommand), typeof(OpenFolderBrowserControl), new UIPropertyMetadata(null)) public ICommand Command { get { return (ICommand)GetValue(CommandProperty); } set { SetValue(CommandProperty, value); } } public object CommandParameter { get { return (object)GetValue(CommandParameterProperty); } set { SetValue(CommandParameterProperty, value); } } public static readonly DependencyProperty CommandParameterProperty = DependencyProperty.Register("CommandParameter", typeof(object), typeof(OpenFolderBrowserControl)); public IInputElement CommandTarget { get { return (IInputElement)GetValue(CommandTargetProperty); } set { SetValue(CommandTargetProperty, value); } } public static readonly DependencyProperty CommandTargetProperty = DependencyProperty.Register("CommandTarget", typeof(IInputElement), typeof(OpenFolderBrowserControl));

实现点击功能

好了,到此为止我仅定义好了点击事件和 Command,但是并没有能够触发这两个功能的地方。

既然是要实现点击功能,那最直观的方法就是 OnMouseLeftButtonUp,该方法是 WPF 核心基类 UIElement的虚方法,我们可以直接重写。如下代码:

csharp
public class OpenFolderBrowserControl : Control, ICommandSource { //上文中已有代码此处省略... protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e) { base.OnMouseLeftButtonUp(e); //调用点击事件 Click?.Invoke(e.Source, e); //调用Command ICommand command = Command; object parameter = CommandParameter; IInputElement target = CommandTarget; RoutedCommand routedCmd = command as RoutedCommand; if (routedCmd != null && routedCmd.CanExecute(parameter, target)) { routedCmd.Execute(parameter, target); } else if (command != null && command.CanExecute(parameter)) { command.Execute(parameter); } } }

到此位置,我们的非Button自定义控件实现点击的需求就完成了,接下来测试一下。

测试

准备测试窗体和 ViewModel,这里为了不引入依赖包,也算是复习一下 MVVM 的实现,就手动实现 ICommandINotifyPropertyChanged

ICommand 实现:

csharp
public class RelayCommand : ICommand { private readonly Action? _execute; public RelayCommand(Action? execute) { _execute = execute; } public bool CanExecute(object? parameter) { return true; } public void Execute(object? parameter) { _execute?.Invoke(); } public event EventHandler? CanExecuteChanged; }

TestViewModel 实现: 这里的 ClickCommand 触发之后,我输出了当前 FilePath的值。

csharp
public class TestViewModel : INotifyPropertyChanged { public TestViewModel() { FilePath = Environment.GetFolderPath(Environment.SpecialFolder.Desktop); } public event PropertyChangedEventHandler? PropertyChanged; protected virtual void OnPropertyChanged(string propertyName) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } private string filePath = string.Empty; /// <summary> /// 文件路径 /// </summary> public string FilePath { get { return filePath; } set { filePath = value; OnPropertyChanged(nameof(FilePath)); } } private ICommand clickCommand = null; /// <summary> /// 点击事件 /// </summary> public ICommand ClickCommand { get { return clickCommand ??= new RelayCommand(Click); } set { clickCommand = value; } } private void Click() { MessageBox.Show($"ViewModel Clicked!The value of FilePath is {FilePath}"); } }

窗体UI代码

xml
<Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="1*" /> <ColumnDefinition Width="2*" /> </Grid.ColumnDefinitions> <TextBlock HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="22" Text="设置文件路径:" /> <local:OpenFolderBrowserControl Grid.Column="1" HorizontalAlignment="Left" Click="OpenFolderBrowserControl_Click" Command="{Binding ClickCommand}" FilePath="{Binding FilePath, Mode=TwoWay}" /> </Grid>

窗体 Code Behind 代码

csharp
public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); DataContext = new TestViewModel(); } private void OpenFolderBrowserControl_Click(object sender, RoutedEventArgs e) { FolderBrowserDialog folderBrowserDialog = new FolderBrowserDialog(); DialogResult result = folderBrowserDialog.ShowDialog(); if (result == System.Windows.Forms.DialogResult.OK) { string selectedFolderPath = folderBrowserDialog.SelectedPath; var Target = sender as OpenFolderBrowserControl; if (Target != null) { Target.FilePath = selectedFolderPath; } } } }

测试结果

我点击整个控件的任意地方,都能打开文件夹浏览器。

image.png

选择音乐文件夹后,弹窗提示 ViewModel Clicked!The value of FilePath is C:\Users\Administrator\Music

image.png

结论

从测试结果中可以看出,在 UI 注册的 ClickCommand 均触发。这个方案仅仅是抛砖引玉,只要任意控件(非button)需要实现点击功能,都可以这样去实现。

实现核心就是两个方案:

  • 直接定义点击事件。
  • 实现ICommandSource。

然后再重写各种鼠标事件,鼠标按下,鼠标抬起,双击等都可以实现。

上述方案既保证了 UI 的优雅也保证了 MVVM 架构的前后分离特性。

如果大家有更好更优雅的方案,欢迎留言讨论。

本文作者:Peter.Pan

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!