65.9K
CodeProject 正在变化。 阅读更多。
Home

行为模式 - WPF 和 Avalonia 中的可视化行为(附实际示例)

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (9投票s)

2021年12月24日

MIT

12分钟阅读

viewsIcon

14670

在这里,我将描述行为——一种允许在不修改对象代码的情况下,非侵入性地修改和增强对象行为的功能。

行为模式简介

请注意,本文和示例代码均已更新,以兼容最新版本的Avalonia - 11.0.6

什么是行为模式?

有一种非常有趣的模式叫做“行为”,主要用于可视化编程(WPF 和 Avalonia),但它也可以很好地用于完全非可视化的代码。

据我所知,历史上,“行为”一词是由 MS Blend SDK 引入的,当时他们使用行为来实现自定义对象,这些对象在附加到视觉类后会触发该类的行为发生变化。

总的来说,行为就是附加到对象上,以非侵入性的方式修改或增强对象行为的东西——而无需修改对象本身的类。

C# 中的行为通常通过响应对象的事件来实现(在 WPF 和 Avalonia 中,它们也可以响应依赖项属性或附加属性的变化)。一些更简单的行为只会改变对象在附加或分离时的状态——这类行为甚至不需要对象具有任何事件。

在 WPF 和 Avalonia 中,视觉行为最常用于在 XAML 视觉树或逻辑树中发生事件时产生某种视觉变化。

行为与 MVVM

当采用 MVVM(Model-View-View Model)模式时,行为尤其有用,因为该模式的正确使用意味着通过绑定到某个非可视化视图模型的 DataTemplate 来定义视图(无需代码隐藏)。一方面,不使用代码隐藏很重要,因为代码隐藏经常用于将视觉和非视觉关注点进行不良混合,并使 XAML 表示与 C# 代码紧密耦合。另一方面,您仍然可能需要使用 C# 来处理涉及两个或多个属性的复杂可视化对象修改。避免代码隐藏的最佳方法是通过行为。

请注意,可视化对象之间的某些通信可以通过视图模型本身完成,例如,`ToggleButton` 的 `IsChecked` 属性可以与视图模型中定义的属性进行双向绑定,该属性的变化将触发其他变化,从而反映在其他可视化属性上。这是可以的,并且完全合法,但有时,通过视图模型进行此类通信是不希望的或不可能的。例如

  1. 如果更改是纯粹的视觉且局部的(不影响更改发生位置周围一小片逻辑区域的任何内容,特别是如果它不涉及任何业务逻辑),则最好不要污染您的视图模型,避免添加不必要的额外代码。
  2. 如果我们想基于某个路由事件(而非 `Button.Click` 或 `MenuItem.Click` 事件——这些我们可以使用命令)来更改一些视觉效果,那么我们就必须使用行为或代码隐藏。

先决条件和其他方法

为了最大限度地从本文中受益,您需要了解一些基本的 WPF/Avalonia 概念,包括 XAML、视觉树和逻辑树、路由事件传播、附加属性和绑定。

对于初次接触 Avalonia 的用户来说——它是一个开源 UI 开发框架,与 WPF 非常相似但更强大,而且非常重要——它还是 **跨平台** 的——使用 Avalonia 构建的 UI 桌面应用程序将在 Windows、Linux 和 Mac 计算机上运行。Avalonia 还即将发布一个通过 WASM 技术在浏览器中运行的版本。

如果您是 Avalonia 或 WPF 的初学者,可以从以下文章开始:

  1. 使用 AvaloniaUI 在简易示例中进行多平台 UI 编码。第一部分 - AvaloniaUI 构建块
  2. 多平台 Avalonia .NET 框架 XAML 基础知识轻松示例
  3. 使用简单的示例学习 Avalonia .NET 框架跨平台编程基础概念
  4. Avalonia .NET 框架编程高级概念和简易示例

关于行为,一个有趣的问题是如何保持行为对象附加到它所修改或增强的对象上。从下面的示例中可以看到,一些行为可以实现为静态类,并在该行为内的某个附加属性在该对象上获得特定值时“附加”到视觉对象上。这是我创建行为的首选方式。

其他行为,例如 Avalonia Behaviors 中的行为,是非静态对象,它们使用一些特殊的附加属性来附加行为。这些行为是 UWP 行为的 Avalonia 重写,而 UWP 行为又受到了原始 MS Blend 行为的启发。

要了解如何安装 Avalonia Visual Studio 扩展以及如何创建 Avalonia 项目,请参阅 使用 Visual Studio 2019 创建和运行简单的 Avalonia 项目

关于本文内容

不幸的是,行为不是非常简单的软件对象,在我看来,要理解行为,您需要了解它们是如何工作的。因此,本文描述了创建几个简单的自定义行为。

作为后续,我计划发表另一篇文章,解释来自 NP.Avalonia.Visuals 包中的一些非常有用的行为。

阅读本文并运行示例至关重要。如果您尝试创建类似的自定义行为示例项目,效果会更好。

我将从一个小的 WPF 行为示例开始,然后专注于 Avalonia,它是 WPF 的一个更好、更大、跨平台的版本。

示例

代码位置

所有代码,包括一个 WPF 示例,都位于 NP.Avalonia.Demos/CustomBehaviors 下。

我使用 .NET 5.0 和 VS2019 编写了这些代码,尽管应该可以轻松地向上或向下移植。

WPF 行为:在视觉元素上发生路由事件时调用方法。

该示例的代码可以在 NP.Demos.WPFCallActionBehaviorSample 下找到。

在您的 Visual Studio 中打开、编译并运行解决方案。这是示例的 MainWindow。

如果您将鼠标悬停在左侧的黄色方块上,窗口背景会变成红色。如果您将鼠标悬停在右侧的粉色方块上,会弹出一个小的对话框窗口,其中包含文本“我是一个快乐的对话框!”。

现在,让我们看看这个功能的实现。

将窗口背景设置为红色和打开弹出窗口的方法定义在 `MainWindow.xaml.cs` 文件中,它们分别是 `void MakeWindowBackgroundRed()` 和 `void OpenDialog()` 方法。

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
    }

    // Turns window background red
    public void MakeWindowBackgroundRed()
    {
        Background = new SolidColorBrush(Colors.Red);
    }

    // opens a dialog
    public void OpenDialog()
    {
        Window dialogWindow =
            new Window()
            {
                Left = this.Left + 50,
                Top = this.Top + 50,
                Owner = this, 
                Width = 200, 
                Height = 200 
            };

        dialogWindow.Content = new TextBlock
        {
            HorizontalAlignment = HorizontalAlignment.Center,
            VerticalAlignment = VerticalAlignment.Center,
            Text = "I am a happy dialog!"
        };

        dialogWindow.ShowDialog();
    }
}  

`MakeWindowBackgroundRed()` 方法在黄色方块上发生 `MouseEnter` 路由事件时被调用,而 `OpenDialog()` 方法在粉色方块上发生相同的事件时被调用。

现在看 XAML 文件 - `MainWindow.xaml`。

<Window ...
        xmlns:local="clr-namespace:NP.Demos.WPFCallActionBehaviorSample"
        ...
        Width="400"
        Height="300">
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition />
            <ColumnDefinition />
        </Grid.ColumnDefinitions>
        <Border Background="Yellow"
                HorizontalAlignment="Center"
                VerticalAlignment="Center" 
                local:CallActionOnEventBehavior.TheEvent="{x:Static UIElement.MouseEnterEvent}"
                local:CallActionOnEventBehavior.TargetObject="{Binding RelativeSource={RelativeSource AncestorType=Window}}"
                local:CallActionOnEventBehavior.MethodToCall="MakeWindowBackgroundRed"
                Width="50"
                Height="50"/>

        <Border Background="Pink"
                Grid.Column="1"
                HorizontalAlignment="Center"
                VerticalAlignment="Center"
                local:CallActionOnEventBehavior.TheEvent="{x:Static UIElement.MouseEnterEvent}"
                local:CallActionOnEventBehavior.TargetObject="{Binding RelativeSource={RelativeSource AncestorType=Window}}"
                local:CallActionOnEventBehavior.MethodToCall="OpenDialog"
                Width="50"
                Height="50" />
    </Grid>
</Window>  

请注意,我们定义了 `xmlns:local` XML 命名空间以指向我们项目的命名空间 - `NP.Demos.WPFCallActionBehaviorSample`。

两个方块——黄色和粉色——被定义为两个 50x50 的边框。下面是如何让黄色方块上发生的 `MouseEnter` 路由事件调用 `MainWindow` 对象上的 `MakeWindowBackgroundRed()` 方法。

local:CallActionOnEventBehavior.TheEvent="{x:Static UIElement.MouseEnterEvent}"
local:CallActionOnEventBehavior.TargetObject="{Binding RelativeSource={RelativeSource AncestorType=local:MainWindow}}"
local:CallActionOnEventBehavior.MethodToCall="MakeWindowBackgroundRed"  

上面三行中设置的所有 3 个属性都是项目本地的静态类 `CallActionOnEventBehavior` 中定义的静态附加属性。这个类稍后会进行解释。

现在让我们看一下上面三行中设置的属性。

  1. ``local:CallActionOnEventBehavior.TheEvent="{x:Static UIElement.MouseEnterEvent}"`` 将我们边框对象上的行为附加属性设置为 `UIElement` 类中定义的静态 `UIElement.MouseEnterEvent` 路由事件 `MouseEnterEvent`。(路由事件,就像附加属性一样,都有一个定义它们的静态字段)。
  2. ``local:CallActionOnEventBehavior.TargetObject="{Binding RelativeSource={RelativeSource AncestorType=local:MainWindow}}"`` - 我们将我们边框对象上的附加 `CallActionOnEventBehavior.TargetObject` 属性绑定到视觉树上方的 `MainWindow`。请注意,由于我们使用的是附加属性,因此它们可以作为绑定的目标。
  3. ``local:CallActionOnEventBehavior.MethodToCall="MakeWindowBackgroundRed"`` 最后,我们将方法名称设置为 "MakeWindowBackgroundRed" 方法。

粉色方块以类似的方式定义了它的行为,只是方法名称是 "OpenDialog"。

让我们将注意力转移到实现该行为的静态类 `CallActionOnEventBehavior`。它定义了 3 个附加属性(我们在 XAML 文件中设置的属性相同)。

  1. ``TheEvent`` 类型为 `RoutedEvent` - 指定了视觉对象应该为其调用方法的路由事件。
  2. ``TargetObject`` 类型为 `object` - 指定了调用方法的对象。
  3. ``MethodToCall`` 类型为 `string` - 指定了要调用的方法的名称。

``TheEvent`` 附加属性定义了一个回调 `OnEventChanged`,以便在属性更改时触发。

 public static readonly DependencyProperty TheEventProperty =
    DependencyProperty.RegisterAttached
    (
        "TheEvent",
        typeof(RoutedEvent),
        typeof(CallActionOnEventBehavior),
        new PropertyMetadata(default(RoutedEvent), OnEventChanged /* callback */)
    );  

在该回调中,我们将视觉对象上的新事件连接到处理程序 `HandleRoutedEvent(...)`(如果旧路由事件非空,则也断开与同一处理程序的连接)。

private static void OnEventChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    // we can only set the behavior on FrameworkElement - almost any visual element
    FrameworkElement el = (FrameworkElement)d;

    RoutedEvent oldRoutedEvent = e.OldValue as RoutedEvent;

    if (oldRoutedEvent != null)
    {
        // remove old event handler from the object (if exists)
        el.RemoveHandler(oldRoutedEvent, (RoutedEventHandler)HandleRoutedEvent);
    }

    RoutedEvent newRoutedEvent = e.NewValue as RoutedEvent;

    if (newRoutedEvent != null)
    {
        // add new event handler to the object
        el.AddHandler(newRoutedEvent, (RoutedEventHandler) HandleRoutedEvent);
    }
}
#endregion TheEvent attached Property  

``void HandleRoutedEvent(...)`` 方法的实现获取 `TargetObject` 和 `MethodToCall` 的值,并使用反射调用 `TargetObject` 上的 `MethodToCall` 方法。

// handle the routed event when happens on the object
// by calling the method of name 'methodName' onf the
// TargetObject
private static void HandleRoutedEvent(object sender, RoutedEventArgs e)
{
    FrameworkElement el = (FrameworkElement)sender;

    // if TargetObject is not set, use DataContext as the target object
    object targetObject = GetTargetObject(el) ?? el.DataContext;

    string methodName = GetMethodToCall(el);

    // do not do anything
    if (targetObject == null || methodName == null)
    {
        return;
    }

    MethodInfo methodInfo = 
        targetObject.GetType().GetMethod(methodName);

    if (methodInfo == null)
    {
        return;
    }

    // call the method using reflection
    methodInfo.Invoke(targetObject, null);
}  

当然,这个简单的行为中有很多东西没有实现,例如,假设 `TargetObject` 上只有一个名为“MethodToCall”的方法,并且该方法没有参数。实际的 `CallAction` 行为来自 NP.Avalonia.Visuals 开源项目,它更加完善和强大。然而,我们的 `CallActionOnEventBehavior` 对于理解静态行为的工作原理来说已经足够了。

Avalonia 行为:在视觉元素上发生路由事件时调用方法。

NP.Demos.CallActionBehaviorSample 包含一个与上述项目非常相似的项目,它使用 Avalonia 而不是 WPF。

运行项目,示例应用程序应该完全相同。

与 WPF 项目的区别(除了某些 Avalonia 类型名称与相应的 WPF 类型不同外)非常小。

主要区别在于我们如何为 `TheEvent` 附加属性更改设置回调。在 WPF 中,我们将回调作为传递给附加属性定义的元数据的一个参数:`new PropertyMetadata(default(RoutedEvent), OnEventChanged /* callback */)`。

在 Avalonia 中,我们在 `CallActionOnEventBehavior` 类的静态构造函数中使用 Reactive Extensions (Rx) 来订阅属性的变化。

public class CallActionOnEventBehavior
{
   ...
   static CallActionOnEventBehavior()
   {
      TheEventProperty.Changed.Subscribe(OnEventChanged);
   }
   ...
}

在视觉元素上使用行为的两个实例

静态行为的一个问题是您不能在同一视觉元素上使用它们的多个实例。例如,使用 `CallActionOnEventBehavior`,我们可以在 `PointerEnter` 事件上调用 `MakeWindowBackgroundRed()` 方法,但不能在不同事件上调用不同方法,例如在 `PointerLeave` 事件上调用 `RestoreBackground()` 方法。

总的来说,根据我的经验,这种需要将相同类型的行为附加到同一元素的需求非常罕见,当需要时,有一个技巧可以实现这一点。

通过 NP.Demos.DoubleCallActionBehaviorSample 行为可以模拟在同一元素上两次调用 `CallActionOnEventBehavior` 的示例。

与前一个示例相比,更改仅限于 `MainWindow.axaml.cs` 和 `MainWindow.axaml` 文件。

`MainWindow` 类现在有一个额外的方法 `RestoreBackground()`,它将窗口背景恢复到变为红色之前的状态。

private IBrush? _oldBackground = null;
// Turns window background red
public void MakeWindowBackgroundRed()
{
    _oldBackground = Background;
    Background = new SolidColorBrush(Colors.Red);
}

public void RestoreBackground()
{
    Background = _oldBackground;
}  

在 `MainWindow.axaml` 文件中,使窗口变红的黄色 `Border` 现在是透明 `Grid` 面板的子项。 `Grid` 面板仅用于为第二个 `PointerLeave` 事件(从 `Border` 冒泡到 `Grid`)调用行为。

<Grid local:CallActionOnEventBehavior.TheEvent="{x:Static InputElement.PointerLeaveEvent}"
      local:CallActionOnEventBehavior.TargetObject="{Binding RelativeSource={RelativeSource AncestorType=local:MainWindow}}"
      local:CallActionOnEventBehavior.MethodToCall="RestoreBackground"					
      HorizontalAlignment="Center"
      VerticalAlignment="Center"
      Width="50"
      Height="50">
    <Border Background="Yellow"
            local:CallActionOnEventBehavior.TheEvent="{x:Static InputElement.PointerEnterEvent}"
            local:CallActionOnEventBehavior.TargetObject="{Binding RelativeSource={RelativeSource AncestorType=local:MainWindow}}"
            local:CallActionOnEventBehavior.MethodToCall="MakeWindowBackgroundRed"				
            HorizontalAlignment="Stretch"
            VerticalAlignment="Stretch"/>
</Grid>  

最终的示例行为正是我们想要的——鼠标进入黄色方块时窗口变为红色,然后鼠标离开时恢复为白色。

拖拽行为示例

前三个示例都围绕着同一个行为构建,该行为处理一些路由事件并调用一个方法。在这个示例中,我们将演示一个更复杂的行为,它允许在窗口内拖拽控件。

拖拽示例代码位于 NP.Demos.DragBehaviorSample 项目下。

打开、编译并运行项目——您将在窗口左右两侧垂直一半的中间看到一个粉色圆圈和一个蓝色方块。您可以通过按住鼠标左键并拖动它们到您想要的位置来移动它们。

非平凡的代码位于 `DragBehavior.cs` 和 `MainWindow.axaml` 文件中。

`MainWindow.axaml` 非常简单——窗口包含一个具有两列的网格。左列有一个粉色椭圆,右列有一个蓝色矩形。

<Window ...
        xmlns:local="clr-namespace:NP.Demos.DragBehaviorSample"
        ...>
    <Grid ColumnDefinitions="*, *">
        <Ellipse Width="30"
                 Height="30"
                 Fill="Pink"
                 HorizontalAlignment="Center"
                 VerticalAlignment="Center"
                 local:DragBehavior.IsSet="True"/>

        <Rectangle Width="30"
                   Height="30"
                   Fill="Blue"
                   Grid.Column="1"
                   HorizontalAlignment="Center"
                   VerticalAlignment="Center"
                   local:DragBehavior.IsSet="True"/>
    </Grid>
</Window>  

文件中最有趣的行是设置椭圆和矩形上行为的行,方法是将附加属性 `local:DragBehavior.IsSet` 设置为 `true`。

查看 `DragBehavior.cs` 文件。它包含三个附加属性。

  1. ``bool IsSet`` - 在控件上设置为 true 后,控件即可拖拽。
  2. ``Point InitialPointerLocation`` - 在拖拽操作开始时设置为窗口内的指针位置。
  3. ``Point InitialDragShift`` - 在拖拽操作开始时设置为控件的偏移量(相对于原始位置)。

对 `IsSet` 附加属性的回调在属性设置为 `true` 时,将处理程序设置为 `PointerPressed` 事件,并将控件的 `RenderTransform` 设置为 `TranslateTransform`(它会移动控件)。如果属性设置为 `false`,则发生相反的操作—— `PointerPressed` 处理程序被移除,`RenderTransform` 变为 `null`。

static DragBehavior()
{
    IsSetProperty.Changed.Subscribe(OnIsSetChanged);
}

// set the PointerPressed handler when 
private static void OnIsSetChanged(AvaloniaPropertyChangedEventArgs<bool> args)
{
    IControl control = (IControl) args.Sender;

    if (args.NewValue.Value == true)
    {
        // connect the pointer pressed event handler
        control.RenderTransform = new TranslateTransform();
        control.PointerPressed += Control_PointerPressed;
    }
    else
    {
        // disconnect the pointer pressed event handler
        control.RenderTransform = null;
        control.PointerPressed -= Control_PointerPressed;
    }
}  

查看 `Control_PointerPressed(...)` 处理程序——当拖拽操作开始时,它会被触发。

// start drag by pressing the point on draggable control
private static void Control_PointerPressed(object? sender, PointerPressedEventArgs e)
{
    IControl control = (IControl)sender!;

    // capture the pointer on the control
    // meaning - the mouse pointer will be producing the
    // pointer events on the control
    // even if it is not directly above the control
    e.Pointer.Capture(control);

    // calculate the drag-initial pointer position within the window
    Point currentPointerPositionInWindow = GetCurrentPointerPositionInWindow(control, e);

    // record the drag-initial pointer position within the window
    SetInitialPointerLocation(control, currentPointerPositionInWindow);

    Point startControlPosition = GetShift(control);

    // record the drag-initial shift of the control
    SetInitialDragShift(control, startControlPosition);

    // add handler to do the shift and 
    // other processing on PointerMoved
    // and PointerReleased events. 
    control.PointerMoved += Control_PointerMoved;
    control.PointerReleased += Control_PointerReleased;
}  

我们在控件内捕获鼠标,获取指针位置的初始值和控件的初始偏移量,并将这些值分别记录在 `InitialPointerLocation` 和 `InitialDragShift` 附加属性中。我们还为控件上的 `PointerMoved` 和 `PointerReleased` 事件设置了处理程序——这些处理程序将在拖拽操作结束时释放。

我们在 `PointerMoved` 事件上进行的操作如下。

 
// update the shift when pointer is moved
private static void Control_PointerMoved(object? sender, PointerEventArgs e)
{
    IControl control = (IControl)sender!;
    // Shift control to the current position
    ShiftControl(control, e);
} 

本质上,我们只调用 `ShiftControl` 方法。

// modifies the shift on the control during the drag
// this essentially moves the control
private static void ShiftControl(IControl control, PointerEventArgs e)
{
    // get the current pointer location
    Point currentPointerPosition = GetCurrentPointerPositionInWindow(control, e);

    // get the pointer location when Drag operation was started
    Point startPointerPosition = GetInitialPointerLocation(control);

    // diff is how far the pointer shifted
    Point diff = currentPointerPosition - startPointerPosition;

    // get the original shift when the drag operation started
    Point startControlPosition = GetInitialDragShift(control);

    // get the resulting shift as the sum of 
    // pointer shift during the drag and the original shift
    Point shift = diff + startControlPosition;

    // set the shift on the control
    SetShift(control, shift);
}  

此方法获取指针的当前位置,获取其与拖拽操作开始时指针位置之间的差值,并将此差值与控件在拖拽操作开始时的偏移量相加,以获得控件所需的当前偏移量。

``Control_PointerReleased`` 处理程序释放捕获,并移除 `PointerMoved` 和 `PointerReleased` 处理程序,同时将控件移动到拖拽操作的最终位置。

// Drag operation ends when the pointer is released. 
private static void Control_PointerReleased(object? sender, PointerReleasedEventArgs e)
{
    IControl control = (IControl)sender!;

    // release the capture
    e.Pointer.Capture(null);

    ShiftControl(control, e);

    // disconnect the handlers 
    control.PointerMoved -= Control_PointerMoved;
    control.PointerReleased -= Control_PointerReleased;
}  

结论

本文描述了非常有用的行为模式——一种允许在不修改对象类代码的情况下,非侵入性地修改对象行为的功能。行为可以使用可观察对象(包括事件)来检测变化,并根据这些变化修改对象属性。

© . All rights reserved.