WPF --- 非Button自定义控件实现点击功能
阅读原文时间:2023年08月18日阅读:3

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

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

首先,既然要设计一个整体控件,那么 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 定义ClickCommand

定义点击事件

定义点击事件比较简单,直接声明一个 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 的实现,就手动实现 ICommandINotifyPropertyChanged

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:\Users\Administrator\Music

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

实现核心就是两个方案:

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

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

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

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