- A+
引言
今天在做一个设置文件夹路径的功能,就是一个文本框,加个按钮,点击按钮,弹出 FolderBrowserDialog
再选择文件夹路径,简单做法,可以直接 StackPanel
横向放置一个 TextBox
和一个 Image Button
,然后点击按钮在 后台代码中给 ViewModel
的 FilePath
赋值。但是这样属实不够优雅,UI 不够优雅,代码实现也可谓是强耦合,那接下来我分享一下我的实现方案。
目标
做这个设置文件夹路径的功能,我的目标是点击任何地方都可以打开 FolderBrowserDialog
,那就需要把文本框,按钮作为一个整体控件,且选择完文件夹路径后就给绑定的 ViewModel
的 FilePath
赋值。
准备工作
首先,既然要设计一个整体控件,那么 UI 如下:
接下来创建这个整体的控件,不使用 Button
,直接使用 Control
,来创建自定义控件 OpenFolderBrowserControl
:
Code Behind 代码如下:
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 中的设计代码如下:
<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
定义Click
和 Command
。
定义点击事件
定义点击事件比较简单,直接声明一个 RoutedEventHandler
,命名为 Click
就可以了。
public event RoutedEventHandler? Click;
定义Command
定义 Command
就需要 ICommandSource
接口,重点介绍一下 ICommandSource
接口。
ICommandSource
接口用于指示控件可以生成和执行命令。该接口定义了三个成员
- 定义了一个
ICommand
类型的属性Command
, - 定义了一个表示与控件关联的,
IInputElement
类型的CommandTarget
- 定义了一个表示命令参数,
object
类型的属性CommandParameter
上述两段的定义如下:
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
的虚方法,我们可以直接重写。如下代码:
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 的实现,就手动实现 ICommand
和 INotifyPropertyChanged
。
ICommand 实现:
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
的值。
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代码:
<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 代码
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; } } } }
测试结果
我点击整个控件的任意地方,都能打开文件夹浏览器。
选择音乐文件夹后,弹窗提示 ViewModel Clicked!The value of FilePath is C:UsersAdministratorMusic
结论
从测试结果中可以看出,在 UI 注册的 Click 和 Command 均触发。这个方案仅仅是抛砖引玉,只要任意控件(非button)需要实现点击功能,都可以这样去实现。
实现核心就是两个方案:
- 直接定义点击事件。
- 实现ICommandSource。
然后再重写各种鼠标事件,鼠标按下,鼠标抬起,双击等都可以实现。
上述方案既保证了 UI 的优雅也保证了 MVVM 架构的前后分离特性。
如果大家有更好更优雅的方案,欢迎留言讨论。