总目录



前言

本章介绍WPF中的事件与命令,以及如何创建自定义的事件与命令,掌握了事件与命令加上之前的属性通知,对于我们后续掌握MVVM及其原理是大有帮助的。


一、事件

WPF 引入了路由事件,这些事件可在应用程序的元素树中调用存在于各个侦听器上的处理程序。

1.路由事件

功能定义:路由事件是一种可以针对元素树中的多个侦听器(而不是仅针对引发该事件的对象)调用处理程序的事件。
实现定义:路由事件是一个 CLR 事件,由 RoutedEvent 类的实例提供支持并由 Windows Presentation Foundation (WPF) 事件系统处理。

其实路由事件我们并不陌生,如我们常用的Click、MouseDown、MouseEnter、MouseLeave、MouseLeftButtonDown、MouseLeftButtonUp 等都是路由事件

1、从案例开始

首先我们定义一个简单的Click 事件,点击按钮弹框即可。

<Button Height="50" Width="150" Content="初步认识事件" Click="Button_Click"></Button>
private void Button_Click(object sender, RoutedEventArgs e)
{
     MessageBox.Show("我被点击了!");
}

或者

<Button x:Name="btn" Height="50" Width="150" Content="初步认识事件"></Button>
        public MainWindow()
        {
            InitializeComponent();
            this.btn.Click += new RoutedEventHandler(Btn_Click);
        }

        private void Btn_Click(object sender, RoutedEventArgs e)
        {
            MessageBox.Show("我被点击了!");
        }

上面就是我们平常定义事件的两种方式,第一种是将事件的定义放在了xaml页面中,第二种则是将事件定义在代码隐藏文件的分部类中,Btn_Click 就是事件的处理程序,负责处理事件触发后的业务逻辑。
那这个Click 是什么呢?我们F12进去瞧瞧。

// Click 是个event
public event RoutedEventHandler Click;

//RoutedEventHandler 是一个委托,两个入参:object sender 表示事件源,RoutedEventArgs e 则表示事件中的参数信息
public delegate void RoutedEventHandler(object sender, RoutedEventArgs e);

通过这个我们知道实质上Click就是WPF中已经定义好的一个事件,具有两个入参,我们用的时候,通过+= 注册事件的处理程序。而正因为是Click是一个事件因此我们还可以使用lambda简写: this.btn.Click += (s,e) => { MessageBox.Show("我被点击了!"); };

2、元素树

来,还是上案例:

    <Border Background="LightGreen" BorderBrush="Red" BorderThickness="2" Margin="10">
        <StackPanel Background="LightBlue" Orientation="Horizontal" VerticalAlignment="Center" HorizontalAlignment="Center">
            <Button Name="YesButton" Width="100" Height="50" Margin="15">Yes</Button>
            <Button Name="NoButton"  Width="100" Height="50" Margin="15">No</Button>
            <Button Name="CancelButton"  Width="100" Height="50" Margin="15">Cancel</Button>
        </StackPanel>
    </Border>

在这里插入图片描述
如上图,对照了文档大纲与实际运行的界面,文档大纲这类一层包一层的元素层级结构,就是WPF中的元素树。了解元素树对于我们理解路由策略是很有帮助的。

3、路由事件的顶级方案

仍旧看上面案例,有三个按钮,现在呢,每个按钮都有一个Click 事件,对于部分场景下,这样定义很麻烦,因此呢,我们可以做如下处理:在三个按钮的父级元素上定义一个事件供三个按钮使用,具体实现如下:

首先在StackPanel 添加了一个ButtonBase.Click="StackPanel_Click" 事件

    <Border Background="LightGreen" BorderBrush="Red" BorderThickness="2" Margin="10">
        <StackPanel ButtonBase.Click="StackPanel_Click" Background="LightBlue" Orientation="Horizontal" VerticalAlignment="Center" HorizontalAlignment="Center">
            <Button Name="YesButton" Width="100" Height="50" Margin="15">Yes</Button>
            <Button Name="NoButton"  Width="100" Height="50" Margin="15">No</Button>
            <Button Name="CancelButton"  Width="100" Height="50" Margin="15">Cancel</Button>
        </StackPanel>
    </Border>

然后编写该事件逻辑代码:

        private void StackPanel_Click(object sender, RoutedEventArgs e)
        {
            FrameworkElement feSource = e.Source as FrameworkElement;
            switch (feSource.Name)
            {
                case "YesButton":
                    MessageBox.Show("YesButton");
                    break;
                case "NoButton":
                    MessageBox.Show("NoButton");
                    break;
                case "CancelButton":
                    MessageBox.Show("CancelButton");
                    break;
            }
            e.Handled = true;
        }

或者我们不在xaml上定义,直接在cs文件使用AddHandler方法直接给StackPanel添加一个路由事件

        public MainWindow()
        {
            InitializeComponent();
            //第一个参数,是决定路由事件类型,第二个参数是传入一个委托,事件的处理程序
            stackPanel.AddHandler(ButtonBase.ClickEvent,new RoutedEventHandler(stackPanelClick));            
        }

        private void stackPanelClick(object sender, RoutedEventArgs e)
        {
            FrameworkElement feSource = e.Source as FrameworkElement;
            switch (feSource.Name)
            {
                case "YesButton":
                    MessageBox.Show("YesButton1");
                    break;
                case "NoButton":
                    MessageBox.Show("NoButton2");
                    break;
                case "CancelButton":
                    MessageBox.Show("CancelButton3");
                    break;
            }
            e.Handled = true;
        }

嗯,不错,都可以实现了一个处理程序,处理三个按钮的业务。但是这里有两个疑问,
一、为什么我在StackPanel 上定义的Click 事件会触发按钮呢?
二、StackPanel本身没有Click 事件啊,在StackPanel 上定义的 ButtonBase.Click又是什么?
带着疑问我们继续盘它。

3、路由策略

要解决第一个疑问就不得不说一说WPF中的路由策略,路由策略有三种:

  • 浮升(也称冒泡):调用事件源上的事件处理程序。 路由事件随后会路由到后续的父级元素,直到到达元素树的根。 大多数路由事件都使用浮升路由策略。 浮升路由事件通常用于报告来自不同控件或其他 UI 元素的输入或状态变化。
  • 隧道:最初将调用元素树的根处的事件处理程序。 随后,路由事件将朝着路由事件的源节点元素(即引发路由事件的元素)方向,沿路由线路传播到后续的子元素。合成控件的过程中通常会使用或处理隧道路由事件,通过这种方式,可以有意地禁止复合部件中的事件,或者将其替换为特定于整个控件的事件。 在 WPF中提供的输入事件通常是以隧道/浮升对实现的。 隧道事件有时又称作预览事件,这是由该对所使用的命名约定决定的。
  • 直接:只有源元素本身才有机会调用处理程序以进行响应。 这与 Windows 窗体用于事件的“路由”相似。 但是,与标准 CLR 事件不同的是,直接路由事件支持类处理,并且可供 EventSetter 和 EventTrigger 使用。

由以上三种策略也就对应了三种路由事件:冒泡路由事件,隧道路由事件,直接路由事件;

以上官方的解释虽然全面,但是看了也是让人云里雾里,我们上图,看图说话。
在这里插入图片描述

  • 如果将引发路由事件的元素称为源元素。
    注意不是在谁身上定义事件,谁就是源元素,而是事件由谁引发,例如:鼠标首先点击在按钮上,那么按钮就源元素,如果鼠标首先点击在stackPanel的区域,那么stackPanel 就是源元素(这个涉及Visual Tree 的内容,后面会讲解)
  • 源元素向上追溯到根元素的,这类由内到外则是冒泡路由事件。
  • 根元素向下追溯到源元素的,这类由外到内则是隧道路由事件。
  • 通常冒泡和隧道路由事件都是成对出现的,并且具有相同的签名(参数列表和返回值相同),例如KeyDown 事件和 PreviewKeyDown ,MouseDown 和PreviewMouseDown 等等,少数情况下,只有冒泡事件或者只有直接路由事件。
  • 隧道路由事件一般都是以Preview 开头的事件,对于部分无法区分路由策略的事件,我们可以查看官方资料中该事件的路由事件信息,会提供事件的路由策略,如查看 UIElement.KeyDown 事件
  • 直接路由事件,则是谁定义的事件就只作用于谁。

现在我们通过实例看一下:

首先我们分别给最外层的window ,第二层的border,第三层的stackPanel 分别定义MouseDown事件

this.MouseDown += (s, e) => { MessageBox.Show("window - MouseDown"); };
this.border.MouseDown += (s, e) => { MessageBox.Show("border - MouseDown"); };
this.stackPanel.MouseDown += (s, e) => { MessageBox.Show("stackPanel - MouseDown"); };

当我们点击StackPanel区域,会由内到外依次的触发MouseDown事件,效果如下:
在这里插入图片描述
这个足以形象的说明冒泡路由事件的执行原理。

现在我们将MouseDown 冒泡路由事件,改为PreviewMouseDown 隧道路由事件,

this.PreviewMouseDown += (s, e) => { MessageBox.Show("window - PreviewMouseDown"); };
this.border.PreviewMouseDown += (s, e) => { MessageBox.Show("border - PreviewMouseDown"); };
this.stackPanel.PreviewMouseDown += (s, e) => { MessageBox.Show("stackPanel - PreviewMouseDown"); };

当我们点击StackPanel区域,会由外到内依次的触发PreviewMouseDown 事件,效果如下:
在这里插入图片描述
回到最初的案例,我们在三个按钮的父元素StackPanel上定义了一个Click 事件,Click 是属于ButtonBase类中的一个事件并且路由策略为冒泡,因此当鼠标点击在按钮上时,会首先触发按钮的Click事件,但是按钮自身并没有定义处理程序,因此向上路由找到了stackPanel的click事件,再通过RoutedEventArgs 参数中 Source 找到事件源,由此实现点击按钮处理相关业务逻辑。

4、附加事件

上面我们解决了案例中的第一个问题,现在我们要解决第二个问题:StackPanel本身没有Click 事件啊,在StackPanel 上定义的 ButtonBase.Click又是什么?
ButtonBase.Click 就是附加事件。 ButtonBase“拥有”该事件,但是路由事件系统允许将任何路由事件的处理程序附加到任何 UIElement 或 ContentElement 实例侦听器

这里需要注意的是:虽然StackPanel 附加了一个Click 事件,但是并不等于StackPanel 就具有了Click事件,只是将这个事件附加给了StackPanel,让StackPanel可以监听到Button的Click事件。

如何理解上面黄色标注的话呢?
还是上案例:

stackPanel.AddHandler(ButtonBase.ClickEvent,new RoutedEventHandler((s,e) => { MessageBox.Show("StackPanel =>Button - Click"); }));

this.YesButton.Click += (s, e) => { MessageBox.Show("YesButton - Click");  };

分别在StackPanel 和内部的其中一个按钮上定义Click 事件,结果是:
点击YesButton 按钮会先触发 YesButton 自身的Click事件,后触发 StackPanel 附加的ButtonBase.Click事件。点击StackPanel 区域啥也不会发生。

XAML 语言定义了一个名为附加事件的特殊类型的事件。 使用附加事件,可以将特定事件的处理程序添加到任意元素中。处理事件的元素不必定义或继承附加事件,可能引发事件的对象和用来处理实例的目标也都不必将该事件定义为类成员,或将其作为类成员来“拥有”。

在WPF中有提供了非常多的附加事件,方便我们使用,使的我们可以摆脱了元素控件原有事件的限制,更灵活的开发。例如StackPanel没有Click 我们可以使用ButtonBase.Click 给它附加上一个Click事件。

常见的附加事件,如鼠标类的:Mouse.MouseDown、Mouse.MouseEnter 等,以及键盘操作类的:Keyboard.KeyDown,Keyboard.PreviewKeyUp等,特别是在写一些样式或模板的时候,我们就可以直接利用这些附加事件给任何指定的UIElement更方便灵活的添加事件。

5、e.Handle将事件标记为已处理

解决了疑问之后,还是接着上面第3小结 中的案例,现在呢,有个新的需求,希望在点击StackPanel的时候,不再往上路由,直接到此为止。那么就可以设置 e.Handle =true,表示该事件已经是已处理状态,那么事件就不会再向上路由。

            this.MouseDown += (s, e) => { MessageBox.Show("window - MouseDown"); };
            this.border.MouseDown += (s, e) => { MessageBox.Show("border - MouseDown"); };
            this.stackPanel.MouseDown += (s, e) => 
            { 
                MessageBox.Show("stackPanel - MouseDown"); 
                e.Handled = true; 
            };

效果如下:
在这里插入图片描述
可以看到,当我们点击了StackPanel 区域的时候,因为设置了 e.Handled = true; 因此只会处理stackPanel的事件,不会再向上路由,而点击border 区域的时候依次触发border 和window的MouseDown事件。

另外需要注意的是,如果对于同一元素,同时定义的冒泡和隧道事件,那么隧道事件会将冒泡事件覆盖,冒泡事件不会被触发。

5、路由事件存在的问题与解决方案

this.MouseDown += (s, e) => { MessageBox.Show("window - MouseDown"); };
this.border.MouseDown += (s, e) => { MessageBox.Show("border - MouseDown"); };
this.stackPanel.MouseDown += (s, e) => { MessageBox.Show("stackPanel - MouseDown"); };
this.YesButton.MouseDown += (s, e) => { MessageBox.Show("YesButton - MouseDown");  };

如上代码中由内到外的元素都加了一个鼠标按下的冒泡路由事件MouseDown ,想看下效果,但是按钮后发现并没有任何响应,YesButton的MouseDown事件没有被触发,这是为什么呢?

官方解释:某些控件类可能对鼠标按钮事件具有固有的类处理。 鼠标左键向下事件是控件中具有类处理的最可能事件。 类处理通常将基础 Mouse类事件标记为已处理。 标记事件后,附加到该元素的其他实例处理程序通常不会引发。 附加到 UI树中根的浮泡方向中的元素的任何其他类或实例处理程序也通常不会引发。

个人理解,仅供参考:
因为啊,你看我们上面有4层,Window包裹Border,Border包裹StackPanel,StackPanel包裹Button。假设Button可以触发MouseDown 事件,由于MouseDown 事件是冒泡路由事件,那么势必就造成点击按钮的时候,肯定会触发其他元素的事件,然而事实上日常开发中我们点击按钮也仅仅只是想触发按钮的事件而已,而按钮的父元素stackPanel说一定也有自己的鼠标点击事件需要独立处理。这个时候我们可以选择使用e.Handle来解决,但这并非是最佳的解决方案。因此就出现了 ButtonBase.Click 事件。(如果自己做自定义控件和用户控件的时候是可以体会到这一点的)

ButtonBase.Click 是鼠标对ButtonBase各类点击事件(如MouseDown,MouseUp,MouseLeftButtonDown等)的一个替代方案。所有对ButtonBase及其派生类的鼠标操作,最终都会被解析为Click 事件,并且将其他对按钮进行的鼠标事件标注为已处理。

这就解释了为什么,当在YesButton上定义MouseDown事件无效,因为这些事件被解析为了Click 事件,而Button上其他的事件又被设置为了已处理状态,所以无法进行路由传导。因此上面的代码的效果就是点击按钮后,无任何响应!

至此无法响应的问题分析结束。
那又有一个问题了?有人说,我就是轴或者需求就是要求那个Button 触发MouseDown事件怎么办呢?

对于这类情况,官方也给出了解决方案。
第一种:改用隧道路由事件,如之前使用MouseDown,现在改为PreviewMouseDown,因为PreviewMouseDown 未被Button标记为已处理状态,因此仍旧可以使用。

对于上述问题的第一种解决方案的实现如下:

this.YesButton.PreviewMouseDown += (s, e) => { MessageBox.Show("YesButton - PreviewMouseDown");  };

第二种:通过调用 AddHandler 并选择允许处理程序侦听事件的签名选项,以程序方式在控件上注册处理程序,即使这些处理程序已在路由事件数据中标记为已处理。

6、AddHandler

上面提到的第二种方法,其实就是强制的给元素添加路由事件,即使该路由事件已标注为已处理,在使用AddHandler添加后仍旧会起作用。

下面认识一下这个方法:

AddHandler 的作用就是为指定的路由事件添加路由事件处理程序,并将该处理程序添加到当前元素的处理程序集合中。

简单说就是给指定元素,添加指定的路由事件和相关处理程序

//RoutedEvent routedEvent要处理的路由事件的标识符。简单理解就是事件的类型名称,如ClickEvent,MouseDownEvent 等
//Delegate handler  对处理程序实现的引用。
// bool handledEventsToo  默认false
//如果为 true,即使路由事件在其事件数据中标记为已处理,也会调用处理程序;
//如果为 false,即当路由事件被标记为已处理时,将不调用处理程序。
public void AddHandler (System.Windows.RoutedEvent routedEvent, Delegate handler, bool handledEventsToo);

那么第二种解决方案的具体实现如下:

this.MouseDown += (s, e) => { MessageBox.Show("window - MouseDown"); };
this.border.MouseDown += (s, e) => { MessageBox.Show("border - MouseDown"); };
this.stackPanel.MouseDown += (s, e) => { MessageBox.Show("stackPanel - MouseDown"); };          
this.YesButton.AddHandler(Button.MouseDownEvent, new RoutedEventHandler((s, e) => 
{ 
      MessageBox.Show("YesButton - MouseDown");
      e.Handled = false;//想要向上路由就需要将Handled 改为false,因为Button的事件默认执行完就是已处理
}), true);

2.自定义事件

其实理解路由事件和附加事件可以和之前我们讲述的依赖属性和附加属性做下类比,将路由事件比作依赖属性,附加事件类比为附加属性,就知道其中的差异了。

而自定义的路由事件和附件事件,也是多用于自定义控件中。

1、附加事件和路由事件的区别

  • 附加事件的本质也是路由事件。
  • 路由事件的宿主是Button、Grid等这些我们可以在界面上看得见的控件元素对象。
  • 而附加事件的宿主是Binding类、Mouse类、KeyBoard类这种无法在界面显示的类对象。

2、自定义路由事件

创建自定义路由事件步骤如下:

  • 1、声明并注册路由事件。
  • 2、利用CLR事件包装路由事件(封装路由事件)。
  • 3、创建可以激发路由事件的方法。
1)使用默认参数的自定义路由事件

具体如何创建自定义路由事件,步骤如下所示:

    public class MyButton:Button
    {
        //【第一步】声明并注册路由事件
        public static readonly RoutedEvent TapEvent = EventManager.RegisterRoutedEvent(
         "Tap", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(MyButton));

        //【第二步】将路由事件包装成 CLR 事件
        public event RoutedEventHandler Tap
        {
            add { AddHandler(TapEvent, value); }
            remove { RemoveHandler(TapEvent, value); }
        }

        //【第三步】创建可以激发路由事件的方法
        protected override void OnClick()
        {
            //1 先重写 OnClick方法
            base.OnClick();
            //2 在重写的 OnClick方法中调用RaiseEvent方法激发事件
            RoutedEventArgs args = new RoutedEventArgs(MyButton.TapEvent, this);
            RaiseEvent(args);
            //3 最后实现的结果就是,当点击按钮的时候,就会触发我们自定义的路由事件

            //注意:对于继承自Button的自定义控件来说是通过重写OnClick方法实现,对于其他控件可能是OnMouseDown或者其他什么方法实现

        }
    }

在xaml 使用:

    <Grid>
        <local:MyButton x:Name="mybtnsimple" 
        Tap="TapHandler" 
        Click="mybtnsimple_Click" 
        Width="100" Height="50" Margin="15" Content="自定义路由事件"></local:MyButton>
    </Grid>

在xaml中分别定义了local:MyButton 的Click 事件和 自定义的 Tap 事件,我们会发现效果如下:
在这里插入图片描述
当点击按钮后,会先触发Click事件,然后触发Tap事件,从上面断点调试不难看出这是由OnClick中代码的执行顺序决定的。

2)使用自定义参数的自定义路由事件

创建自定义参数路由事件步骤如下:

  • 1、创建自定义参数对象
  • 2、声明并注册路由事件。
  • 3、利用CLR事件包装路由事件(封装路由事件)。
  • 4、创建可以激发路由事件的方法。

具体实操步骤如下:

  // 【第一步】定义一个自定义的事件参数类,需要需要继承自RoutedEventArgs
    public class SuperClickRoutedEventArgs : RoutedEventArgs
    {
        public SuperClickRoutedEventArgs(RoutedEvent routedEvent, object source)
            : base(routedEvent,source)
        {

        }
        public DateTime ClickTime { get; set; }

        public string Flag { get; } = "测试,我是事件中传递的只读参数";
    }

    public class MyAdvancedButton : Button
    {
        // 【第二步】声明和注册路由事件
        public static readonly RoutedEvent SuperClickEvent = EventManager.RegisterRoutedEvent("SuperClick",
            RoutingStrategy.Direct, typeof(EventHandler<SuperClickRoutedEventArgs>), typeof(MyAdvancedButton));

        // 【第三步】将路由事件包装成 CLR 事件
        public event EventHandler<SuperClickRoutedEventArgs> SuperClick
        {
            add { this.AddHandler(SuperClickEvent, value); }
            remove { this.RemoveHandler(SuperClickEvent, value); }
        }

        // 【第四步】创建可以激发路由事件的方法,借用 Click 事件激发
        protected override void OnClick()
        {
            base.OnClick();
            var args = new SuperClickRoutedEventArgs(SuperClickEvent, this) 
            { 
                ClickTime = DateTime.Now,  
            };
            this.RaiseEvent(args);
        }
    }

在xaml 中使用:

<local:MyAdvancedButton x:Name="myAdvanceBtn" 
		SuperClick="myAdvanceBtn_SuperClick" 
		Width="100" Height="50" Margin="15" Content="自定义路由事件"></local:MyAdvancedButton>

private void myAdvanceBtn_SuperClick(object sender, SuperClickRoutedEventArgs e)
{
     MessageBox.Show($"按钮点击时间:{e.ClickTime.ToShortTimeString()}|||传递的只读参数:{e.Flag}");
}

这里需要注意的是:因为个人在写本文的时候也参考的很多文章,对于第二步包装事件,我使用的是
public event EventHandler<SuperClickRoutedEventArgs> SuperClick ,因为我们的事件是一个需要传递自定义参数的事件,因此这个事件类型需要如此定义。
而大多数文章都是public event EventHandler SuperClick 这样写的话有一个问题,就是自动生成的事件处理程序,参数类型是事件参数的基类EventArgs 而非我们自定义的SuperClickRoutedEventArgs

3、自定义附加事件

了解自定义附加事件之前,需要先搞清楚两种情况的附加:

  • 第一种定义在可视化元素内的路由事件,我们通过AddHandler或者在xaml使用类似ButtonBase.Click 附加给其他元素。
  • 第二种定义在非可视化元素内的附加事件,我们通过AddHandler或者在xaml 使用类似Mouse.MouseDown 附加给其他元素。
  • 第一种称之为 非附加路由事件的附加,第二种称之为附加路由事件的附加。

创建自定义参数路由事件步骤如下:

  • 1、声明并注册路由事件
  • 2、定义Add xxx Hanlder 和 Remove xxx Handler 两个封装方法

具体实操步骤如下:

    public class CustomAttachedEvent
    {
        //【第一步】声明并注册路由事件
        public static readonly RoutedEvent SubmitEvent = EventManager.RegisterRoutedEvent("Submit", 
            RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(CustomAttachedEvent));

        //【第二部】定义Add xxx Hanlder 和 Remove xxx Handler 两个封装方法
        //方法的作用是通过AddHandler为 被附加事件的元素 添加/移除路由事件以及相关处理程序。
        public static void AddSubmitHandler(DependencyObject d, RoutedEventHandler handler)
        {
            UIElement uie = d as UIElement;
            if (uie != null)
            {
                uie.AddHandler(CustomAttachedEvent.SubmitEvent, handler);
            }
        }
        public static void RemoveSubmitHandler(DependencyObject d, RoutedEventHandler handler)
        {
            UIElement uie = d as UIElement;
            if (uie != null)
            {
                uie.RemoveHandler(CustomAttachedEvent.SubmitEvent, handler);
            }
        }
    }

如何使用呢?由于自定义附加事件的类不是派生自UIElement,因此就没有办法在定义附加事件的时候像自定义路由事件一样可以在定义的类中使用RaiseEvent 激发事件。想要激发事件,就需要在类外部通过元素使用RaiseEvent 去激发事件。

假设一种场景,场景不一定很恰当,但是可以帮助我们理解自定义附加事件。我有三个按钮,同意,拒绝,取消,我无论点击任何按钮,我都希望能够触发一个提交操作记录到服务器的事件。

具体代码如下:

        <Button x:Name="YesButton" Width="100" Height="50" Margin="5">Yes</Button>
        <Button x:Name="NoButton"  Width="100" Height="50" Margin="5" >No</Button>
        <Button x:Name="CancelButton"  Width="100" Height="50" Margin="5">Cancel</Button>
        public Window1()
        {
            InitializeComponent();

            //【一】将事件附加到当前窗体上,并且给事件一个处理程序myBtn_Submit
            CustomAttachedEvent.AddSubmitHandler(this, new RoutedEventHandler(myBtn_Submit));
            //【三】实例化路由事件参数对象,将附加事件标识符传入
            var args = new RoutedEventArgs(CustomAttachedEvent.SubmitEvent);

            //【四】在各个按钮上调用RaiseEvent 激发事件
            this.YesButton.Click += (s, e) =>
            { 
                MessageBox.Show("YesButton - Click");
                this.YesButton.RaiseEvent(args);
            };
            this.NoButton.Click += (s, e) => 
            { 
                MessageBox.Show("NoButton - Click");
                this.NoButton.RaiseEvent(args);
            };
            this.CancelButton.Click += (s, e) => 
            { 
                MessageBox.Show("CancelButton - Click");
                this.CancelButton.RaiseEvent(args);
            };

        }

        //【二】定义处理程序
        private void myBtn_Submit(object sender, RoutedEventArgs e)
        {
            MessageBox.Show($"自定义附加事件触发了 ---提交{(e.Source as Button).Name}的操作记录");
        }

效果如下:
在这里插入图片描述
上面案例是通过CustomAttachedEvent.AddSubmitHandler 在cs文件中去附加事件,当然也可以直接在xaml 中使用xxx.CustomAttachedEvent.Sumbit 附加事件。

另外如果自定义附加事件需要事件参数也是自定义的,可以参考上面自定义参数的自定义路由事件去操作。

4、RegisterRoutedEvent解析

在注册路由事件和附加事件,实际上使用的都是RegisterRoutedEvent进行注册,这也说明附加事件本质是也是一种路由事件。

public static System.Windows.RoutedEvent RegisterRoutedEvent
 (string name, System.Windows.RoutingStrategy routingStrategy, Type handlerType, Type ownerType);

参数
name
路由事件的名称。 该名称在所有者类型中必须是唯一的,并且不能为 null 或空字符串。

routingStrategy
作为枚举值的事件的路由策略。有Bubble(冒泡),Direct(直接),Tunnel(隧道)三种

handlerType
事件处理程序的类型。 该类型必须为委托类型,并且不能为 null。

ownerType
路由事件的所有者类类型。 该类型不能为 null。

3.其他

1、Logical Tree 与 Visual Tree

​ WPF 中有两种“树”:逻辑树(Logical Tree)和可视化元素树(Visual Tree)。
在 WPF 的 Logical Tree 上,最显著的特点就是它完全由布局组件和控件组成,充当“树叶”的一般都是控件,如果我们仔细观察控件,会发现 WPF 控件本身也是一颗由更细微级别的组件(它们不是控件,而是一些可视化组件,派生自 Visual 类)组成的“树”。即当我们把 Logical Tree 延伸至控件的模板(Template)组件级别时,我们得到的就是 Visual Tree。

详情可参考:WPF 中的逻辑树(Logical Tree)与可视化元素树(Visual Tree)

2、Source与OriginalSource 的区别

路由事件是沿着VisualTree传递。VisualTree与LogicalTree区别在于:LogicalTree的叶子是构成用户界面的控件,而VisualTree连控件中的细微结构也算上。

“路由事件在VisualTree上传递”本意上是说“路由事件的消息在VisualTree上传递”,而路由事件的消息包含在RoutedEventArgs实例中。RoutedEventArgs有两个属性Source和OriginalSource,这两个属性都表示路由事件传递的起点,那么他们之间有什么区别呢?
Source表示的是LogicalTree上消息的源头;
OriginalSource则表示VisualTree上的源头。

就比如说一个UserControl内包裹了一个Button,然后在Window中引用该UserControl ,那么点击按钮的时候,如果使用e.Source 找到的就是UserControl ,如果使用e.OriginalSource找到的则是Button。

详情可参考:RoutedEventArgs的Source与OriginalSource

二、命令

命令是WPF输入系统中的一部分,目前WPF基础系列主要目的是先掌握整体知识体系,这里将重点介绍一下命令,对输入系统感兴趣的可以先查看 官方资料-WPF输入

1.定义

命令是WPF中的一种输入机制,与设备输入相比,它提供的输入处理更侧重于语义级别。示例命令如许多应用程序均具有的“复制”、“剪切”和“粘贴”操作。

个人理解,供参考:与事件相比,命令更像是对事件的一个抽象,一个事件往往对应的是一个具体的输入操作(如鼠标点击,键盘输入)然后执行对应的业务逻辑,而命令更侧重于实现一段业务逻辑,供给不同的事件去使用,比如一个复制的命令,可能鼠标点击按钮需要使用,键盘按下Enter键,同样需要使用,那么就可以调用同一个命令。事件在日后MVVM的开发中,占用的比例会很少,因为耦合度较高;而命令由于其可以分割调用对象和实际业务的特性,从而实现业务与前端页面的解耦,则在MVVM的开发中占比较大。

2.用途

第一个用途: 是分隔语义和从执行命令的逻辑调用命令的对象。

这可使多个不同的源调用同一命令逻辑,并且可针对不同目标自定义命令逻辑。
例如,许多应用程序中均有的编辑操作“复制”、“剪切”和“粘贴”若通过使用命令来实现,那么可通过使用不同的用户操作来调用它们。应用程序可允许用户通过单击按钮、选择菜单中的项或使用组合键(例如 Ctrl+X)来剪切所选对象或文本。通过使用命令,可将每种类型的用户操作绑定到相同逻辑。

另一用途: 是指示操作是否可用。

继续以剪切对象或文本为例,此操作只有在选择了内容时才会发生作用。 如果用户在未选择任何内容的情况下尝试剪切对象或文本,则不会发生任何操作。为了向用户指示这一点,许多应用程序通过禁用按钮和菜单项来告知用户是否可以执行某操作。 命令可以通过实现 CanExecute方法来指示操作是否可行。 按钮可以订阅 CanExecuteChanged 事件,如果 CanExecute 返回 false 则禁用,如果CanExecute 返回 true 则启用。

在WPF 中有路由命令,还有已经定义好命令库供我们使用,不过平常我们使用最多的当属自定义命令,本次我们先从自定义命令入手介绍。

3.自定义命令

1、从案例开始

目前我们点击按钮,实现一段业务是这样的:

       <Button Height="50" Width="200" Content="按钮" Click="Button_Click"></Button>
        private void Button_Click(object sender, RoutedEventArgs e)
        {
            DoSomething();//处理业务
        }

        private void DoSomething()
        {
            Debug.WriteLine("业务代码。。。。。");
            MessageBox.Show("业务处理完了 !");
        }

现在,布置个新任务,我们为了后续使用MVVM模式去开发项目,需要通过命令实现以上的业务。
怎么使用命令怎么实现呢?首先得知道命令是个啥,F12进入Command看一下,是 public ICommand Command { get; set; },原来Command 是一个类型为ICommand的属性。那ICommand 是啥,我们再进入ICommand 看一下

    public interface ICommand
    {
        event EventHandler? CanExecuteChanged;
        
        bool CanExecute(object? parameter);
        
        void Execute(object? parameter);
     }

ICommand 是一个接口(也是WPF命令核心接口),接口中有1个事件,2个方法需要实现。

当鼠标点击按钮的时候,首先调用CanExecute,返回一个bool类型的结果,如果为true将执行Execute 方法,如果为false ,就不会执行Execute 方法。两个方法均可传参,如果命令中不需要传参可设置为null。

那我们知道了,Command 必须绑定一个实现了ICommand接口的类型对象才可以。因此需要先实现ICommand 接口。
实现如下:


    public class MyCommand1 : ICommand
    {
        public event EventHandler? CanExecuteChanged;

        public bool CanExecute(object? parameter)
        {
            return true;
        }

        public void Execute(object? parameter)
        {
            DoSomething();
        }

        private void DoSomething()
        {
            Debug.WriteLine("业务代码。。。。。");
            MessageBox.Show("业务处理完了 !");
        }
    }

我们需要在xaml中绑定命令,那么得在ViewModel中定义一个ICommand命令,

    public class ViewModel4
    {
        public ICommand TestCommand { get; set; }
        public ViewModel4() 
        {
            TestCommand = new MyCommand1();
        }
    }

指定 DataContext

        public Window4()
        {
            InitializeComponent();
            this.DataContext = new ViewModel4();
        }

在xaml 绑定

<Button Height="50" Width="200" Content="按钮1" Command="{Binding TestCommand}"></Button>

通过以上代码,完成了以前使用Click 完成的业务,初步实现了我们的目标。

2、实现ICommand的通用类

上面案例中,实现了我们初步的需求,至少能与运行了。但是呢,如果每次一个命令都要像上面那样实现一遍,代码根本得不到重用,因此我们需要将实现的类,修改一下保证可以重用。

如何通用呢,每次执行的业务都不尽相同,相同的就是实现接口这部分的代码,那么此时就需要用到委托了,委托就是干这个的。我们可以通过实例化ICommand实现类的时候将需要执行的业务方法,以参数的形式传入不就可以实现重用了,开干 !

Command通用类修改如下:

 public class DelegateCommand : ICommand
    {
        public DelegateCommand(Action<object?> action, Func<object?, bool> func = null)
        {
            this.ExecuteAction = action;
            this.CanExecuteFunc = func;
        }

        public DelegateCommand(Action action, Func<bool> func = null)
        {
            this.ExecuteActionNoPara = action;
            this.CanExecuteFuncNoPara = func;
        }

        public event EventHandler? CanExecuteChanged;

        public bool CanExecute(object? parameter = null)
        {
            if (parameter != null)
            {
                return CanExecuteFunc == null ? true : CanExecuteFunc.Invoke(parameter);
            }
            return CanExecuteFuncNoPara == null ? true : CanExecuteFuncNoPara.Invoke();
        }

        public void Execute(object? parameter = null)
        {
            if (parameter != null)
            {
                ExecuteAction?.Invoke(parameter);
            }
            else
            {
                ExecuteActionNoPara?.Invoke();
            }
        }

        private Action<object?> ExecuteAction { get; set; }
        private Func<object?, bool> CanExecuteFunc { get; set; }

        private Action ExecuteActionNoPara { get; set; }
        private Func<bool> CanExecuteFuncNoPara { get; set; }
    }

上面的类封装了有参数的委托和无参数的委托,默认CanExcute就是true,那么后续我们在使用的时候,只管实例化DelegateCommand,然后传入需要执行的方法的方法名称即可。

如下所示:

    public class ViewModel4
    {
        public ICommand TestCommand { get; set; }
        public ViewModel4() 
        {
            TestCommand = new DelegateCommand (DoSomething);
        }

        private void DoSomething()
        {
            Debug.WriteLine("业务代码。。。。。");
            MessageBox.Show("业务处理完了 !");
        }
    }

通过这样,后续我们需要执行什么样的业务,只需将对应的处理的方法传入DelegateCommand就可以了。而不需要每次重复实现ICommand 接口。

4.路由命令

本文不做介绍,需要了解的可以查看:官方资料-命令

5.命令快捷键

比如我有一个自定义的命令 ShowCommand ,需要我按下Space键 或者 鼠标右键点击的时候执行,那么可以这样做:

<Window.InputBindings>
     <KeyBinding Command="{Binding ShowCommand}"  Key="Space" Modifiers=""/>
     <MouseBinding Command="{Binding ShowCommand}" MouseAction="RightClick"></MouseBinding>
</Window.InputBindings>

InputBindings 是一个输入绑定的集合,可以放KeyBinding和MouseBinding ,因此可以将需要的快捷键操作和鼠标操作,加入其中,如上案例。

上面的案例是针对Window设置InputBindings ,当然我们也可以针对我们需要Button 或者 UserControl 设置自己的InputBindings。

三、事件转化为命令

在MVVM模式我们基本都会使用Command,然而实际上,能使用Command的对象是需要实现ICommandSource接口的,在WPF中实现ICommandSource接口的对象是 ButtonBase、MenuItem、Hyperlink 和 InputBinding。如果我们想在这些以外元素使用Command,就需要将一些元素上的事件转化成命令,那么如何转化呢?

转化步骤如下:

1 首先添加NuGet包,安装下图红框内的NuGet包

在这里插入图片描述

2 在XAML页面中引用xmlns:i="http://schemas.microsoft.com/xaml/behaviors"

在这里插入图片描述

3 具体使用

<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Background="Red">
    <i:Interaction.Triggers>
         <i:EventTrigger EventName="MouseDown">
             <i:InvokeCommandAction Command="{Binding MouseDownCommand}"></i:InvokeCommandAction>
         </i:EventTrigger>
    </i:Interaction.Triggers>
</StackPanel>

这里需要注意:对于给StackPanel ,Grid这类布局控件使用事件转命令的时候,需要设置背景色才可以,即使透明度为0.1或者直接设置为Transparent也是可以的。但是就是不可以不设置Background,否则无法触发事件。

如果需要在以上命令中传递参数,直接加上CommandParameter 即可。
另外如果需要将触发事件的参数传入,可以使用PassEventArgsToCommand="True",如下所示:

<Grid VerticalAlignment="Center" HorizontalAlignment="Center" Background="Red">
    <i:Interaction.Triggers>
         <i:EventTrigger EventName="MouseDown">
             <i:InvokeCommandAction Command="{Binding MouseDownCommand}" PassEventArgsToCommand="True"></i:InvokeCommandAction>
         </i:EventTrigger>
    </i:Interaction.Triggers>
</Grid>

使用如下:

 		public ICommand MouseDownCommand { get; set; }
 
        public MainViewModel()
        {
            //初始化
            MouseDownCommand = new DelegateCommand(ShowResult) ;
        }

		//获取鼠标位置,并展示结果
        private void ShowResult(object obj)
        {
            if (obj is MouseButtonEventArgs e)
            {
                var grid = (Grid)e.OriginalSource;

                var ps = e.GetPosition(grid);
                MessageBox.Show($"当前鼠标坐标:{ps.X}:{ps.Y}");
            }
        }

上面通过使用PassEventArgsToCommand="True",将事件参数传递到方法中,使用object接收,并且通过事件参数的OriginalSource获取事件源控件,然后可以通过控件做一些其他操作,个人并不建议这样操作,因为这样会导致耦合度增加,除非一些特殊情况非得使用不可再用。


总结

以上就是今天的内容,经过了这五章的介绍,对WPF有了基本的认识,结合INotifyPropertyChanged 和ICommand 的内容,我们已经可以手搭一个简易的MVVM模式下的项目框架了!下一章将会实例搭建一个简易的MVVM项目框架。希望以上内容可以帮助到你,如文中有不对之处,还请批评指正。


官方文档 - WPF
官方文档 - 事件
官方文档 - 输入
C#WPF XAML事件、资源、样式
WPF 之路由事件和附加事件(六)
WPF 中的逻辑树(Logical Tree)与可视化元素树(Visual Tree)
RoutedEventArgs的Source与OriginalSource

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐