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

使用简单的示例学习 Avalonia .NET 框架跨平台编程基础概念

starIconstarIconstarIconstarIconstarIcon

5.00/5 (40投票s)

2021年9月3日

MIT

39分钟阅读

viewsIcon

63903

本文解释了类似 WPF 的跨平台 UI 包 Avalonia 最重要和最基本概念。

引言

请注意,本文已更新以反映 Avalonia 11 的更改,并且这里引用的示例已更新为在 Avalonia 11 下运行。

本文可视为 使用 AvaloniaUI 进行跨平台 UI 编码的简单示例。第一部分 - AvaloniaUI 构建块 的第二部分,尽管不必阅读第一篇文章即可理解本文内容。

关于 Avalonia

Avalonia 是一个开源包,与 WPF 非常相似,但与 WPF、UWP 或 WinUI 不同,它允许为各种桌面和移动平台以及 Web(通过 WebAssembly)创建应用程序。

Avalonia 的代码可重用性高达 99% - 只需要很少的平台相关代码,而且只有在需要处理多个窗口时才需要。只要您停留在单个窗口内 - Avalonia 代码可重用性为 100%,并且在 Windows 上工作的任何内容都将在 Web 应用程序或移动应用程序上正常运行。

Avalonia 的源代码可在 GitHub 上的 Avalonia 源代码 上找到。

Avalonia 在 Gitter 上提供不错的免费公开支持:Gitter 上的 Avalonia 或通过 Telegram,以及在 Avalonia 支持 购买商业支持的一些选项。您也可以在 Avalonia Github 讨论区 提问。

Avalonia 比 Web 编程框架或 Xamarin 更好的原因已在上一篇文章中详细介绍:使用 AvaloniaUI 进行跨平台 UI 编码的简单示例。第一部分 - AvaloniaUI 构建块。在此,我将只总结两个主要原因

  • Avalonia 11 是一个允许创建的包
    • 适用于 Windows、Linux、MacOS 的桌面应用程序,
    • 适用于 Android、iOS 和 Tizen 的移动应用
    • Web 应用程序(通过 WebAssembly)
  • Avalonia 的代码可重用性高达 99% - 只需要很少的平台相关代码,而且只有在需要处理多个窗口时才需要。只要您停留在单个窗口内 - Avalonia 代码可重用性为 100%,并且在 Windows 上工作的任何内容都将在 Web 应用程序或移动应用程序上正常运行。
  • Avalonia 代码编译速度非常快 - 允许快速原型制作。
  • Avalonia 性能也非常高 - 远优于其任何竞争对手。
  • Avalonia 框架(就像 WPF 一样)是 100% 组合式的 - 一个简单的按钮可以由几何路径、边框和图像等基本元素组成,就像创建非常复杂的页面或视图一样。开发者完全可以自由选择控件的外观和行为以及哪些属性是可自定义的。此外,更简单的基本元素可以组织成更复杂的元素,从而降低复杂性。HTML/JavaScript/TypeScript 框架或 Xamarin 都无法达到如此程度的组合性 - 事实上,它们的基本元素是按钮、复选框和菜单,这些元素带有许多可供自定义修改的属性(有些属性可能特定于平台或浏览器)。在这方面,Avalonia 开发者有更多的自由来创建客户所需的任何控件。
  • WPF 带来了许多新的开发范式,可以显著更快、更干净地开发可视化应用程序 - 其中包括可视化树和逻辑树、绑定、附加属性、附加路由事件、数据和控件模板、样式、行为。这些范式中很少有在 Web 框架和 Xamarin 中实现,并且在那里功能要弱得多,而在 Avalonia 中 - 所有这些都已实现,并且某些功能,例如属性和绑定,甚至比 WPF 中的实现方式更强大。

本文的目的

本文的主要目的是向那些不一定了解 WPF 的人解释最重要的 Avalonia/WPF 概念。 对于 WPF 专家来说,本文将作为通往 Avalonia 的门户。

我将通过提供解释、详细的图片和简单的 Avalonia 示例来阐明这些概念,只要有可能,就会突出显示该概念。

本文的组织结构

本文将涵盖以下主题

  1. 可视化树
  2. 逻辑树
  3. 附加属性
  4. 样式属性
  5. 直接属性
  6. 绑定

以下主题将留待以后文章讨论

  1. 路由事件
  2. Commands
  3. 控件模板(基础)
  4. MVVM 模式、数据模板ItemsPresenterContentPresenter
  5. 从 XAML 调用 C# 方法
  6. XAML - 通过标记扩展重用 Avalonia XAML
  7. 样式、过渡、动画

示例代码

示例代码位于 Avalonia 概念文章的演示代码 下。此处的所有示例均在 Windows 10、MacOS Catalina 和 Ubuntu 20.4 上进行了测试。

所有代码都应该可以在 Visual Studio 2019 下编译和运行 - 这是我一直使用的。另外,请确保您第一次编译示例时互联网连接已打开,因为有些 nuget 包需要下载。

概念解释

可视化树

Avalonia(和 WPF)的基本构建块(基本元素)包括

  1. 基本元素 - Avalonia 宇宙中无法分解为子元素的非常基本元素,如 TextBlockBorderPathImageViewbox 等。
  2. 面板 - 负责在其内部排列其他元素的元素。

其余控件(更复杂的控件,包括 ButtonComboBoxMenu 等基本控件)和复杂的视图是通过将各种基本元素组合在一起,将它们放置在其他基本元素或面板内来构建的。在 Avalonia 中,基本元素通常继承自 Control 类,而更复杂的控件继承自 TemplatedControl 类;而在 WPF 中,基本元素继承自 Visual,更复杂的控件继承自 Control(在 WPF 中,Control 具有 Template 属性和相关基础设施,而在 Avalonia 中,则是 TemplatedControl 拥有它们)。您可以在上一篇文章的 Avalonia 基本元素部分 中了解更多关于 Avalonia 基本元素的信息。

Avalonia(和 WPF)可视化对象的组成可以是分层的:我们用基本元素创建一些更简单的对象,然后用这些更简单的对象(也许还有基本元素)创建更复杂的对象,依此类推。这种分层组合的原则是重用可视化组件的核心方法之一。

下图显示了一个简单的按钮可能由几个基本元素组成:例如,它可能由一个 Grid 面板组成,该面板有一个用于按钮文本的 TextBlock 对象和一个用于按钮图标的 Image 对象。对象这种包含结构清楚地定义了一个简单的树 - 可视化树。

这是上面描述的非常简单的按钮的图示

这是按钮可视化树的图示

当然,真实的按钮可视化树可能更复杂,还包括用于按钮边框和阴影的边框,以及一个或多个面板,当鼠标悬停在按钮上时,这些面板会改变不透明度或颜色,以指示该按钮在鼠标单击时处于活动状态,以及其他许多东西,但为了解释可视化树的概念,上面描述的按钮就足够了。

现在开始NP.Demos.VisualTreeSample.sln 解决方案。在此解决方案中,唯一与默认内容不同的文件是MainWindow.axaml.axaml 文件与.xaml 文件非常相似,Avalonia 使用它们以便与 WPF.xaml 文件共存)和MainWindow.axaml.cs。您可以在 AvaloniaUI 应用程序项目中找到有关文件的更多信息,请参阅 使用 Visual Studio 2019 创建和运行简单的 AvaloniaUI 项目 部分。

这是MainWindow.xaml 的内容

<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        x:Class="NP.Demos.VisualAndLogicalTreeSample.MainWindow"
        Title="NP.Demos.VisualAndLogicalTreeSample"
        Width="300"
        Height="200">
  <Button x:Name="SimpleButton"
          Content="Click Me" 
          HorizontalAlignment="Center"
          VerticalAlignment="Center"/>
</Window>  

也简要看一下App.axaml 文件。您会看到对 SimpleTheme 的引用

<Application.Styles>
    <SimpleTheme/>
</Application.Styles>  

主题定义了所有主要控件的外观和行为,当然也包括按钮。它们通过使用样式和模板来实现这一点,具体如何实现 - 将在接下来的系列文章中进行解释。重要的是要理解,我们的 Button 的可视化树是由 ButtonControlTemplate 定义的,该模板位于 Button 的样式内,而该样式又位于 SimpleTheme 内。

这是您运行项目时看到的内容

单击窗口以使其获得鼠标焦点,然后按 F12 键。Avalonia 工具窗口将打开

该工具窗口类似于 WPF 的 snoop(尽管在某些方面它仍然不如 WPF 的 snoop 功能强大)。它使您能够检查可视化树或逻辑树中任何元素的任何属性或事件,还可以修改可写属性。

在 Avalonia 中,逻辑树(稍后将提供其解释)比 WPF 中扮演的角色更大,因此默认情况下,工具会显示逻辑树,要切换到可视化树,您需要单击“可视化树”选项卡(在上图中用红色椭圆高亮显示)。

一旦我们将工具切换为显示可视化树,同时按住 Control 和 Shift 键,然后将鼠标悬停在按钮文本上。左侧的工具可视化树将展开到包含按钮文本的元素,中间的属性窗格将显示当前选定可视化树元素的属性(在本例中为按钮的 TextBlock 元素)。

可视化树实际上是为整个窗口显示的(并且对应于当前选定元素的该部分已展开)。

您可以看到,来自 FluentThemeButton 的可视化树实际上比我们上面考虑的更简单 - 它只包含三个元素 - Button(根元素),然后是 ContentPresenter,最后是 TextBlock 元素。

您可以选择另一个元素,例如 Button - 它是 TextBlock 的祖父,以查看工具中间窗格中的 button 属性。如果您正在寻找一个特定的属性,例如 DataContext,您可以在属性表的顶部键入其名称的一部分,例如“context”,它将过滤出名称包含“context”一词的属性。

关于该工具的更多信息将在下一节中介绍。

获取可视化树节点的 C# 功能示例位于MainWindow.xaml.cs 文件中的 OnButtonClick 方法内

private void OnButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
    IVisual parent = _button.GetVisualParent();

    var visualAncestors = _button.GetVisualAncestors().ToList();

    var visualChildren = _button.GetVisualChildren().ToList();

    var visualDescendants = _button.GetVisualDescendants().ToList();
}  

此方法用于处理按钮的单击事件

_button = this.FindControl<Button>("SimpleButton");

_button.Click += OnButtonClick; 

请注意,为了使可视化树扩展方法可用,我们不得不在MainWindow.axaml.cs 文件的顶部添加 using Avalonia.VisualTree; 命名空间引用。

在方法的最后一行设置一个断点,然后单击按钮。您可以在 Watch 窗口中检查 OnButtonClick() 方法中的变量内容。

可以看到结果与工具中观察到的可视化树一致

确实,

  1. 我们的 Button 的父级是 ContentPresenter
  2. 我们的 Button 有四个祖先:ContentPresenterVisualLayoutManagerPanelWindow
  3. 我们的 Button 只有一个子级 - ContentPresenter
  4. 我们的 Button 有两个后代:ContentPresenterTextBlock

Avalonia 工具

既然我们在上一节中提到了 Avalonia 工具,那么在这里就多介绍一些关于它的信息。

该工具的优点在于它也是用 Avalonia 编写的,因此是跨平台的。如果您想在 Mac OS 和 Linux 上检查树和属性,它也会显示在这些平台上 - 您只需单击您希望工具为其工作的窗口,然后按 F12 键。

该工具仅显示对应于单个窗口的信息,因此如果您正在使用多个您希望检查其树和属性的窗口,您将需要使用多个工具窗口。

该工具不会显示在没有设置 DEBUG 预处理器变量的配置上,例如,默认的发布配置。

逻辑树

逻辑树是可视化树的子集 - 它比可视化树更稀疏 - 包含的元素更少。它紧密跟随 XAML 代码,但不会展开任何控件模板(它们是什么将在以后的文章中解释)。当显示 ContentControl 时,它会直接从 ContentControl 到表示其 Content 的元素(省略中间的所有内容)。当显示 ItemsControl 时,它会直接从 ItemsControl 元素到表示其项目内容的元素,同样省略中间的所有内容。

代码位于NP.Demos.LogicalTreeSample.sln 解决方案下。运行它您将看到以下内容

这是从MainWindow.xaml 文件中生成此布局的 XAML 代码

<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        x:Class="NP.Demos.LogicalTreeSample.MainWindow"
        Title="NP.Demos.LogicalTreeSample"
        Width="300"
        Height="200">

  <Grid RowDefinitions="*, *">
    <Button x:Name="ClickMeButton" 
            Content="Click Me"
            HorizontalAlignment="Center"
            VerticalAlignment="Center"/>

    <ItemsControl Grid.Row="1"
                  HorizontalAlignment="Center"
                  VerticalAlignment="Center">
      <Button x:Name="Item1Button" 
              Content="Item1 Button"/>
      <Button x:Name="Item2Button"
              Content="Item2 Button"/>
    </ItemsControl>
  </Grid>
</Window>  

我们看到窗口的内容由带有两行的 Grid 面板表示。顶行包含按钮“Click Me”,底行包含一个带有两个按钮的 ItemsControl:“Item1 Button”和“Item2 Button”。按钮的 XAML 名称与它们上面写的内容相同,只是没有空格:“ClickMeButton”、“Item1Button”和“Item2Button”。

单击示例窗口,然后按 F12 启动工具并展开工具中的逻辑树 - 您将看到以下内容

您可以看到,可视化树中只包含对应于MainWindow.axaml 文件 XAML 标签的元素,以及Buttons 中的 TextBoxes(因为按钮是 ContentControl - TextBoxes 是表示其内容的元素)。可视化树中存在的许多节点在这里都丢失了 - 您在这里找不到由于控件模板展开而创建的可视化树元素 - 没有窗口边框、面板、VisualLayoutManager 等,我们直接从 Window 跳到 Grid,因为 Grid 元素是MainWindow.axaml 文件的一部分。同样,我们直接从 Button 跳到 TextBlock,省略 ContentPresenter,因为它来自 Button 的模板展开。

现在看一下MainWindow.axaml.cs 文件中的 OnButtonClick 方法

private void OnButtonClick(object? sender, RoutedEventArgs e)
{
    ItemsControl itemsControl = this.FindControl<ItemsControl>("TheItemsControl");

    var logicalParent = itemsControl.GetLogicalParent();
    var logicalAncestors = itemsControl.GetLogicalAncestors().ToList();
    var logicalChildren = itemsControl.GetLogicalChildren().ToList();
    var logicalDescendants = itemsControl.GetLogicalDescendants().ToList();
}  

我们正在获取 ItemsControl 元素的逻辑父级、祖先、子级和后代。此方法设置为“ClickMeButton”的 Click 事件处理程序。

Button clickMeButton = this.FindControl<Button>("ClickMeButton");
clickMeButton.Click += OnButtonClick;  

请注意,为了获得这些扩展方法,我们不得不在MainWindow.xaml.cs 文件的顶部添加 using Avalonia.LogicalTree 命名空间。

在方法末尾放置一个断点,运行应用程序并按“ClickMeButton”。在 **Watch** 窗口中检查变量。

这与我们在工具中看到的内容完全一致。

附加属性

附加属性是一个非常重要且有用的概念。它最初由 WPF 引入,然后直接进入 Avalonia,但功能更强大且已扩展。

为了解释什么是附加属性,让我们首先回顾一下 C# 中的简单读/写属性。本质上,在 MyClass 类中定义的 T 类型属性可以通过两个方法来表示 - getter 和 setter 方法。

public class MyClass  
{
  T Getter();
  void Setter(T value);
}

通常,此类属性是通过在同一类中定义的类型为 T 的后备字段实现的。

public class MyClass  
{
  // the backing field
  T _val;

  T Getter() => _val;
  void Setter(T value) => _val = value;
} 

在 WPF 工作期间,WPF 架构师面临一个有趣的问题。每个可视化对象都需要定义数百甚至数千个属性,其中大部分在任何时候都具有默认值。为每个对象中的每个属性定义后备字段会导致内存消耗巨大,尤其是因为其中大约 90% 的属性在任何时候都具有默认值。

因此,为了解决这个问题,他们提出了附加属性。附加属性不是将属性值存储在对象内的后备字段中,而是将值存储在某种静态哈希表字典(或Map)中,其中值由可能拥有这些属性的各种对象索引。只有具有非默认属性值的对象才位于哈希表中,如果对象条目不在哈希表中,则假定该对象的属性具有默认值。附加属性的静态哈希表几乎可以在任何类中定义 - 通常,它定义在不使用其值的类之外。所以,大致(且近似地)来说 - 一个类型为 double 的类 MyClass 上的附加属性 MyAttachedProperty 的实现将类似于

public class MyClass
{

}

public static class MyAttachedPropertyContainer
{
    // Attached Property's default value
    private static double MyAttachedPropertyDefaultValue = 5.0;

    // Attached Property's Dictionary
    private static Dictionary<MyClass, double> MyAttachedPropertyDictionary =
                                              new Dictionary<MyClass, double>();

    // property getter
    public static double GetMyAttachedProperty(this MyClass obj)
    {
        if (MyAttachedPropertyDictionary.TryGetValue(obj, out double value)
        {
            return value;
        }
        else // there is no entry in the Dictionary for the object
        {
            return MyAttachedPropertyDefaultValue; // return default value
        }
    }

    // property setter
    public static SetMyAttachedProperty(this MyClass obj, double value)
    {
        if (value == MyAttachedPropertyDefaultValue)
        {
           // since the property value on this object 'obj' should become default,
           // we remove this object's entry from the Dictionary -
           // once it is not found in the Dictionary, - the default value will be returned
           MyAttachedPropertyDictionary.Remove(obj);
        }
        else
        {
            // we set the object 'to have' the passed property value
            // by setting the Dictionary cell corresponding to the object
            // to contain that value
            MyAttachedPropertyDictionary[obj] = value;
        }
    }
}

因此,与其让 MyClass 类型的每个对象都包含该值,不如将该值存储在某个静态字典中,该字典由 MyClass 类型的对象索引。也可以为该属性指定一个默认值(在本例中为 5.0),以便只有具有非默认属性值的对象才需要一个字典条目。

这种方法以 getter 和 setter 属性速度略微变慢为代价,节省了大量内存。

在尝试了附加属性后,人们发现除了节省内存之外,它们还带来了许多其他好处 - 例如

  • 您可以轻松地向它们添加一些属性更改通知回调,一旦属性在对象上更改,这些回调就会触发。
  • 您可以在不修改类本身的情况下,在类上定义一个附加属性。这极其重要。一个明显的例子是,普通按钮没有 CornerRadius 属性。假设您的应用程序中有许多不同类型的按钮,突然,用户要求其中许多按钮应具有平滑的边框圆角,并且不同的按钮应具有不同的圆角半径。您不想为按钮创建新的派生类型并处处替换它们并重新测试所有这些按钮,但您可以通过稍微修改按钮样式来完成。您可以创建一个附加属性 TheCornerRadiusProperty,将按钮边框的 CornerRadius 属性绑定到按钮的 TheCornerRadiusProperty,并在各个按钮的样式中设置此属性以获得所需的值。
  • 概括上一项,附加属性允许创建和附加行为到可视化对象 - 行为是复杂的类,允许修改和增强可视化对象的功能,而无需修改可视化对象类。行为对于本文来说有点复杂,将在以后进行描述。

当然,上面显示的非常简单的实现没有考虑到许多其他问题,如线程、回调、注册(为了知道我们类 MyClass 上允许的所有附加属性)等等。此外,将默认值定义为静态变量本身在属性之外,就像我们上面那样,这是很糟糕的。由于这些考虑,创建一种特殊类型(可能带有泛型参数)AttachedProperty<...> 来包含字典、默认值以及属性运行所需的所有其他功能是有意义的。这就是 WPF 和 Avalonia 所做的。

在继续进行附加属性示例之前,最好下载我提供的 Avalonia 片段 Avalonia 片段 并安装它们。安装说明可以在同一 URL 找到。

附加属性示例位于NP.Demos.AttachedPropertySample.sln 解决方案下。尝试运行它。您将看到以下内容

滑块的变化可以在 010 之间改变,当您改变滑块位置时,矩形的 StrokeThickness 属性相应地改变 - 矩形变得更厚或更薄(当滑块位置为 0 时,矩形完全消失)。

查看AttachedProperties.cs 文件内容 - RectangleStrokeThickness 附加属性定义在那里。该属性是使用avap 片段(名称代表 Avalonia 附加属性)创建的。

public static class AttachedProperties
{
    #region RectangleStrokeThickness Attached Avalonia Property

    // Attached Property Getter
    public static double GetRectangleStrokeThickness(AvaloniaObject obj)
    {
        return obj.GetValue(RectangleStrokeThicknessProperty);
    }

    // Attached Property Setter
    public static void SetRectangleStrokeThickness(AvaloniaObject obj, double value)
    {
        obj.SetValue(RectangleStrokeThicknessProperty, value);
    }

    // Static field that of AttachedProperty<double> type. This field contains the
    // Attached Properties' Dictionary, the default value and the rest of the required 
    // functionality
    public static readonly AttachedProperty<double> RectangleStrokeThicknessProperty =
        AvaloniaProperty.RegisterAttached<object, Control, double>
        (
            "RectangleStrokeThickness", // property name
            3.0 // property default value
        );

    #endregion RectangleStrokeThickness Attached Avalonia Property
}  

我们可以看到

  • public static double GetRectangleStrokeThickness(AvaloniaObject obj) 是 getter(类似于上面讨论的),
  • public static void SetRectangleStrokeThickness(AvaloniaObject obj, double value) 是 setter。
  • public static readonly AttachedProperty<double> RectangleStrokeThicknessProperty</double> 是包含字典(或对象到值哈希表)以及附加属性的默认值和所有其他所需功能的静态字段。

定义附加属性所需的代码量看起来很多,但借助avap 片段,它在几秒钟内全部生成。所以,如果您打算使用 Avalonia - 片段是必需的(就像在 WPF 中一样)。您还可以看到我的片段将每个附加属性放在自己的区域内,因此可以折叠它,使代码更具可读性。

现在,看一下MainWindow.cs 文件中的 XAML 代码

<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:NP.Demos.AttachedPropertySample"
        x:Class="NP.Demos.AttachedPropertySample.MainWindow"
        Title="NP.Demos.AttachedPropertySample"
        local:AttachedProperties.RectangleStrokeThickness="7"
        Width="300"
        Height="300">
  <Grid RowDefinitions="*, Auto">
        <Rectangle Width="100"
                   Height="100"
                   Stroke="Green"
                   StrokeThickness="{Binding Path=
                                    (local:AttachedProperties.RectangleStrokeThickness), 
                                     RelativeSource={RelativeSource AncestorType=Window}}"
                   HorizontalAlignment="Center"
                   VerticalAlignment="Center"/>
    
      <Slider Minimum="0"
              Maximum="10"
              Grid.Row="1"
              Value="{Binding Path=(local:AttachedProperties.RectangleStrokeThickness), 
                              Mode=TwoWay, 
                              RelativeSource={RelativeSource AncestorType=Window}}"
              Margin="10,20"
              HorizontalAlignment="Center"
              VerticalAlignment="Center"
              Width="150"/>
  </Grid>
</Window>  

请注意,在 XAML 中,我使用了绑定 - 这是一个非常重要的概念,稍后将详细解释。

我们有一个带有两行的 Grid 面板 - 上行有一个 Rectangle,下行有一个 Slider 控件。Slider 的值可以在 010 之间改变。

几乎在最上面 - 在 Window XAML 标签中有一行如下:

xmlns:local="clr-namespace:NP.Demos.AttachedPropertySample"  

此行定义了本地 XAML 命名空间,以便通过该命名空间,我们可以引用我们的 RectangleStrokeThickness 附加属性。

下一行很有趣:

local:AttachedProperties.RectangleStrokeThickness="7"  

在这里,我们将窗口对象的 RectangleStrokeThickness 附加属性的初始值设置为数字 7。请注意我们如何指定附加属性:<namespace-name>:<class-name>.<AttachedProperty-name>

这行...

StrokeThickness="{Binding Path=(local:AttachedProperties.RectangleStrokeThickness), 
                          RelativeSource={RelativeSource AncestorType=Window}}"

...在 Rectangle 标签下将矩形的 StrokeThickness 属性绑定到矩形祖先窗口上的附加属性 RectangleStrokeThickness。请注意 Binding 中附加属性的格式 - 附加属性的完整名称在括号内 - 这是 Avalonia 和 WPF 的要求 - 没有括号,绑定将不起作用,人们可能会花费数小时来弄清楚哪里出了问题。

Slider 的行

Value="{Binding Path=(local:AttachedProperties.RectangleStrokeThickness), 
                Mode=TwoWay, 
                RelativeSource={RelativeSource AncestorType=Window}}"  

SliderValue 属性绑定到滑块的窗口祖先的 RectangleStrokeThickness 附加属性(当然,这与矩形的窗口祖先是同一个 Window 对象)。此绑定是双向绑定 - 意味着 SliderValue 属性的更改也会更改 Window 上的 RectangleStrokeThickness 附加属性值。

此视图的操作原理很简单 - 通过移动 Slider 的所谓拇指来改变 Slider 的值 - 将通过 Slider 的绑定触发窗口上 RectangleStrokeThickness 附加属性的更改,反过来,这将通过 Rectangle 的绑定的 StrokeThickness 属性的更改来触发。

当然,在这种简单的情况下,我们可以直接将 SliderValue 连接到 RectangleStrokeThickness 属性,而无需涉及窗口上的附加属性,但那样的话,示例将无法演示附加属性的工作方式(并且在许多情况下,例如当控件上不存在所需属性时,附加属性是必需的)。

现在尝试删除顶部将初始值设置为 7 的行

local:AttachedProperties.RectangleStrokeThickness="7"  

然后重新启动应用程序。您将看到 RectangleStrokeThicknessSliderValue 的初始值变成了 3.0 而不是 7.0。这是因为我们的附加属性的默认值为 3.0,正如在注册附加属性时定义的。

现在让我们讨论附加属性更改通知。

查看MainWindow.axaml.cs 文件:这里是该文件中有趣的代码

public partial class MainWindow : Window
{
    // to stop change notification dispose of this subscription token
    private IDisposable _changeNotificationSubscriptionToken;

    public MainWindow()
    {
        InitializeComponent();

        ...

        // subscribe
        _changeNotificationSubscriptionToken =
            AttachedProperties
                .RectangleStrokeThicknessProperty
                .Changed
                .Subscribe(OnRectangleStrokeThicknessChanged);
    }

    // this method is called when the Attached property changes
    private void OnRectangleStrokeThicknessChanged
    (AvaloniaPropertyChangedEventArgs<double> changeParams)
    {
        // if the object on which this attached property changes
        // is not this very window, do not do anything
        if (changeParams.Sender != this)
        {
            return;
        }

        // check the old and new values of the attached property. 
        double oldValue = changeParams.OldValue.Value;

        double newValue = changeParams.NewValue.Value;
    }  

    ...
}

在顶部,我们定义了订阅令牌 - 它是 IDisposable,所以如果我们想停止响应订阅更改,我们可以调用 _changeNotificationSubscriptionToken.Dispose()

在构造函数中订阅附加属性更改。

// subscribe
_changeNotificationSubscriptionToken =
    AttachedProperties
        .RectangleStrokeThicknessProperty
        .Changed
        .Subscribe(OnRectangleStrokeThicknessChanged);  

当值更改时,将调用 void OnRectangleStrokeThicknessChanged(...) 方法。该方法接受一个类型为 AvaloniaPropertyChangedEventArgs<double> 的单个参数,该参数包含所有必需的信息。

  1. Sender 属性提供附加属性更改的对象。
  2. OldValue 属性包含有关先前值的信息。
  3. NewValue 属性包含有关当前值的信息。

您可以在方法的末尾放置一个调试断点,在调试器中启动应用程序并尝试移动滑块 - 您将停在断点处,并能够检查当前值。

另一种更简单的方法可以做到大致相同的事情(但无法终止订阅),即在MainWindow.axaml.cs 文件中创建一个静态构造函数,并使用AddClassHandler 扩展方法。

static MainWindow()
{
    AttachedProperties
        .RectangleStrokeThicknessProperty
        .Changed
        .AddClassHandler<MainWindow>((x, e) => x.OnAttachedPropertyChanged(e));
}

private void OnAttachedPropertyChanged(AvaloniaPropertyChangedEventArgs e)
{
    double? oldValue = (double?) e.OldValue;

    double? newValue = (double?)e.NewValue;
}  

请注意,在这里,您无需检查发送者是否与当前对象相同。

您可以看到 OnAttachedPropertyChanged(...) 方法的签名稍微不太类型安全。通常,这种方式是完全可以的,并且 99% 的情况下,您可以使用 AddClassHandler(...) 来达到您所需的目的。

您可能已经注意到,Avalonia 在处理附加属性更改通知时使用了强大的 IObservable 响应式扩展范例。

样式属性

WPF 有一个依赖属性的概念,它基本上与附加属性相同,只是它们定义在使用它们的类内部,相应地,它们的 getter 和 setter 放置在的同名属性中。请注意,对于依赖属性,我们仍然具有不浪费内存存储默认值和轻松添加回调的优势,但我们失去了在不修改类的情况下添加属性的优势。

我曾尝试在 Avalonia 中使用本地定义的附加属性,并且没有注意到任何问题,但根据 Avalonia 文档,最好使用所谓的样式属性(原因 - 我目前还不清楚)。

我们将遵循文档,并运行一个演示如何使用所谓样式属性的示例。

对于示例,请打开NP.Demos.StylePropertySample.sln 解决方案。

该示例的运行方式与上一个示例完全相同,代码也非常相似,只是它不是使用AttachedProperties.cs 文件中定义的 RectangleStrokeThickness 附加属性,而是使用MainWindow.axaml.cs 文件中定义的同名 Style 属性。您可以看到Style 属性的 getter 和 setter 是非静态的,并且相当简单。

#region RectangleStrokeThickness Styled Avalonia Property
public double RectangleStrokeThickness
{
    // getter 
    get { return GetValue(RectangleStrokeThicknessProperty); }

    // setter
    set { SetValue(RectangleStrokeThicknessProperty, value); }
}

// the static field that contains the hashtable mapping the 
// object of type MainWindow into double and also containing the 
// information about the default value
public static readonly StyledProperty<double> RectangleStrokeThicknessProperty =
    AvaloniaProperty.Register<MainWindow, double>
    (
        nameof(RectangleStrokeThickness)
    );
#endregion RectangleStrokeThickness Styled Avalonia Property  

这个style 属性也是使用我的另一个片段 - avsp(代表 Avalonia Style Property)在几秒钟内创建的。

直接属性

有时,人们希望使用一个由字段支持的简单 C# 属性,同时又能订阅其更改并将其用作某些绑定的目标 - 只有附加、样式和直接属性才能用作 Avalonia 绑定的目标。然而,简单的 C# 属性仍然可以作为绑定的,通过实现 INotifyPropertyChanged 接口来提供更改通知。

直接属性示例位于NP.Demos.DirectPropertySample.sln 解决方案下。该演示的行为与前两个演示完全相同,只是我们使用的是直接属性而不是样式或附加属性。

以下是在MainWindow.xaml.cs 文件中定义直接属性的方式。

#region RectangleStrokeThickness Direct Avalonia Property
private double _RectangleStrokeThickness = default;

public static readonly DirectProperty<MainWindow, double> RectangleStrokeThicknessProperty =
    AvaloniaProperty.RegisterDirect<MainWindow, double>
    (
        nameof(RectangleStrokeThickness),
        o => o.RectangleStrokeThickness,
        (o, v) => o.RectangleStrokeThickness = v
    );

public double RectangleStrokeThickness
{
    get => _RectangleStrokeThickness;
    set
    {
        SetAndRaise(RectangleStrokeThicknessProperty, ref _RectangleStrokeThickness, value);
    }
}

#endregion RectangleStrokeThickness Direct Avalonia Property  

此直接属性是通过使用avdr 片段(名称代表 Avalonia Direct)在几秒钟内创建的。

有关附加、样式和直接属性的更多信息

AttachedProperty<...>StyleProperty<...>DirectProperty<...> 类都派生自 AvaloniaProperty 类。

如上所述,只有附加、样式和直接属性才能成为 Avalonia UI 绑定的目标。

附加、样式和直接属性只能设置在实现 AvaloniaObject 的类上 - 这是一个非常基础的类,所有 Avalonia 可视化对象都实现了它。

如果您不需要更改变量的先前值(如我们上面示例中的 OldValue),订阅附加、样式和直接属性更改的最佳方法是使用 AvaloniaObject.GetObservable(AvaloniaProperty property) 方法。

为了演示使用 GetObservable(...) 方法,我们可以修改我们的附加属性示例如下:

public MainWindow()
{
...
_changeNotificationSubscriptionToken =
    this.GetObservable(AttachedProperties.RectangleStrokeThicknessProperty)
        .Subscribe(OnStrokeThicknessChanged);
}

private void OnStrokeThicknessChanged(double newValue)
{
...
}

您可以看到 OldValue 在回调中不再可用。

绑定

什么是 Avalonia UI 和 WPF 中的绑定?为什么需要它?

绑定是一个非常强大的概念,它允许将两个属性绑定在一起,以便当其中一个发生变化时,另一个也会发生变化。通常,绑定是从属性到目标属性 - 普通的单向绑定,但也有双向绑定,它确保无论哪个属性发生变化,两个属性都保持同步。还有另外两种绑定模式:单向到源一次性绑定,这些模式的使用频率相当低。

还有不太常讨论但同样重要的集合绑定,其中一个集合会模仿另一个集合,或者两个集合会互相模仿。

请注意,绑定的目标不一定与绑定的源完全相同,可以在源和目标之间以及反过来进行转换,如下所示。

绑定是所谓的 MVVM 模式(将在未来的文章中详细讨论)的核心概念。MVVM 模式的核心思想是,复杂的视觉对象模仿非常简单的非视觉对象 - 即所谓的视图模型 (VM) 的属性和行为。

因此,大多数业务逻辑可以在简单的非视觉对象上开发和测试,然后通过绑定传输到非常复杂的视觉对象,该对象将自动以类似的方式运行。

关于 Avalonia 绑定的优点

Avalonia 绑定比 WPF 绑定强大得多,错误和怪癖更少,也更容易使用 - 原因在于它们是最近由一位才华横溢的人(或几个人)Steven Kirk 构建的,他显然喜欢 WPF 绑定,了解其怪癖和限制,并且还了解软件开发理论和实践的最新进展 - 响应式扩展。

Avalonia 绑定的另一个优点是,与许多其他 Avalonia 功能不同,它们的文档相当齐全:在 Avalonia 数据绑定文档

鉴于以上所述,我认为展示如何在实际 C#/XAML 示例中创建各种绑定会很有用,特别是对于那些没有 WPF 经验的人。

Avalonia 绑定概念

Avalonia 绑定是一个复杂对象,具有许多功能,其中一些最重要的功能将在本小节中讨论。

Avalonia(和 WPF)绑定最好通过下图来解释。

以下绑定部分很重要

  1. 绑定源对象 - 通过该对象可以获得绑定源属性的路径的对象。
  2. 绑定目标对象 - 其 AvaloniaProperty(附加、样式或直接)属性作为绑定目标的。目标对象只能是派生自 AvaloniaObject 的类(这意味着它可以是任何 Avalonia 可视化对象)。AvaloniaObject 类似于 WPF 的 DependencyObject
  3. 绑定路径 - 从源对象到源属性的路径。路径由路径链接组成,每个链接可以是常规(C#)属性或 Avalonia 属性。在 XAML 绑定中,Avalonia 属性应放在括号中。以下是 XAML 中绑定的路径示例:MyProp1.(local:AttachedProperties.AttachedProperty1).MyProp2。此路径意味着在源对象上查找常规 C# 属性 MyProp1,然后查找附加属性 AttachedProperty1(定义在本地命名空间的 AttachedProperties 类中),然后再在该附加属性值中查找常规 C# 属性 MyProp2
  4. Target 属性 - 只能是附加、样式或直接属性类型之一。
  5. BindingMode 可以是
    1. OneWay - 从源到目标
    2. TwoWay - 当源或目标更改时,另一个也会更新。
    3. OneWayToSource - 当目标更新时,源也会更新,反之则不然。
    4. OneTime - 仅在初始化期间将目标与源同步一次。
    5. Default - 依赖于目标属性的首选绑定模式。当初始化附加样式或直接属性时,可以指定首选绑定模式,在这种情况下将使用该模式(当绑定本身未指定 BindingMode 时)。
  6. 转换器 - 仅当源值和目标值不同时才需要。它用于在源和目标之间以及反过来转换值。对于普通绑定,转换器应实现 IValueConverter 接口。

Avalonia 和 WPF 中都有所谓的多重绑定MultiBinding 假定有多个绑定源,但仍然是单个绑定目标。在这种情况下,多个源通过一个特殊的转换器合并为一个目标,该转换器实现了 IMultiValueConverter

绑定的复杂部分之一是,在 Avalonia 和 WPF 中都有几种指定源对象的方法,但 Avalonia 提供了更多方法。以下是指定源对象的各种方法的描述。

  1. 如果您根本不指定源对象 - 在这种情况下,默认源对象将由绑定目标 的 DataContext 属性提供。DataContext 会自动向下传播到可视化树,除非被显式更改(有一些例外)。
  2. 您可以通过将绑定Source 属性设置为 XAML 中的源,或者直接在 C# 中设置它,或者在 XAML 中使用 StaticResource 标记扩展来显式指定源。
  3. 有一个 ElementName 属性,可用于通过名称在同一个 XAML 文件中查找源元素。
  4. 有一个 RelativeSource 属性,它根据其 Mode 属性打开了更多有趣的查找源对象的方法。
    1. 对于 Mode==Self,源对象将与目标对象相同。
    2. Mode==TemplatedParent 只能在某个 Avalonia TemplatedControlControlTemplate 中使用 - 关于它的含义将在下一部分进行解释。ControlTemplate 中的 TemplatedParent 意味着绑定的源是模板所实现的控件。
    3. Mode==FindAncestor 意味着将在可视化树向上搜索源对象。还应在此模式中使用 AncestorType 属性来指定要搜索的源对象的类型。如果未指定其他任何内容,则该类型的第一个对象将成为源对象。如果还将 AncestorLevel 设置为某个正整数 N,则指定将返回该类型的第 N 个祖先对象(默认为 AncestorLevel == 1)作为绑定的源。
      在 Avalonia 中(但在 WPF 中没有),RelativeSourceTree 属性可以(惊人地)设置为 TreeType.Logical(默认为 TreeType.Visual)。在这种情况下,祖先将在逻辑树(更稀疏、更复杂)中向上搜索。

现在,理论讲够了,让我们做一些实际的例子。

在 XAML 中演示不同的绑定源

此示例位于NP.Demos.BindingSourcesSample.sln 解决方案下。此示例显示了在 XAML 中设置绑定源的各种可能方法。

运行示例后您将看到的内容

现在让我们逐一回顾各种示例(所有示例都位于MainWindow.axaml 文件中),并解释生成它的 XAML 代码。

DataContext(默认)绑定源
<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        ...
        DataContext="This is the Window's DataContext"
        ...>
        ...
        <Grid ...>
            <TextBlock Text="{Binding}"/>
        </Grid>
        ...
</Window>

当绑定中未指定源时,Binding 的源将回退到元素的 DataContext 属性。在我们的示例中,DataContext 设置在 Window 上,但由于它会向下传播到可视化树(除非显式更改),因此我们的 TextBlock 具有相同的 DataContext - 它只是一个由我们的 TextBlock 显示的字符串

设置 Binding.Source 属性

在第二个示例中,我们使用 StaticResource 标记扩展将绑定源设置为定义为 Window 资源字符串“This is the Window's resource”。

<Window xmlns="https://github.com/avaloniaui"
        ...>
  <Window.Resources>
    <x:String x:Key="TheResource">This is the Window's resource</x:String>
  </Window.Resources>
  ...
        <TextBlock Text="{Binding Source={StaticResource TheResource}}"
                   .../>
  ...
</Window>  
按 ElementName 绑定

我们的窗口具有 XAML 名称 -“TheWindow”,我们使用它来绑定到其 Tag:(Tag 是每个 Avalonia 控件上定义的属性,它可以包含任何对象。)

<Window ...
        Tag="This is the Window's Tag"
        x:Name="TheWindow"
        ...>
        ...
             <TextBlock Text="{Binding #TheWindow.Tag}"
                        .../>
        ...      
</Window>   

以上是 Avalonia 的简写形式,等同于 Text={Binding Path=Tag, ElementName=TheWindow}

使用 RelativeSource 绑定到自身

此示例显示元素如何使用 RelativeSource 的 Self 模式将自身作为绑定的源对象。

<TextBlock Text="{Binding Path=Tag, RelativeSource={RelativeSource Self}}"
         Tag="This is my own (TextBox'es) Tag"
         .../>  
绑定到 TemplatedParent

RelativeSource 配合 TemplatedParent 模式只能在 ControlTemplate 中使用,并且使用它意味着绑定引用由当前模板实现的控件上的属性(或路径)。

<TemplatedControl Tag="This is Control's Tag"
                  ...>
    <TemplatedControl.Template>
        <ControlTemplate>
            <TextBlock Text="{Binding Path=Tag, 
                       RelativeSource={RelativeSource TemplatedParent}}"/>
        </ControlTemplate>
    </TemplatedControl.Template>
</TemplatedControl>  

上面的代码表示我们正在绑定到由 ControlTemplate 实现的 TemplatedControl 上的 Tag 属性。

使用 RelativeSource 和 AncestorType 绑定到可视化树祖先

指定 AncestorType 将告知 Binding RelativeSource 处于 FindAncestor 模式。

<Grid ...
    Tag="This is the first Grid ancestor tag"
    ...>
    <StackPanel>
        <TextBlock Text="{Binding Path=Tag, 
                   RelativeSource={RelativeSource AncestorType=Grid}}"/>
    </StackPanel>
</Grid>
使用 RelativeSource、AncestorType 和 AncestorLevel 绑定到可视化树祖先

使用 AncestorLevel,您可以指定您需要的不只是第一个所需类型的祖先,而是第 N 个祖先 - 其中 N 可以是任何正整数。

在下面的代码中,我们正在搜索元素祖先中的第二个 Grid

<Grid ...
      Tag="This is the second Grid ancestor tag">
    <StackPanel>
        <Grid Tag="This is the first Grid ancestor tag">
            <StackPanel>
                <TextBlock Text="{Binding Path=Tag, 
                 RelativeSource={RelativeSource AncestorType=Grid, AncestorLevel=2}}"/>
            </StackPanel>
        </Grid>
    </StackPanel>
</Grid>
使用 Avalonia 绑定路径简写查找逻辑树中的父级
<Grid Tag="This is the first Grid ancestor tag">
  <StackPanel Tag="This is the immediate ancestor tag">
      <TextBlock Text="{Binding $parent.Tag}"/>
  </StackPanel>
</Grid>  

请注意,$parent.Tag 表示查找元素的父级(第一个祖先)并从中获取 Tag 属性。此绑定应等同于长版本

  <TextBlock Text="{Binding Path=Tag, 
   RelativeSource={RelativeSource Mode=FindAncestor, Tree=Logical}}">
使用 Avalonia 绑定路径简写查找逻辑树中类型为 Grid 的第一个父级
<Grid Tag="This is the first Grid ancestor tag">
  <StackPanel Tag="this is the immediate ancestor tag">
    <Button Tag="This is the first logical tree ancestor tag">
      <TextBlock Text="{Binding $parent[Grid].Tag}"/>
    </Button>
  </StackPanel>
</Grid>  

$parent[Grid].Tag 可以做到这一点。

使用 Avalonia 绑定路径简写绑定到第二个 Grid 祖先
<Grid Tag="This is the second Grid ancestor tag">
  <StackPanel>
    <Grid Tag="This is the first Grid ancestor tag">
      <StackPanel Tag="this is the immediate ancestor tag">
        <Button Tag="This is the first logical tree ancestor tag">
          <TextBlock Text="{Binding $parent[Grid;1].Tag}"/>
        </Button>
      </StackPanel>
    </Grid>
  </StackPanel>
</Grid>  

$parent[Grid;1] 指的是类型为 Grid 的第二个祖先。这里存在不一致 - 祖先的编号在可视化树中从 1 开始,但在逻辑树中从 0 开始。

演示不同的绑定模式

此示例位于NP.Demos.BindingModesSample.sln 解决方案下。此示例的所有代码均位于MainWindow.axaml 文件中。

运行示例,您将看到以下内容

前三个 TextBoxes 绑定到 Window 的同一个 Tag 属性 - 第一个使用 TwoWay 模式,第二个 - OneWay,第三个 - OneTime。尝试在顶部的 TextBox 中键入。然后,第二个 TextBox 将更新,但第三个不会。

这可以理解,因为顶部的 TextBoxWindow 的 tag 具有双向绑定 - 当您修改其文本时,Window 的 tag 也会更新,并且与同一 tag 的单向绑定将更新第二个 TextBox

如果您尝试修改第二个 TextBox 中的文本,将不会发生任何事情,因为它具有单向绑定 - 从 WindowTagTextBox.Text 绑定。当然,当有人修改第 3 个 TextBox 中的文本时,也不会发生任何事情。

以下是顶部三个文本框的相关代码(第四个比较特殊,我稍后会解释原因)。

<Window Tag="Hello World!"
        ...>
    ...
    <TextBox ...
             Text="{Binding $parent[Window].Tag, Mode=TwoWay}"/>
    <TextBox ...
             Text="{Binding $parent[Window].Tag, Mode=OneWay}"/>
    <TextBox ...
             Text="{Binding $parent[Window].Tag, Mode=OneTime}"/>
    ...
</Window>

第四个 TextBox 演示了 OneWayToSource 模式。请注意,最初,它不显示任何内容。如果您开始键入,您会看到下面的文本也出现了。

以下是第四个 TextBox 的相关代码。

<Grid ...
      Tag="This is a OneWayToSource Grid Tag">
  ...
  <TextBox Text="{Binding $parent[Grid].Tag, Mode=OneWayToSource}"
           .../>
  <TextBlock Text="{Binding $parent[Grid].Tag, Mode=OneWay}"
             .../>
</Grid>  

TextBoxTextBlock 都绑定到 Grid panel 上的 Tag

请注意,Tag 最初包含一些文本:“This is a OneWayToSource Grid Tag”。然而,TextBoxTextBlock 最初都是空的。这是因为 OneWayToSource 绑定删除了 tag 的初始值(TextBox 最初没有任何文本,因此由于绑定覆盖了 Tag 的初始值)。

这就是为什么我从未使用 Window 的 Tag 作为第四个 TextBox - 它会破坏前面三个 TextBoxes 的初始值。

这也是我很少使用 OneWayToSource 绑定的原因 - 如果它将初始值从 Source 分配给 Target,然后再从 TargetSource 工作,那将更有用。

绑定转换器

打开NP.Demos.BindingConvertersSample.sln 解决方案。运行它您将看到以下内容

尝试从顶部的 TextBox 中删除文本。绿色文本将消失,红色文本将出现。

此外,无论您在顶部或底部 TextBox 中输入什么,都会在另一个 TextBox 中出现相同字符但反向排列(从右到左)。

以下是相关代码

<Grid ...>
    ...
  <TextBox  x:Name="TheTextBox" 
            Text="Hello World!"
            .../>
  <TextBlock Text="This text shows when the text in the TextBox is empty"
             IsVisible="{Binding #TheTextBox.Text, 
             Converter={x:Static StringConverters.IsNullOrEmpty}}"
             Foreground="Red"
             .../>
  <TextBlock Text="This text shows when the text in the TextBox is NOT empty"
             IsVisible="{Binding #TheTextBox.Text, 
             Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
             Foreground="Green"
             .../>
  <TextBox  Grid.Row="4"
            Text="{Binding #TheTextBox.Text, Mode=TwoWay, 
            Converter={StaticResource TheReverseConverter}}"
            ...>
</Grid>

对于两个 TextBlock,我使用了 Avalonia 内置的转换器 - IsNullOrEmptyIsNotNullOrEmpty。它们定义在 StringConverters 类中作为静态属性,该类是默认 Avalonia 命名空间的一部分。这就是为什么不需要命名空间前缀,以及为什么我使用 x:Static 标记扩展来查找它们,例如 Converter={x:Static StringConverters.IsNullOrEmpty}

底部的 TextBox 使用项目中定义的 ReverseStringConverter

public class ReverseStringConverter : IValueConverter
{
    private static string? ReverseStr(object value)
    {
        if (value is string str)
        {
            return new string(str.Reverse().ToArray());
        }

        return null;
    }

    public object? Convert
    (object value, Type targetType, object parameter, CultureInfo culture)
    {
        return ReverseStr(value);
    }

    public object? ConvertBack
    (object value, Type targetType, object parameter, CultureInfo culture)
    {
        return ReverseStr(value);
    }
}  

请注意,转换器实现了 IValueConverter 接口。它通过 Convert(...)ConvertBack(...) 方法分别定义了前向和后向转换。底部的 TextBoxes 绑定当然是 TwoWay,因此无论哪个 TextBox 发生更改,另一个也会发生更改。

多值绑定示例

下一个示例演示如何将绑定目标连接到多个源。代码位于NP.Demos.MultiBindingSample.sln 解决方案下。

运行示例,您将看到以下内容

尝试在任何一个 TextBox 中键入 smth。它们的连接字符串将继续显示在底部。

以下是执行此操作的相关代码

<Grid RowDefinitions="Auto,Auto,Auto"
      <TextBox x:Name="Str1"
               Text="Hi"
               .../>
      <TextBox x:Name="Str2" 
               Text="Hello"
               .../>
      <TextBlock ...>
          <TextBlock.Text>
              <MultiBinding Converter="{x:Static local:ConcatenationConverter.Instance}">
                  <Binding Path="#Str1.Text"/>
                  <Binding Path="#Str2.Text"/>
              </MultiBinding>
          </TextBlock.Text>
      </TextBlock>
</Grid>  

MultiBinding 包含两个单值绑定到各个文本框。

  <Binding Path="#Str1.Text"/>
  <Binding Path="#Str2.Text"/>  

它们的值由 MultiValue 转换器(Converter="{x:Static local:ConcatenationConverter.Instance}")转换为它们的连接。

MultiValue 转换器定义在示例项目中的 ConcatenationConverter 类中。

public class ConcatenationConverter : IMultiValueConverter
{
    // static instance to reference
    public static ConcatenationConverter Instance { get; } =
        new ConcatenationConverter();

    public object? Convert(IList<object> values, 
           Type targetType, object parameter, CultureInfo culture)
    {
        if (values == null || values.Count == 0)
        {
            return null;
        }

        return 
            string.Join("", values.Select(v => v?.ToString()).Where(v => v != null));
    }
}  

该类实现了 IMultiValueConverter 接口(而不是用于单值绑定的 IValueConverter)。

IMultiValueConverter 只有一个方法 - Convert(...) 用于前向转换,并且它接受的第一个参数是 IList<object>,其中包含每个源值的条目。

为了避免通过创建 XAML 资源来污染 XAML 代码,我创建了一个名为 Instance静态属性,它引用了同一类的全局实例,并且可以通过 x:Static 标记扩展轻松地从 XAML 访问:Converter="{x:Static local:ConcatenationConverter.Instance}"

在 C# 代码中创建绑定

下一个示例位于NP.Demos.BindingInCode.sln 解决方案下。运行它您将看到以下内容

尝试更改 TextBox 中的文本 - 直到按下“Bind”按钮之前,其他任何操作都不会发生。按下它后,文本将出现在 TextBox 下方,模仿其中的文本。

当您按下“Unbind”按钮时,下面的文本将再次停止响应修改。

此功能主要通过MainWindow.axaml.cs 中的代码实现。XAML 代码仅定义和定位 TextBox 和其下的 TextBlock 以及两个按钮:BindButtonUnbindButton

...
<StackPanel ...>
    <TextBox x:Name="TheTextBox"
             Text="Hello World"/>
    <TextBlock x:Name="TheTextBlock"
               HorizontalAlignment="Left"/>
</StackPanel>
...
<StackPanel ...>
    <Button x:Name="BindButton" 
            Content="Bind"/>

    <Button x:Name="UnbindButton"
            Content="Unbind"/>
</StackPanel>
...  

这是相关的 C# 代码。

public partial class MainWindow : Window
{
    TextBox _textBox;
    TextBlock _textBlock;
    public MainWindow()
    {
        InitializeComponent();
        ...
        _textBox = this.FindControl<TextBox>("TheTextBox");
        _textBlock = this.FindControl<TextBlock>("TheTextBlock");

        Button bindButton = this.FindControl<Button>("BindButton");
        bindButton.Click += BindButton_Click;

        Button unbindButton = this.FindControl<Button>("UnbindButton");
        unbindButton.Click += UnbindButton_Click;
    }

    IDisposable? _bindingSubscription;
    private void BindButton_Click(object? sender, RoutedEventArgs e)
    {
        if (_bindingSubscription == null)
        {
            _bindingSubscription =
                _textBlock.Bind(TextBlock.TextProperty, 
                                new Binding { Source = _textBox, Path = "Text" });

            // The following line will also do the trick, but you won't be able to unbind.
            //_textBlock[!TextBlock.TextProperty] = _textBox[!TextBox.TextProperty];
        }
    }

    private void UnbindButton_Click(object? sender, RoutedEventArgs e)
    {
        _bindingSubscription?.Dispose();
        _bindingSubscription = null;
    }

    private void InitializeComponent()
    {
        AvaloniaXamlLoader.Load(this);
    }
}

绑定是通过调用 TextBlock 上的 Bind 方法实现的。

 _bindingSubscription =
   _textBlock.Bind(TextBlock.TextProperty, new Binding { Source = _textBox, Path = "Text" });  

它返回一个存储在 _bindingSubscription 字段中的可处置对象。

为了销毁绑定 - 必须处置此对象:_bindingSubscription.Dispose()

令人惊讶的是(至少对于非常真正地属于您的人来说),以下 C# 代码也将建立相同的绑定。

_textBlock[!TextBlock.TextProperty] = _textBox[!TextBox.TextProperty];  

只是这种绑定将无法销毁(或者至少不像 Bind(...) 方法返回的绑定那样容易销毁)。

经过一番研究,我明白了它是如何工作的:感叹号 (!) 运算符将 AvaloniaProperty 对象转换为 IndexerDescriptor 类型对象。该对象可以传递给 AvaloniaObject[] 运算符,以返回 IBinding 类型的对象。然后,对另一个 AvaloniaObject 上的 IndexerDescriptor 单元格进行赋值将调用 Bind(...) 方法并创建绑定。

绑定到非可视化类的属性

之前,我们展示了在可视化对象上绑定两个(源和目标)属性的不同方法。然而,绑定的源不必定义在可视化对象上。事实上,正如我们之前在非常重要且流行的 MVVM 模式下提到的,复杂的视觉对象被用来模仿简单的非视觉对象——所谓的视图模型——的行为。

在本小节中,我们将展示如何在非可视化类中创建可绑定属性并将我们的可视化对象绑定到它们。

项目位于NP.Demos.BindingToNonVisualSample.sln。运行它您将看到以下内容

中间有一个名字列表。名字的数量显示在左下角,右下角有一个删除最后一个名字的按钮。

单击按钮删除列表中的最后一个项目。您会看到列表和项目数量都会更新。当您删除列表中的所有项目时,“项目数量”将变为“0”,按钮将变为禁用。

此示例的自定义代码位于三个文件中:ViewModel.csMainWindow.axamlMainWindow.axaml.csViewModel 是一个非常简单的纯非可视化类。以下是其代码。

public class ViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;
    protected void OnPropertyChanged(string propName)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propName));
    }

    // collection of names
    public ObservableCollection<string> Names { get; } = new ObservableCollection<string>();

    // number of names
    public int NamesCount => Names.Count;

    // true if there are some names in the collection,
    // false otherwise
    public bool HasItems => NamesCount > 0;

    public ViewModel()
    {
        Names.CollectionChanged += Names_CollectionChanged;
    }

    // fire then notifications every time Names collection changes.
    private void Names_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
    {
        // Change Notification for Avalonia for properties
        // NamesCount and HasItems
        OnPropertyChanged(nameof(NamesCount));
        OnPropertyChanged(nameof(HasItems));
    }
}  

请注意,Names 集合的类型是 ObservableCollection<string>。这确保了绑定到 Names 集合的可视化集合能够在向非可视化 Names 集合添加或删除项目时更新自身。

另请注意,每次 Names 集合更改时,我们都会触发 PropertyChanged 事件,并将 nameof(NamesCount)nameof(HasItems) 作为参数传递。这将通知这些属性的绑定,它们需要更新目标。

现在看看MainWindow.axaml

<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:NP.Demos.BindingToNonVisualSample"
        ...>
  <!-- Define the DataContext of the Window-->
  <Window.DataContext>
    <local:ViewModel>
      <local:ViewModel.Names>
        <x:String>Tom</x:String>
        <x:String>Jack</x:String>
        <x:String>Harry</x:String>
      </local:ViewModel.Names>
    </local:ViewModel>
  </Window.DataContext>
  <Grid ...>

    <!-- Binding the Items of ItemsControl to the Names collection -->
    <ItemsControl Items="{Binding Path=Names}"
                  .../>

    <Grid Grid.Row="1">

      <!-- Binding Text to NamesCount -->
      <TextBlock Text="{Binding Path=NamesCount, StringFormat='Number of Items: {0}'}"
                 HorizontalAlignment="Left"
                 VerticalAlignment="Center"/>

      <!-- Binding Button.IsEnabled to HasItems -->
      <Button x:Name="RemoveLastItemButton"
              Content="Remove Last Item"
              IsEnabled="{Binding Path=HasItems}"
              .../>
    </Grid>
  </Grid>
</Window>

窗口 DataContext 直接设置为包含 ViewModel 类型的对象,其 Names 集合填充为 TopJackHarry。由于 DataContext 会向下传播到可视化树,因此MainWindow.axaml 文件中的其余元素将具有相同的 DataContext

ItemControlItems 属性绑定到 ViewModel 对象的 Names 集合:<ItemsControl Items="{Binding Path=Names}"。请注意,在 WPF 中,ItemsControl 将使用 ItemsSource 属性。

TextBlockText 属性绑定到 ViewModelNamesCount 属性:<TextBlock Text="{Binding Path=NamesCount, StringFormat='Number of Items: {0}'}"。请注意绑定中使用 StringFormat - 它允许在绑定值周围添加一些字符串

最后,ButtonIsEnabled 属性绑定到 ViewModel 上的 HasItems 属性,因此当项目数量变为“0”时,按钮将变为禁用。

最后,MainWindow.xaml.cs 文件只是包含设置事件处理程序,以便在每次单击按钮时从 Names 集合中删除最后一个项目。

public MainWindow()
{
    InitializeComponent();

    ...

    Button removeLastItemButton =
        this.FindControl<Button>("RemoveLastItemButton");

    removeLastItemButton.Click += RemoveLastItemButton_Click;
}

private void RemoveLastItemButton_Click(object? sender, RoutedEventArgs e)
{
    ViewModel viewModel = (ViewModel)this.DataContext!;

    viewModel.Names.RemoveAt(viewModel.Names.Count - 1);
}  

结论

本文致力于介绍 Avalonia 最重要的概念,其中许多概念源自 WPF,但在 Avalonia 中得到了扩展,变得更好、更强大。

那些想要正确理解和使用 Avalonia 的人应该阅读、学习并理解这些概念。

我计划撰写另一篇文章或几篇文章来解释更高级的 Avalonia 概念,特别是:

  1. 路由事件
  2. Commands
  3. 控件模板(基础)
  4. MVVM 模式、数据模板、ItemsPresenter 和 ContentPresenter
  5. 从 XAML 调用 C# 方法
  6. XAML - 通过标记扩展重用 Avalonia XAML
  7. 样式、过渡、动画

历史

  • 2021 年 9 月 3 日:初始版本
  • 2023 年 12 月 22 日:将文章和代码示例更新为 Avalonia 11 - Avalonia 的最新版本。
© . All rights reserved.