多平台 Avalonia .NET 框架编程高级概念轻松示例





5.00/5 (23投票s)
本文涵盖了编程和软件设计所需的 Avalonia/WPF 重要概念
引言
注意,本文已更新以反映 Avalonia 11 的变化,并且此处提及的示例已更新为在 Avalonia 11 下运行。
本文可视为以下系列文章的第四部分
- 使用 AvaloniaUI 在简易示例中进行多平台 UI 编码。第一部分 - AvaloniaUI 构建块
- 多平台 Avalonia .NET 框架 XAML 基础知识轻松示例
- 使用简单的示例学习 Avalonia .NET 框架跨平台编程基础概念
有关在 Avalonia 11 中创建项目和一般更改的通用信息,请参阅上述系列的第一篇文章以及 多平台 XAML/C# 奇迹包:Avalonia。将 Avalonia 与基于 WinUI 的解决方案进行比较。
如果您了解 WPF,则无需阅读前面的文章即可阅读本文;否则,您应首先阅读前面的文章。
关于 Avalonia
Avalonia 是一个新的开源包,与 WPF 非常相似,但与 WPF 或 UWP 不同,它可以在大多数平台上运行——Windows、MacOS 和各种版本的 Linux,并且在许多方面比 WPF 更强大。
为什么 Avalonia 比 Web 编程框架或 Xamarin 更好的原因在上一篇文章中详细描述:使用 Avalonia 进行多平台 UI 编码轻松示例。第 1 部分 - Avalonia 构建块。在这里,我只重申两个主要原因
- 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 更强大的方式实现。
本文目的
本文的目的是继续使用简单的编码示例解释 Avalonia 高级概念。
本文组织
将涵盖以下主题
- 路由事件
- Avalonia 命令
- Avalonia 用户控件
- Avalonia 控件模板和自定义控件
- 数据模板和视图模型
示例代码
示例代码位于 Avalonia 高级概念文章的演示代码。此处的所有示例均已在 Windows 10、MacOS Catalina 和 Ubuntu 20.4 上进行测试
所有代码都应该在 Visual Studio 2019 下编译和运行——这就是我一直在使用的。此外,请确保在首次编译示例时您的互联网连接处于打开状态,因为需要下载一些 nuget 包。
概念解释
路由事件
路由事件概念
与 WPF 相同,Avalonia 具有附加路由事件的概念,这些事件在可视化树中上下传播。它们比 WPF 路由事件更强大,更容易处理(如下所述)。
与普通的 C# 事件不同,它们
- 可以在触发它们的类之外定义并“附加”到对象。
- 可以在 WPF 可视化树中上下传播——这意味着事件可以由一个树节点触发,并在另一个树节点(触发节点的祖先之一)上处理。
路由事件有三种不同的传播模式
- 直接——这意味着事件只能在触发它的同一可视化树节点上处理。
- 冒泡——事件从当前节点(触发事件的节点)传播到可视化树的根,并可以在途中任何地方处理。例如,如果可视化树由一个包含
Grid
的Window
组成,该Grid
包含一个Button
,并且在Button
上触发了一个冒泡事件,那么该事件将从Button
传播到Grid
,然后传播到Window
。 - 隧道——事件从可视化树的根节点传播到当前节点(触发事件的节点)。使用与上面相同的示例,
隧道
事件将首先在 Window 上触发,然后在Grid
上触发,最后在button
上触发。
以下图片描绘了冒泡和隧道事件传播
Avalonia 路由事件比 WPF 路由事件更强大、更合乎逻辑,因为在 WPF 中,事件只能选择一种路由策略——它可以是直接、冒泡或隧道。为了在处理主要(通常是冒泡)事件之前进行一些预处理,许多冒泡事件都有它们的隧道对等事件在它们之前触发——即所谓的预览事件。预览事件在 WPF 中是完全不同的事件,它们与相应的冒泡事件之间没有逻辑联系(除了它们的名称)。
在 Avalonia 中,同一事件可以注册为具有多种路由策略——所谓的预览事件不再必要——因为同一事件可以首先作为隧道事件(用于预览)触发,然后作为冒泡事件触发——做实际的事情。这也可能导致错误,例如,如果您在隧道和冒泡状态下以相同的方式处理事件——事件可能会被处理两次而不是一次。在事件处理程序订阅期间进行简单的过滤或在事件处理程序中进行简单的检查将解决此问题。
如果您不是 WPF 专家,并且对路由事件有点困惑,请不要担心——将有示例来说明上述内容。
内置路由事件示例
Avalonia 中已经存在许多路由事件(就像 WPF 中有许多内置事件一样)。我们将使用 PointerPressedEvent
路由事件演示路由事件传播,该事件在用户在 Avalonia 中的某个可视化元素上按下鼠标按钮时触发。WPF LeftMouseButtonDown
路由事件与 PointerPressedEvent
非常相似。
示例代码位于 NP.Demos.BuiltInRoutedEventSample 解决方案下。
看看非常简单的 MainWindow.axaml 文件
<Window x:Name="TheWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="NP.Demos.BuiltInRoutedEventSample.MainWindow"
Title="NP.Demos.BuiltInRoutedEventSample"
Background="Red"
Width="200"
Height="200">
<Grid x:Name="TheRootPanel"
Background="Green"
Margin="35">
<Border x:Name="TheBorder"
Background="Blue"
Margin="35"/>
</Grid>
</Window>
我们有一个 Window
(红色背景),其中包含一个绿色背景的 Grid
,其中包含一个蓝色背景的 Border
。
在 Visual Studio 调试器中运行项目——您将看到以下内容
单击中间的蓝色方块,然后查看 Visual Studio 的“输出”窗格。您将看到以下内容
Tunneling Routed Event PointerPressed raised on TheWindow; Event Source is TheBorder
Tunneling Routed Event PointerPressed raised on TheRootPanel; Event Source is TheBorder
Tunneling Routed Event PointerPressed raised on TheBorder; Event Source is TheBorder
Bubbling Routed Event PointerPressed raised on TheBorder; Event Source is TheBorder
Bubbling Routed Event PointerPressed raised on TheRootPanel; Event Source is TheBorder
Bubbling Routed Event PointerPressed raised on TheWindow; Event Source is TheBorder
事件首先作为隧道事件从窗口传播到蓝色边框,然后作为冒泡事件沿相反方向传播。
现在看看 MainWindow.axaml.cs 文件,其中包含处理事件和分配处理程序的所有代码
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
...
// add event handler for the Window
this.AddHandler
(
Control.PointerPressedEvent,
HandleClickEvent,
RoutingStrategies.Bubble | RoutingStrategies.Tunnel
//,true // uncomment if you want to test that the event still propagates event
// after being handled
);
Grid rootPanel = this.FindControl<Grid>("TheRootPanel");
// add event handler for the Grid
rootPanel.AddHandler
(
Control.PointerPressedEvent,
HandleClickEvent,
RoutingStrategies.Bubble | RoutingStrategies.Tunnel);
Border border = this.FindControl<Border>("TheBorder");
// add event handler for the Blue Border in the middle
border.AddHandler(
Control.PointerPressedEvent,
HandleClickEvent,
RoutingStrategies.Bubble | RoutingStrategies.Tunnel);
}
private void HandleClickEvent(object? sender, RoutedEventArgs e)
{
Control senderControl = (Control) sender!;
string eventType = e.Route switch
{
RoutingStrategies.Bubble => "Bubbling",
RoutingStrategies.Tunnel => "Tunneling",
_ => "Direct"
};
Debug.WriteLine($"{eventType} Routed Event {e.RoutedEvent!.Name}
raised on {senderControl.Name}; Event Source is {(e.Source as Control)!.Name}");
// uncomment if you want to test handling the event
//if (e.Route == RoutingStrategies.Bubble && senderControl.Name == "TheBorder")
//{
// e.Handled = true;
//}
}
}
我们使用 AddHandler
方法将处理程序分配给 Window
、Grid
和 Border
。让我们仔细看看其中一个
// add event handler for the Window
this.AddHandler
(
Control.PointerPressedEvent, // routed event
HandleClickEvent, // event handler
RoutingStrategies.Bubble | RoutingStrategies.Tunnel // routing strategy filter
);
AddHandler
的第一个参数是 RoutedEvent
——一个包含视觉对象到事件处理程序映射的 static
对象。这类似于 AttachedProperty
对象维护视觉对象到对象值的映射。与 AttachedProperty
相同,RoutedEvent
可以在类之外定义,并且除了具有其处理程序的对象之外,不会影响内存。
第二个参数是事件处理方法 HandleClickEvent
。这是方法的实现
private void HandleClickEvent(object? sender, RoutedEventArgs e)
{
Control senderControl = (Control) sender!;
string eventTypeString = e.Route switch
{
RoutingStrategies.Bubble => "Bubbling",
RoutingStrategies.Tunnel => "Tunneling",
_ => "Direct"
};
Debug.WriteLine($"{eventTypeStr} Routed Event {e.RoutedEvent!.Name}
raised on {senderControl.Name}; Event Source is {(e.Source as Control)!.Name}");
...
}
它所做的只是将句子写入 Debug
输出(对于 Visual Studio 调试器,这意味着它将其写入输出窗格)。
第三个参数 (RoutingStrategies.Bubble | RoutingStrategies.Tunnel
) 是路由策略过滤器。例如,如果您从中删除 RoutingStrategies.Tunnel
,它将开始仅对冒泡事件运行作出反应(尝试将其作为练习)。默认情况下,它设置为 RoutingStrategies.Direct | RoutingStrategies.Bubble
。
请注意,所有(或几乎所有)内置路由事件都有其对应的普通 C# 事件,当路由事件触发时,这些 C# 事件也会触发。我们可以使用例如 PointerPressed
C# 事件将其连接到 HandleClickEvent
处理程序
rootPanel.PointerPressed += HandleClickEvent;
但在这种情况下,我们将无法选择 RoutingStrategies
过滤(它将保持默认值 - RoutingStrategies.Direct | RoutingStrategies.Bubble
)。此外,我们将无法选择一个重要的 handledEventsToo
参数,该参数将很快解释。
在 HandleClickEvent
方法的末尾,有几行额外注释掉的代码,您现在应该取消注释
// uncomment if you want to test handling the event
if (e.Route == RoutingStrategies.Bubble && senderControl.Name == "TheBorder")
{
e.Handled = true;
}
此代码的目的是在事件首次在边框上完成所有隧道和冒泡后,将其设置为 Handled
。尝试再次运行应用程序并单击蓝色边框。Visual Studio 的输出窗格中将打印以下内容
Tunneling Routed Event PointerPressed raised on TheWindow; Event Source is TheBorder
Tunneling Routed Event PointerPressed raised on TheRootPanel; Event Source is TheBorder
Tunneling Routed Event PointerPressed raised on TheBorder; Event Source is TheBorder
Bubbling Routed Event PointerPressed raised on TheBorder; Event Source is TheBorder
由于事件在边框上的第一次冒泡之后已经处理,因此可视化树中更高级别的处理程序(网格和窗口上的处理程序)将不再触发。但是,有一种方法可以强制它们在已经处理的路由事件上触发。例如,要在窗口级别执行此操作,请取消注释窗口上 AddHandler(...)
调用的最后一个参数
// add event handler for the Window
this.AddHandler
(
Control.PointerPressedEvent, //routed event
HandleClickEvent, // event handler
RoutingStrategies.Bubble | RoutingStrategies.Tunnel // routing strategy filter
,true // uncomment if you want to test that the event still propagates event
// after being handled
);
最后一个参数称为 handledEventsToo
,如果它为 true
,则它也会在之前已处理的事件上触发相应的处理程序。默认情况下,它为 false
。
取消注释后,再次运行应用程序并在蓝色边框上按下鼠标按钮。输出将是
Tunneling Routed Event PointerPressed raised on TheWindow; Event Source is TheBorder
Tunneling Routed Event PointerPressed raised on TheRootPanel; Event Source is TheBorder
Tunneling Routed Event PointerPressed raised on TheBorder; Event Source is TheBorder
Bubbling Routed Event PointerPressed raised on TheBorder; Event Source is TheBorder
Bubbling Routed Event PointerPressed raised on TheWindow; Event Source is TheBorder
最后一行显示事件的冒泡过程在窗口上被触发(也处理了),即使事件之前已被标记为已处理。
现在通过鼠标单击示例窗口并按 F12 启动 Avalonia 开发工具。单击“事件”选项卡,在左侧窗格中显示的所有事件中,选择 PointerPressed
进行勾选,并取消勾选其余事件
之后,在应用程序中按下蓝色边框,事件的条目将显示主窗口
现在鼠标单击主窗口中的事件条目——事件链窗格将显示事件如何在视觉树上传播
不幸的是,目前该工具的事件链仅显示未处理事件的传播。它在事件未处理的最后一点停止显示——在我们的例子中,是冒泡过程的第一项。您可以看到该工具中显示的事件隧道实例比我们以前的打印中显示的更多。这是因为该工具显示了事件正在其上触发的视觉树中的所有元素,而我们只将处理程序连接到 Window
、Grid
和 Border
。
自定义路由事件示例
此示例位于 NP.Demos.CustomRoutedEventSample 解决方案中。它与上一个示例非常相似,只是在这里我们触发了在 StaticRoutedEvents.cs 文件中定义的自定义路由事件 MyCustomRoutedEvent
using Avalonia.Interactivity;
namespace NP.Demos.CustomRoutedEventSample
{
public static class StaticRoutedEvents
{
/// <summary>
/// create the MyCustomRoutedEvent
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> MyCustomRoutedEvent =
RoutedEvent.Register<object, RoutedEventArgs>
(
"MyCustomRouted",
RoutingStrategies.Tunnel //| RoutingStrategies.Bubble
);
}
}
如您所见,定义事件非常简单——只需调用 RoutedEvent.Register(...)
方法,传入事件名称和路由策略。
MainWindow.axaml 文件与上一节中的完全相同。MainWindow.axaml.cs 代码也与上一节中的代码非常相似,只是在这里我们处理 MyCustomRoutedEvent
,例如
// add event handler for the Window
this.AddHandler
(
StaticRoutedEvents.MyCustomRoutedEvent, //routed event
HandleCustomEvent, // event handler
RoutingStrategies.Bubble | RoutingStrategies.Tunnel // routing strategy filter
);
我们还添加了一些代码,以便在鼠标按下蓝色边框时触发 MyCustomRoutedEvent
// we add the handler to pointer pressed event in order
// to raise MyCustomRoutedEvent from it.
border.PointerPressed += Border_PointerPressed;
}
/// PointerPressed handler that raises MyCustomRoutedEvent
private void Border_PointerPressed(object? sender, PointerPressedEventArgs e)
{
Control control = (Control)sender!;
// Raising MyCustomRoutedEvent
control.RaiseEvent(new RoutedEventArgs(StaticRoutedEvents.MyCustomRoutedEvent));
}
触发事件的代码行具体是
// Raising MyCustomRoutedEvent
control.RaiseEvent(new RoutedEventArgs(StaticRoutedEvents.MyCustomRoutedEvent));
这是 MainWindow.axaml.cs 文件中(几乎)完整的代码隐藏
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
...
// add event handler for the Window
this.AddHandler
(
StaticRoutedEvents.MyCustomRoutedEvent, //routed event
HandleClickEvent, // event handler
RoutingStrategies.Bubble | RoutingStrategies.Tunnel // routing strategy filter
);
Grid rootPanel = this.FindControl<Grid>("TheRootPanel");
// add event handler for the Grid
rootPanel.AddHandler
(
StaticRoutedEvents.MyCustomRoutedEvent,
HandleCustomEvent,
RoutingStrategies.Bubble | RoutingStrategies.Tunnel);
Border border = this.FindControl<Border>("TheBorder");
// add event handler for the Blue Border in the middle
border.AddHandler(
StaticRoutedEvents.MyCustomRoutedEvent,
HandleCustomEvent,
RoutingStrategies.Bubble | RoutingStrategies.Tunnel
);
// we add the handler to pointer pressed event in order
// to raise MyCustomRoutedEvent from it.
border.PointerPressed += Border_PointerPressed;
}
/// PointerPressed handler that raises MyCustomRoutedEvent
private void Border_PointerPressed(object? sender, PointerPressedEventArgs e)
{
Control control = (Control)sender!;
// Raising MyCustomRoutedEvent
control.RaiseEvent(new RoutedEventArgs(StaticRoutedEvents.MyCustomRoutedEvent));
}
private void HandleCustomEvent(object? sender, RoutedEventArgs e)
{
Control senderControl = (Control) sender!;
string eventTypeStr = e.Route switch
{
RoutingStrategies.Bubble => "Bubbling",
RoutingStrategies.Tunnel => "Tunneling",
_ => "Direct"
};
Debug.WriteLine($"{eventTypeStr} Routed Event
{e.RoutedEvent!.Name} raised on {senderControl.Name};
Event Source is {(e.Source as Control)!.Name}");
}
...
}
当我们运行项目并点击中间的蓝色方块时,Visual Studio 输出窗格中将打印以下内容
Tunneling Routed Event MyCustomRouted raised on TheWindow; Event Source is TheBorder
Tunneling Routed Event MyCustomRouted raised on TheRootPanel; Event Source is TheBorder
Tunneling Routed Event MyCustomRouted raised on TheBorder; Event Source is TheBorder
请注意,只处理了隧道传递。这是因为我们通过将其最后一个参数传递给 RoutingStrategies.Tunnel
,将事件定义为纯隧道事件。如果将其更改为 RoutingStrategies.Tunnel | RoutingStrategies.Bubble
,并再次重新启动解决方案,我们将看到隧道和冒泡传递
Tunneling Routed Event MyCustomRouted raised on TheWindow; Event Source is TheBorder
Tunneling Routed Event MyCustomRouted raised on TheRootPanel; Event Source is TheBorder
Tunneling Routed Event MyCustomRouted raised on TheBorder; Event Source is TheBorder
Bubbling Routed Event MyCustomRouted raised on TheBorder; Event Source is TheBorder
Bubbling Routed Event MyCustomRouted raised on TheRootPanel; Event Source is TheBorder
Bubbling Routed Event MyCustomRouted raised on TheWindow; Event Source is TheBorder
Avalonia 命令
命令概念
当有人构建应用程序时,通常将控制视觉的逻辑放置在一些非视觉类(称为视图模型)中,然后使用绑定和其他方式将 XAML 中的视觉连接到视图模型。其背后的想法是,非视觉对象比视觉对象更简单、更容易测试,因此如果您主要处理非视觉对象,您的编码和测试将更容易。这种模式称为 MVVM。
命令提供了一种在单击 Button
或 MenuItem
时执行视图模型中某些 C# 方法的方法。
Avalonia Button
和 MenuItem
各自都有一个 Command
属性,可以绑定到视图模型中定义的 Command
。这样的命令可以执行与其挂钩的视图模型方法。Avalonia 没有自己的命令实现,但建议使用 ReactiveUI 的 ReactiveCommand
。还可以通过放置在视图模型中的命令对象来控制 Button
(或 MenuItem
)是否启用。
然而,这种将命令放在视图模型中的方法存在主要缺点
- 它强制视图模型依赖于视觉 .NET 程序集(实现命令的程序集)。这打破了非视觉视图模型和视觉之间应该存在的硬障碍。之后,控制(尤其是在有许多开发人员的项目中)视觉代码不会泄漏到视图模型中变得更加困难。
- 它不必要地污染了视图模型。
因此,Avalonia 提供了一种更简洁的方法来调用视图模型上的方法——通过将命令绑定到方法的名称。
使用 Avalonia 命令调用视图模型上的方法
运行此示例,该示例位于 NP.Demos.CommandSample 解决方案下。您将看到以下内容
窗口中间显示一个 Status
字段值。当您按下“Toggle Status”按钮时,它将在 True
和 False
之间切换。
注意 Avalonia 11 - 更改
在以前的 Avalonia 版本中,“Set Status to True”按钮是启用的,点击它会将状态值设置为 True
,取消选中“Can Toggle Status”复选框将禁用“Toggle Status”按钮。
然而,最新版本的 Avalonia (11) 只会绑定到不带参数的方法或带有一个 object
类型参数的方法。由于我们的方法 SetStatus(bool status)
带有一个 bool
类型而不是 object
类型的参数,因此绑定到它的按钮将被禁用。这顺便说一下是到目前为止我在 Avalonia 11 中发现的与 Avalonia 10 相比唯一的特性回归,并且通过使用行为或 DelegateCommands
可以很容易地解决这个问题。
看看名为 ViewModel.cs 的文件。它只包含非可视化代码
public class ViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>
/// fires INotifyPropertyChanged.PropertyChanged event
/// </summary>
private void OnPropertyChanged(string propName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propName));
}
#region Status Property
private bool _status;
/// <summary>
/// Status notifiable property
/// </summary>
public bool Status
{
get
{
return this._status;
}
set
{
if (this._status == value)
{
return;
}
this._status = value;
this.OnPropertyChanged(nameof(Status));
}
}
#endregion Status Property
#region CanToggleStatus Property
private bool _canToggleStatus = true;
/// <summary>
/// Controls whether Toggle Status button is enabled or not
/// </summary>
public bool CanToggleStatus
{
get
{
return this._canToggleStatus;
}
set
{
if (this._canToggleStatus == value)
{
return;
}
this._canToggleStatus = value;
this.OnPropertyChanged(nameof(CanToggleStatus));
}
}
#endregion CanToggleStatus Property
/// <summary>
/// Toggles the status
/// </summary>
public void ToggleStatus()
{
Status = !Status;
}
/// <summary>
/// Set the Status to whatever 'status' is passed
/// </summary>
public void SetStatus(bool status)
{
Status = status;
}
}
它提供
- 一个布尔属性
Status
ToggleStatus()
方法,用于切换Status
属性SetStatus(bool status)
方法,将Status
属性设置为传入的任何参数CanToggleStatus
属性,控制ToggleStatus()
操作是否启用。
每当任何属性更改时,都会触发 PropertyChanged
事件,以便 Avalonia 绑定将收到属性更改的通知。
位于 MainWindow.axaml.cs 文件中的 MainWindow
构造函数将 Window
的 DataContext
设置为我们的 ViewModel
类的一个实例。
public MainWindow()
{
InitializeComponent();
...
this.DataContext = new ViewModel();
}
DataContext
是一个特殊的 StyledProperty
,它会被视觉树的后代继承(除非明确更改),因此它对于窗口的后代也将是相同的。
这是 MainWindow.axaml 文件的内容
<Window x:Name="TheWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="NP.Demos.CommandSample.MainWindow"
Title="NP.Demos.CommandSample"
Width="200"
Height="300">
<Grid x:Name="TheRootPanel"
RowDefinitions="*, *, *, *"
Margin="20">
<CheckBox IsChecked="{Binding Path=CanToggleStatus, Mode=TwoWay}"
Content="Can Toggle Status"
HorizontalAlignment="Left"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding Path=Status, StringFormat='Status={0}'}"
Grid.Row="1"
HorizontalAlignment="Left"
VerticalAlignment="Center"/>
<Button Content="Toggle Status"
Grid.Row="2"
HorizontalAlignment="Right"
VerticalAlignment="Center"
IsEnabled="{Binding Path=CanToggleStatus}"
Command="{Binding Path=ToggleStatus}"/>
<Button Content="Set Status to True"
Grid.Row="3"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Command="{Binding Path=SetStatus}"
CommandParameter="True"/>
</Grid>
</Window>
顶部的 Checkbox
的 IsChecked
属性与 ViewModel
的 CanToggleStatus
进行双向绑定
<CheckBox IsChecked="{Binding Path=CanToggleStatus, Mode=TwoWay}"
Content="Can Toggle Status"
.../>
以便当它改变时,相应的属性也会改变。
TextBlock
显示状态(true
或 false
)
<TextBlock Text="{Binding Path=Status, StringFormat='Status={0}'}"
... />
顶部按钮(通过其命令调用 ViewModel
上的 ToggleStatus()
方法,其 IsEnabled
属性绑定到 ViewModel
上的 CanToggleStatus
属性
<Button Content="Toggle Status"
...
IsEnabled="{Binding Path=CanToggleStatus}"
Command="{Binding Path=ToggleStatus}"/>
底部按钮用于演示在视图模型上调用带参数的方法。其 Command
属性绑定到 SetStatus(bool status)
方法,该方法带有一个布尔参数 - status
。要传递此参数,我们将 CommandParameter
属性设置为“True
”
<Button Content="Set Status to True"
Grid.Row="3"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Command="{Binding Path=SetStatus}"
CommandParameter="True"/>
Avalonia 用户控件
用户控件是几乎从不应该创建或使用的东西,因为对于控件来说,无外观(也称为自定义)控件更强大,并且在视觉和非视觉关注点之间具有更好的分离,而对于 MVVM 模式的视图,DataTemplates
更好。
然而,如果不提及 UserControls
,Avalonia 的故事将不完整。它们也是最容易创建和理解的。
示例代码位于 NP.Demos.UserControlSample 解决方案下
它包含 MyUserControl
用户控件
要从头开始创建此类用户控件,请使用 Add->New Item 上下文菜单,然后在打开的对话框中,在左侧选择 Avalonia,在右侧选择“用户控件 (Avalonia)”,然后按 Add 按钮。
运行示例,这是弹出的窗口
在 TextBox
中开始输入。 “取消”和“保存”按钮将变为启用状态。如果您按 取消,文本将恢复到保存的值(开始时为空)。如果您按 保存,新的保存值将变为 TextBox
中当前的内容。当输入的文本与保存的文本相同时,“取消”和“保存”按钮被禁用,否则启用
MainWindow.axaml 文件只有一个非平凡元素:MyUserControl
<Window x:Name="TheWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="NP.Demos.UserControlSample.MainWindow"
xmlns:local="clr-namespace:NP.Demos.UserControlSample"
...>
<local:MyUserControl Margin="20"/>
</Window>
MainWindow.axaml.cs 文件没有任何非默认代码,因此此功能的所有代码都位于 MyUserControl.axaml 和 MyUserControl.axaml.cs 文件中——C# 文件只是 XAML 文件的代码隐藏。
这是 MyUserControl.axaml 文件的内容
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="NP.Demos.UserControlSample.MyUserControl">
<Grid RowDefinitions="Auto, Auto, *, Auto">
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Left"
VerticalAlignment="Center">
<TextBlock Text="Enter Text: "
VerticalAlignment="Center"/>
<TextBox x:Name="TheTextBox"
MinWidth="150"/>
</StackPanel>
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Grid.Row="1"
Margin="0,10">
<TextBlock Text="Saved Text: "
VerticalAlignment="Center"/>
<TextBlock x:Name="SavedTextBlock"/>
</StackPanel>
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Right"
Grid.Row="3">
<Button x:Name="CancelButton"
Content="Cancel"
Margin="5,0"/>
<Button x:Name="SaveButton"
Content="Save"
Margin="5,0"/>
</StackPanel>
</Grid>
</UserControl>
它没有绑定,也没有命令——只是各种可视化元素的被动排列。
使这一切正常工作的功能位于代码隐藏文件 MyUserControl.axaml.cs 中
public partial class MyUserControl : UserControl
{
private TextBox _textBox;
private TextBlock _savedTextBlock;
private Button _cancelButton;
private Button _saveButton;
// saved value is retrieved from and saved to
// the _savedTextBlock
private string? SavedValue
{
get => _savedTextBlock.Text;
set => _savedTextBlock.Text = value;
}
// NewValue is retrieved from and saved to
// the _textBox
private string? NewValue
{
get => _textBox.Text;
set => _textBox.Text = value;
}
public MyUserControl()
{
InitializeComponent();
// set _cancelButton and its Click event handler
_cancelButton = this.FindControl<Button>("CancelButton");
_cancelButton.Click += OnCancelButtonClick;
// set _saveButton and its Click event handler
_saveButton = this.FindControl<Button>("SaveButton");
_saveButton.Click += OnSaveButtonClick;
// set the TextBlock that contains the Saved text
_savedTextBlock = this.FindControl<TextBlock>("SavedTextBlock");
// set the TextBox that contains the new text
_textBox = this.FindControl<TextBox>("TheTextBox");
// initial New and Saved values should be the same
NewValue = SavedValue;
// every time the text changes, we should check if
// Save and Cancel buttons should be enabled or not
_textBox.GetObservable(TextBox.TextProperty).Subscribe(OnTextChanged);
}
// On Cancel, the TextBox value should become the same as SavedValue
private void OnCancelButtonClick(object? sender, RoutedEventArgs e)
{
NewValue = SavedValue;
}
// On Save, the Saved Value should become the same as the TextBox Value
private void OnSaveButtonClick(object? sender, RoutedEventArgs e)
{
SavedValue = NewValue;
// also we should reset the IsEnabled states of the buttons
OnTextChanged(null);
}
private void OnTextChanged(string? obj)
{
bool canSave = NewValue != SavedValue;
// _cancelButton as _saveButton are enabled if TextBox'es value
// is not the same as saved value and disabled otherwise.
_cancelButton.IsEnabled = canSave;
_saveButton.IsEnabled = canSave;
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}
MyUserControl.xaml 文件中定义的视觉元素通过使用 FindControl<TElement>("ElementName")
方法在 C# 代码中获得,例如
// set _cancelButton and its Click event handler
_cancelButton = this.FindControl<Button>("CancelButton");
然后将按钮的 Click
事件分配给处理程序,例如
_cancelButton.Click += OnCancelButtonClick;
所有有趣的处理都发生在 Click
事件处理程序和 TextBox
的 Text
可观察订阅中
// every time the text changes, we should check if
// Save and Cancel buttons should be enabled or not
_textBox.GetObservable(TextBox.TextProperty).Subscribe(OnTextChanged);
用户控件的主要问题在于,我们将 MyUserControl.axaml 文件提供的视觉表示与 MyUserControl.axaml.cs 文件中包含的 C# 逻辑紧密耦合。
使用自定义控件,我们可以完全分离它们,如下所示。
此外,可以使用 MVVM 模式的视图-视图模型部分将视觉表示与 C# 逻辑分离,这样可以使用完全不同的视觉表示(由不同的 DataTemplates
提供)与定义业务逻辑的相同视图模型。此类 MVVM 示例将在下面给出。
Avalonia 控件模板和自定义控件
您可以在 NP.Demos.CustomControlSample 解决方案下找到此示例。该示例的行为与上一个示例完全相同,但构建方式非常不同。所有非默认 C# 功能都位于 MyCustomControl.cs 文件下
这是它的代码
public class MyCustomControl : TemplatedControl
{
#region NewValue Styled Avalonia Property
public string? NewValue
{
get { return GetValue(NewValueProperty); }
set { SetValue(NewValueProperty, value); }
}
public static readonly StyledProperty<string?> NewValueProperty =
AvaloniaProperty.Register<MyCustomControl, string?>
(
nameof(NewValue)
);
#endregion NewValue Styled Avalonia Property
#region SavedValue Styled Avalonia Property
public string? SavedValue
{
get { return GetValue(SavedValueProperty); }
set { SetValue(SavedValueProperty, value); }
}
public static readonly StyledProperty<string?> SavedValueProperty =
AvaloniaProperty.Register<MyCustomControl, string?>
(
nameof(SavedValue)
);
#endregion SavedValue Styled Avalonia Property
#region CanSave Direct Avalonia Property
private bool _canSave = default;
public static readonly DirectProperty<MyCustomControl, bool> CanSaveProperty =
AvaloniaProperty.RegisterDirect<MyCustomControl, bool>
(
nameof(CanSave),
o => o.CanSave
);
public bool CanSave
{
get => _canSave;
private set
{
SetAndRaise(CanSaveProperty, ref _canSave, value);
}
}
#endregion CanSave Direct Avalonia Property
// CanSave is set to true when SavedValue is not the same as NewView
// false otherwise
private void SetCanSave(object? _)
{
CanSave = SavedValue != NewValue;
}
public MyCustomControl()
{
this.GetObservable(NewValueProperty).Subscribe(SetCanSave);
this.GetObservable(SavedValueProperty).Subscribe(SetCanSave);
}
public void Save()
{
SavedValue = NewValue;
}
public void Cancel()
{
NewValue = SavedValue;
}
}
不要被行数吓到,大部分代码是因为 StyledProperty
和 DirectProperty
定义,由 Avalonia Snippets 中可用和描述的 avsp 和 avdr 片段创建。
有两个样式属性:NewValue
和 SavedValue
,以及一个直接属性:CanSave
。每当任何样式属性更改时,直接属性都会被重新评估为 false
,当且仅当 NewValue == SavedValue
时。这是通过在类构造函数中订阅 NewValue
和 SavedValue
更改来实现的
public MyCustomControl()
{
this.GetObservable(NewValueProperty).Subscribe(SetCanSave);
this.GetObservable(SavedValueProperty).Subscribe(SetCanSave);
}
并通过在回调 SetCanSave(...)
方法中设置它
// CanSave is set to true when SavedValue is not the same as NewView
// false otherwise
private void SetCanSave(object? _)
{
CanSave = SavedValue != NewValue;
}
此方法不需要的参数是为了使其签名与 Subscribe(...)
方法所需的签名匹配而传入的。
还有两个 public
方法供 Button
的命令调用:void Save()
和 void Cancel()
public void Save()
{
SavedValue = NewValue;
}
public void Cancel()
{
NewValue = SavedValue;
}
此 C# 文件与 MyUserControl.axaml.cs 文件(我们在上一节中描述过)之间的区别在于,此文件完全不知道 XAML 实现,并且不包含对 XAML 元素的任何引用。
相反,作为 MainWindow.axaml 文件中的 ControlTemplate
构建的 XAML 通过绑定和命令引用 MyCustomControl.cs 文件中定义的属性和方法。
首先,请注意我们派生了我们的 MyCustomControl
类自 TemplatedControl
public class MyCustomControl : TemplatedControl
{
...
}
正因为如此,它有一个 Template
属性,类型为 ControlTemplate
,我们可以将其设置为此类型的任何对象。以下是位于 MainWindow.axaml 文件中的相应 XAML 代码
<Window x:Name="TheWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="NP.Demos.CustomControlSample.MainWindow"
xmlns:local="clr-namespace:NP.Demos.CustomControlSample"
...>
<local:MyCustomControl Margin="20">
<local:MyCustomControl.Template>
<ControlTemplate TargetType="local:MyCustomControl">
<Grid RowDefinitions="Auto, Auto, *, Auto">
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Left"
VerticalAlignment="Center">
<TextBlock Text="Enter Text: "
VerticalAlignment="Center"/>
<TextBox x:Name="TheTextBox"
Text="{Binding Path=NewValue, Mode=TwoWay,
RelativeSource={RelativeSource TemplatedParent}}"
MinWidth="150"/>
</StackPanel>
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Grid.Row="1"
Margin="0,10">
<TextBlock Text="Saved Text: "
VerticalAlignment="Center"/>
<TextBlock x:Name="SavedTextBlock"
Text="{TemplateBinding SavedValue}"/>
</StackPanel>
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Right"
Grid.Row="3">
<Button x:Name="CancelButton"
Content="Cancel"
Margin="5,0"
IsEnabled="{TemplateBinding CanSave}"
Command="{Binding Path=Cancel,
RelativeSource={RelativeSource TemplatedParent}}"/>
<Button x:Name="SaveButton"
Content="Save"
Margin="5,0"
IsEnabled="{TemplateBinding CanSave}"
Command="{Binding Path=Save,
RelativeSource={RelativeSource TemplatedParent}}"/>
</StackPanel>
</Grid>
</ControlTemplate>
</local:MyCustomControl.Template>
</local:MyCustomControl>
</Window>
我们通过以下行将 Template
属性设置为 ControlTemplate
对象
<local:MyCustomControl Margin="20">
<local:MyCustomControl.Template>
<ControlTemplate TargetType="local:MyCustomControl">
...
请注意,我们正在填充行中的 Template
属性——这对于原型设计是好的,但对于重用却不好。通常,控制模板是作为资源文件中的资源创建的,然后我们使用 {StaticResource
标记扩展来设置 Template
属性。因此,上面的行将如下所示
<local:MyCustomControl Margin="20"
Template="{StaticResource MyCustomControlTemplate}">
这样,我们就可以将相同的模板用于多个控件。或者,我们可以将控件模板与样式一起放置,并为我们的自定义控件使用样式,但这将在以后的文章中解释。
请注意,我们指定了 ControlTemplate
的 TargetType
<ControlTemplate TargetType="local:MyCustomControl">
这将允许我们通过使用 TemplateBinding
或 {RelativeSource TemplatedParent}
连接到 MyCustomControl
类定义的属性。
TextBox
以 TwoWay
模式绑定到控件的 NewValue
属性,因此一个的更改将影响另一个
<TextBox x:Name="TheTextBox"
Text="{Binding Path=NewValue, Mode=TwoWay,
RelativeSource={RelativeSource TemplatedParent}}"
MinWidth="150"/>
“SavedTextBlock
” TextBlock
绑定到 SavedValue
<TextBlock x:Name="SavedTextBlock"
Text="{TemplateBinding SavedValue}"/>
按钮的命令绑定到相应的 public
方法:Cancel()
和 Save()
,而按钮的 IsEnabled
属性绑定到控件的 CanSave
属性
<Button x:Name="CancelButton"
Content="Cancel"
Margin="5,0"
IsEnabled="{TemplateBinding CanSave}"
Command="{Binding Path=Cancel, RelativeSource={RelativeSource TemplatedParent}}"/>
<Button x:Name="SaveButton"
Content="Save"
Margin="5,0"
IsEnabled="{TemplateBinding CanSave}"
Command="{Binding Path=Save, RelativeSource={RelativeSource TemplatedParent}}"/>
NP.Demos.DifferentVisualsForCustomControlSample 显示了以两种不同方式显示的完全相同的自定义控件
顶部的表示与上一个示例相同——而在底部,我更改了行顺序,以便按钮在顶部,保存的文本在中间,TextBox
在底部。这对于用户控件是不可能的。
看看示例代码。两个视觉表示的模板都位于 Themes 项目文件夹下的 Resources.axaml 文件中。 MainWindow.axaml 文件包含该文件的 ResourceInclude
以及对两个实现的 StaticResource
引用——CustomControlTemplate1
和 CustomControlTemplate2
<Window x:Name="TheWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="NP.Demos.DifferentVisualsForCustomControlSample.MainWindow"
xmlns:local="clr-namespace:NP.Demos.DifferentVisualsForCustomControlSample"
...>
<Window.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source=
"avares://NP.Demos.DifferentVisualsForCustomControlSample/Themes/Resources.axaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Window.Resources>
<Grid RowDefinitions="*, *">
<local:MyCustomControl Margin="20"
Template="{StaticResource CustomControlTemplate1}"/>
<local:MyCustomControl Margin="20"
Grid.Row="1"
Template="{StaticResource CustomControlTemplate2}"/>
</Grid>
</Window>
数据模板和视图模型
视图/视图模型概念简介
MVVM 是 Model-View-View Model 模式的缩写。
视图是决定应用程序外观、感觉和视觉行为的视觉效果。
视图模型是一个完全非可视化的类或一组类,它有两个主要作用
- 它提供了一些视图可以通过绑定、命令或其他方式(例如行为)模仿或调用的功能。例如,视图模型可以有一个方法
void SaveAction()
和一个属性IsSaveActionAllowed
,而视图将有一个按钮调用SaveAction()
方法,其IsEnabled
属性将绑定到视图模型上的IsSaveActionAllowed
属性。 - 它封装了模型(例如,来自后端的T数据),在模型更改时向视图提供通知,反之亦然,并且还可以提供不同视图模型和模型之间的通信功能。
在本文中,我们对视图模型和模型之间的通信不感兴趣——这是一个值得专门撰写文章的重要主题。相反,我们将重点关注 MVVM 模式的视图-视图模型 (VVM) 部分。
VVM 模式在 Avalonia 中通过使用 ContentPresenter
(用于单个对象)或 ItemsPresenter
(用于对象集合)实现最佳。
ContentPresenter
在 DataTemplate
的帮助下将非可视对象转换为可视对象(视图)。
ContentPresenter
的 Content
属性通常设置为非可视对象,而 ContentTemplate
应该设置为 DataTemplate
。ContentPresenter
将它们组合成一个可视对象 (View
),其中 DataContext
由 ContentPresenter
的 Content
属性提供,而可视树由 DataTemplate
提供。
ItemsControl
借助 ItemTemplate
(类型为 DataTemplate
)将非可视对象集合转换为可视对象集合,每个可视对象都包含一个 ContentPresenter
,用于将集合中的单个视图模型项转换为可视对象。可视对象根据 ItemsControl.ItemsPanel
属性值提供的面板进行排列。
注意 Avalonia 11 的变化:在之前的 Avalonia 版本(Avalonia 10)中,是 ItemsPresenter 执行此功能。然而,在 Avalonia 11 中,ItemsPresenter
只是 ItemsControl
的一部分,不应在其外部使用(这更接近于 WPF 的工作方式)。此外,对于非可视化集合,不再需要使用 ItemsPresenter
的 Items
属性,而是需要使用 ItemsControl
的 ItemsSource
属性(也更接近于 WPF)。
ItemsControl
的 ItemsSource
属性可以包含非视觉对象的集合。ItemTemplate
中包含 DataTemplate
对象,ItemsControl
将它们组合成视觉对象的集合。
ContentPresenter 示例
此示例的代码位于 NP.Demos.ContentPresenterSample 解决方案中。演示应用程序的行为与“Avalonia 用户控件”和“Avalonia 控件模板和自定义控件”部分中的示例完全相同。
在 TextBox
中开始输入。 “取消”和“保存”按钮将变为启用状态。如果您按 取消,文本将恢复到保存的值(开始时为空)。如果您按 保存,新的保存值将变为 TextBox
中当前的内容。当输入的文本与保存的文本相同时,“取消”和“保存”按钮被禁用,否则启用。
与以前的情况不同,我们没有创建用户控件或自定义控件来实现此目的。相反,我们使用了一个完全非可视化的视图模型和一个由 ContentPresenter
结合在一起的 DataTemplate
。
重要代码位于 ViewModel.cs 和 MainWindow.axaml 文件中。
这是 ViewModel.cs 文件的内容
using System.ComponentModel;
namespace NP.Demos.ContentPresenterSample
{
public class ViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged(string propName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propName));
}
#region SavedValue Property
private string? _savedValue;
public string? SavedValue
{
get
{
return this._savedValue;
}
private set
{
if (this._savedValue == value)
{
return;
}
this._savedValue = value;
this.OnPropertyChanged(nameof(SavedValue));
this.OnPropertyChanged(nameof(CanSave));
}
}
#endregion SavedValue Property
#region NewValue Property
private string? _newValue;
public string? NewValue
{
get
{
return this._newValue;
}
set
{
if (this._newValue == value)
{
return;
}
this._newValue = value;
this.OnPropertyChanged(nameof(NewValue));
this.OnPropertyChanged(nameof(CanSave));
}
}
#endregion NewValue Property
// CanSave is set to true when SavedValue is not the same as NewView
// false otherwise
public bool CanSave => NewValue != SavedValue;
public void Save()
{
SavedValue = NewValue;
}
public void Cancel()
{
NewValue = SavedValue;
}
}
}
我们有 NewValue
和 SavedValue
string
属性,当其中任何一个更改时,它们都会触发 PropertyChanged
通知事件。它们还会通知 CanSave
Boolean
属性可能发生的更改,该属性仅当 NewValue
和 SavedValue
不相同时才为 true
// CanSave is set to true when SavedValue is not the same as NewView
// false otherwise
public bool CanSave => NewValue != SavedValue;
还有两个用于保存和取消的 public
方法
public void Save()
{
SavedValue = NewValue;
}
public void Cancel()
{
NewValue = SavedValue;
}
MainWindow.axaml 文件将 ViewModel
实例和 DataTemplate
定义为资源,以及将它们结合在一起的 ContentPresenter
。这是 ContentPresenter
<Window ...>
<Window.Resources>
...
</Window.Resources>
<ContentPresenter Margin="20"
Content="{StaticResource TheViewModel}"
ContentTemplate="{StaticResource TheDataTemplate}"/>
</Window>
视图模型实例和数据模板通过 StaticResource
标记扩展分配给 ContentPresenter
的 Content
和 ContentTemplate
属性。
以下是我们将 ViewModel
实例定义为 Window
资源的方式
<Window ...>
<Window.Resources>
<local:ViewModel x:Key="TheViewModel"/>
...
</Window.Resources>
...
</Window>
以下是我们如何定义 DataTemplate
<Window ...>
<Window.Resources>
<local:ViewModel x:Key="TheViewModel"/>
<DataTemplate x:Key="TheDataTemplate">
<Grid RowDefinitions="Auto, Auto, *, Auto">
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Left"
VerticalAlignment="Center">
<TextBlock Text="Enter Text: "
VerticalAlignment="Center"/>
<TextBox x:Name="TheTextBox"
Text="{Binding Path=NewValue, Mode=TwoWay}"
MinWidth="150"/>
</StackPanel>
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Grid.Row="1"
Margin="0,10">
<TextBlock Text="Saved Text: "
VerticalAlignment="Center"/>
<TextBlock x:Name="SavedTextBlock"
Text="{Binding Path=SavedValue}"/>
</StackPanel>
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Right"
Grid.Row="3">
<Button x:Name="CancelButton"
Content="Cancel"
Margin="5,0"
IsEnabled="{Binding Path=CanSave}"
Command="{Binding Path=Cancel}"/>
<Button x:Name="SaveButton"
Content="Save"
Margin="5,0"
IsEnabled="{Binding Path=CanSave}"
Command="{Binding Path=Save}"/>
</StackPanel>
</Grid>
</DataTemplate>
</Window.Resources>
...
</Window>
请记住,作为 Content
属性提供给 ContentPresenter
的 ViewModel
对象将成为由 DataTemplate
创建的视觉对象的 DataContext
,因此我们可以将 DataTemplate
上的属性绑定到视图模型的属性,而无需指定绑定的源对象(因为 DataContext
是绑定的默认源)。
我们将 TextBox
以 TwoWay
模式绑定到 ViewModel
的 NewValue
属性,以便如果其中任何一个更改,另一个也会更改
<TextBox x:Name="TheTextBox"
Text="{Binding Path=NewValue, Mode=TwoWay}"
MinWidth="150"/>
我们将 SavedTextBlock
的 Text
属性绑定到 SavedValue
<TextBlock x:Name="SavedTextBlock"
Text="{Binding Path=SavedValue}"/>
我们将按钮的 Command 绑定到 Save()
和 Cancel()
方法,同时还将按钮的 IsEnabled
属性绑定到 ViewModel
的 CanSave
布尔属性
<Button x:Name="CancelButton"
Content="Cancel"
Margin="5,0"
IsEnabled="{Binding Path=CanSave}"
Command="{Binding Path=Cancel}"/>
<Button x:Name="SaveButton"
Content="Save"
Margin="5,0"
IsEnabled="{Binding Path=CanSave}"
Command="{Binding Path=Save}"/>
当然,我们可以将 DataTemplate
提取到不同的文件甚至不同的项目中,并在许多地方重复使用。
ItemsControl 示例
此示例描述了如何使用 ItemsControl
来显示非视觉对象的集合。示例代码位于 NP.Demos.ItemsControlSample 解决方案中。
运行示例,您将看到以下内容
尝试将窗口变窄,名称将换行,例如
这是因为我们正在使用 WrapPanel
来显示多个项目,每个项目都包含一个人的名字和姓氏。
按下“移除最后一个”按钮,最后一个人物项将被移除,“人数”文本将更新
继续按下按钮,直到没有剩余项目——“移除最后一个”按钮将被禁用
查看示例代码。添加了两个视图模型文件:PersonViewModel.cs 和 TestViewModel.cs。
PersonViewModel
是最简单的类,包含不可变属性 FirstName
和 LastName
public class PersonViewModel
{
public string FirstName { get; }
public string LastName { get; }
public PersonViewModel(string firstName, string lastName)
{
FirstName = firstName;
LastName = lastName;
}
}
TestViewModel
代表包含其 People
属性(类型为 ObservableCollection<PersonViewModel>
)中的 PersonViewModel
对象集合的顶层视图模型
public class TestViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
// fires notification if a property changes
private void OnPropertyChanged(string propName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propName));
}
// collection of PersonViewModel objects
public ObservableCollection<PersonViewModel> People { get; } =
new ObservableCollection<PersonViewModel>();
// number of people
public int NumberOfPeople => People.Count;
public TestViewModel()
{
People.CollectionChanged += People_CollectionChanged;
People.Add(new PersonViewModel("Joe", "Doe"));
People.Add(new PersonViewModel("Jane", "Dane"));
People.Add(new PersonViewModel("John", "Dawn"));
}
// whenever collection changes, fire notification for possible updates
// of NumberOfPeople and CanRemoveLast properties.
private void People_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
OnPropertyChanged(nameof(NumberOfPeople));
OnPropertyChanged(nameof(CanRemoveLast));
}
// can remove last item only if collection has some items in it
public bool CanRemoveLast => NumberOfPeople > 0;
// remove last item of the collection
public void RemoveLast()
{
People.RemoveAt(NumberOfPeople - 1);
}
}
它的构造函数中填充了三个名字
public TestViewModel()
{
People.CollectionChanged += People_CollectionChanged;
People.Add(new PersonViewModel("Joe", "Doe"));
People.Add(new PersonViewModel("Jane", "Dane"));
People.Add(new PersonViewModel("John", "Dawn"));
}
属性 NumberOfPeople
包含集合 People
中当前的项目数,属性 CanRemoveLast
指定集合中是否有任何项目
// number of people
public int NumberOfPeople => People.Count;
...
// can remove last item only if collection has some items in it
public bool CanRemoveLast => NumberOfPeople > 0;
每当集合 People
更改时,我们都会通知绑定这两个属性可能已更新
public TestViewModel()
{
People.CollectionChanged += People_CollectionChanged;
...
}
// whenever collection changes, fire notification for possible updates
// of NumberOfPeople and CanRemoveLast properties.
private void People_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
OnPropertyChanged(nameof(NumberOfPeople));
OnPropertyChanged(nameof(CanRemoveLast));
}
有一个 RemoveLast()
方法,用于删除 People
集合中的最后一个项目
// remove last item of the collection
public void RemoveLast()
{
People.RemoveAt(NumberOfPeople - 1);
}
MainWindow.axaml 文件包含显示应用程序的所有 XAML 代码
<Window x:Name="TheWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="NP.Demos.ItemsPresenterSample.MainWindow"
xmlns:local="clr-namespace:NP.Demos.ItemsPresenterSample"
...>
<Window.Resources>
<local:TestViewModel x:Key="TheViewModel"/>
<DataTemplate x:Key="PersonDataTemplate">
<Grid RowDefinitions="Auto, Auto"
Margin="10">
<TextBlock Text="{Binding Path=FirstName, StringFormat='FirstName: {0}'}"/>
<TextBlock Text="{Binding Path=LastName, StringFormat='LastName: {0}'}"
Grid.Row="1"/>
</Grid>
</DataTemplate>
<DataTemplate x:Key="TestViewModelDataTemplate">
<Grid RowDefinitions="*, Auto, Auto">
<ItemsControl ItemsSource="{Binding Path=People}"
ItemTemplate="{StaticResource PersonDataTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<TextBlock Text="{Binding Path=NumberOfPeople, StringFormat='Number of People: {0}'}"
Grid.Row="1"
HorizontalAlignment="Left"
Margin="10"/>
<Button Content="Remove Last"
IsEnabled="{Binding Path=CanRemoveLast}"
Command="{Binding Path=RemoveLast}"
Grid.Row="2"
HorizontalAlignment="Right"
Margin="10"/>
</Grid>
</DataTemplate>
</Window.Resources>
<ContentPresenter Content="{StaticResource TheViewModel}"
ContentTemplate="{StaticResource TestViewModelDataTemplate}"
Margin="10"/>
</Window>
视图模型实例定义在 Window
资源部分的顶部
<local:TestViewModel x:Key="TheViewModel"/>
定义了两个数据模板作为 Window
的 XAML 资源
TestViewModelDataTemplate
- 整个应用程序的数据模板。它围绕TestViewModel
类构建,并使用PersonDataTemplate
显示与每个人对应的视觉效果。PersonDataTemplate
- 显示单个PersonViewModel
项的名字和姓氏。
PersonDataTemplate
非常简单——只是两个用于名字和姓氏的 TextBlocks
——一个在另一个之上
<:Key="PersonDataTemplate">
<Grid RowDefinitions="Auto, Auto"
Margin="10">
<TextBlock Text="{Binding Path=FirstName, StringFormat='FirstName: {0}'}"/>
<TextBlock Text="{Binding Path=LastName, StringFormat='LastName: {0}'}"
Grid.Row="1"/>
</Grid>
</DataTemplate>
TestViewModelDataTemplate
包含 ItemsPresenter
(示例就是为了它而构建的)
<ItemsControl ItemsSource="{Binding Path=People}"
ItemTemplate="{StaticResource PersonDataTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
其 Items
属性绑定到 TestViewModel
类的 People
集合,其 ItemTemplate
属性设置为 PersonDataTemplate
。
其 ItemsPanel
设置为水平方向的 WrapPanel
,只是为了演示我们可以改变视觉项在 ItemsControl
中的排列方式(默认情况下,它们会垂直排列)。
它还包含用于移除最后一个项目的按钮。Button
的命令绑定到视图模型的 RemoveLast()
方法,其 IsEnabled
属性绑定到视图模型的 CanRemoveLast
属性
<Button Content="Remove Last"
IsEnabled="{Binding Path=CanRemoveLast}"
Command="{Binding Path=RemoveLast}"
Grid.Row="2"
HorizontalAlignment="Right"
Margin="10"/>
最后,我们使用 ContentPresenter
将视图模型实例和 DataTemplate
组合在一起
<ContentPresenter Content="{StaticResource TheViewModel}"
ContentTemplate="{StaticResource TestViewModelDataTemplate}"
Margin="10"/>
结论
在本文中,我们涵盖了 Avalonia 的大部分功能,留下了一些主题
- 样式
- 动画
- 过渡
- 行为
这些主题将在本系列的下一篇文章中介绍。
历史
- 2021年11月7日:初始版本