目录
Avalonia ControlTemplates和自定义控件
介绍
本文可以被视为以下文章序列中的第四部分:
- 在Easy Samples中使用AvaloniaUI进行多平台UI编码——第1部分——AvaloniaUI构建块
- 简单示例中的多平台Avalonia .NET Framework编程基本概念
- 多平台Avalonia .NET Framework简单示例中的XAML基础知识
如果你了解WPF,可以不用看前面的文章就可以看这篇文章,否则,你应该先阅读前面的文章。
关于Avalonia
Avalonia是一个新的开源包,它与WPF非常相似,但与WPF或UWP不同的是,它可以在大多数平台上运行——Windows、MacOS和各种风格的Linus,并且在许多方面比WPF更强大。
Avalonia是比Web编程框架或Xamarin更好的框架的原因在上一篇文章中有详细描述:在Easy Samples中使用AvaloniaUI进行多平台UI编码——第1部分——AvaloniaUI构建块。在这里,我只想重申两个主要原因:
- 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下编译和运行——这就是我一直在使用的。此外,请确保在第一次编译示例时您的Internet连接已打开,因为必须下载一些nuget包。
解释概念
路由事件
路由事件概念
与WPF相同,Avalonia有一个附加路由事件的概念,该事件在可视树中向上和向下传播。它们比WPF路由事件更强大且更易于处理(如下所述)。
与通常的C#事件不同,它们:
- 可以在触发它们的类之外定义并“附加”到对象。
- 可以向上和向下传播WPF可视树——从某种意义上说,事件可以由一个树节点触发并在另一个树节点(触发节点的祖先之一)上处理。
路由事件有三种不同的传播模式:
- 直接——这意味着事件只能在触发它的同一可视树节点上处理。
- 冒泡——事件从当前节点(引发事件的节点)传播到可视化树的根节点,并且可以在途中的任何地方进行处理。例如,如果可视树由包含一个包含一个Button的Grid的Window组成,并且在Button上触发了一个冒泡事件,那么该事件将从Button传播到Grid,然后再到Window。
- 隧道——事件从可视树的根节点传播到当前节点(引发事件的节点)。使用与上述相同的示例,Tunneling事件将首先在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>
我们有一个包含一个Grid绿色背景的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#事件对应物,这些事件对应物在引发路由事件时引发。我们可以使用,例如,将它连接到HandleClickEvent处理程序的C# PointerPressed事件:
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
由于在边界上第一次冒泡后事件已被处理,因此视觉树上更高的处理程序(网格和窗口上的处理程序)将不再被触发。然而,即使在已经处理的路由事件上,也有一种方法可以强制它们触发。例如,为了在Window级别上执行此操作,请取消注释Window上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并撤消对其余的检查:
之后,按下应用程序内的蓝色边框,该事件的条目将显示主窗口:
现在鼠标单击主窗口中的事件条目——事件链窗格将显示事件如何在可视树上传播:
不幸的是,目前该工具的事件链仅显示未处理事件的传播。它在事件未处理的最后一点停止显示——在我们的例子中,气泡传递的第一项。您可以看到,与我们之前的打印相比,工具中显示的事件隧道实例更多。这是因为该工具显示了Visual树中引发事件的所有元素,而我们仅将处理程序连接到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。
Command提供了一种在单击Button或MenuItem时在View Model中执行某些C#方法的方法。
Avalonia Button和MenuItem都有一个属性Command,可以绑定到视图模型中定义的Command。这样的命令可以执行一个与其挂钩的View Model方法。Avalonia没有自己的命令实现,但建议使用ReactiveUI的ReactiveCommand。还可以通过放置在视图模型中的命令对象来控制是否启用Button(或MenuItem)。
然而,将命令放置在视图模型中的这种方法有很大的缺点:
- 它强制视图模型依赖于可视化.NET程序集(实现命令)。这打破了应该放置在非可视视图模型和视觉对象之间的硬屏障。在那之后,控制可视化代码不泄漏到视图模型中变得更加困难(尤其是在有许多开发人员的项目中)。
- 它不必要地污染了视图模型。
因此,Avalonia提供了一种相当简洁的方法来调用视图模型上的方法——通过将Command绑定到方法的名称。
使用Avalonia命令调用视图模型上的方法
运行位于NP.Demos.CommandSample解决方案下的此示例。这是您将看到的内容:
窗口中间显示了一个状态字段值。当您按下“切换状态”按钮时,它将在True和False之间切换。单击“Set Status to True”将设置状态值True,取消选中“Can Toggle Status”复选框将禁用“Toggle Status”按钮。
查看名为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
- 切换Status属性的ToggleStatus()方法
- 将Status属性设置为传递给它的任何参数的SetStatus(bool status)方法
- 控制ToggleStatus()是否启用操作的CanToggleStatus属性。
每当任何属性更改时,都会触发PropertyChanged事件,以便Avalonia绑定将收到有关属性更改的通知。
位于MainWindow.asaml.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属性双向绑定CanToggleStatus到ViewModel:
<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属性绑定到具有一个Boolean参数的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更好。
然而,除非我们谈论,否则Avalonia UserControls的故事将不完整。它们也是最容易创建和理解的。
示例代码位于NP.Demos.UserControlSample解决方案下:
它包含MyUserControl用户控制:
要从头开始创建这样的用户控件——使用添加->新项目上下文菜单,然后在打开的对话框中,选择左侧的Avalonia和右侧的“用户控件(Avalonia) ”,然后按添加按钮。
运行示例,这是弹出的窗口:
开始在TextBox中输入。“取消”和“保存”按钮将被启用。如果按Cancel,文本将恢复为保存的值(开始时为空)。如果按Save,新保存的值将变为当前在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文件中定义的可视化元素是在C#代码中通过使用FindControl<TElement>("ElementName")方法获得的,例如:
// 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模式的View-ViewModel部分与C#逻辑分离,因此可以使用定义业务逻辑的相同View Model使用完全不同的可视化表示(由不同的DataTemplates提供)。下面将给出这样的MVVM示例。
Avalonia ControlTemplates和自定义控件
您可以在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定义,大部分代码都在那里,并且是由可用的片段avsp和avdr创建的,并在Avalonia Snippets中进行了描述。
有两个Styled Properties:NewValue和SavedValue和一Direct Property: 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(...)方法所需的参数相匹配。
Button的命令还可以调用两种public方法:void Save()和void Cancel():
public void Save()
{
SavedValue = NewValue;
}
public void Cancel()
{
NewValue = SavedValue;
}
此C#文件与MyUserControl.asaml.cs文件(我们在上一节中描述)之间的区别在于,此文件完全不知道XAML实现,并且没有对XAML元素的任何引用。
相反,在MainWindow.asaml文件中作为ControlTemplate构建的XAML指的是在MyCustomControl.cs文件中通过绑定和命令定义的属性和方法。
首先,请注意我们从TemplatedControl派生了我们的MyCustomControl类:
public class MyCustomControl : TemplatedControl
{
...
}
因此,它具有ControlTemplate类型Template的属性,我们可以将其设置为该类型的任何对象。下面是位于MainWindow.asaml文件中的相应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 <ResourceKey>}标记扩展来设置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.asaml文件中。MainWindow.axaml文件包含该文件的ResourceInclude和对两个实现(CustomControlTemplate1和CustomControlTemplate2)的StaticResource引用:
<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模式的缩写。
视图是决定应用程序的外观、感觉和视觉行为的视觉效果。
View Model是一个完全不可见的类或类集,具有两个主要作用:
- 它提供了一些视图可以通过绑定、命令或其他方式(例如行为)模仿或调用的功能。例如,视图模型可以有一个方法void SaveAction()和一个属性IsSaveActionAllowed,而视图将有一个按钮调用SaveAction()方法,其IsEnabled属性将绑定到视图模型上的IsSaveActionAllowed属性。
- 它包装模型(例如,来自后端的数据),在模型发生变化时向视图提供通知,反之亦然,还可以提供不同视图模型和模型之间的通信功能。
在本文中,我们对View Model和Model之间的通信不感兴趣——这是一个重要的话题,值得单独写一篇文章。相反,我们将在这里集中讨论MVVM模式的视图——视图模型(VVM)部分。
在Avalonia中,VVM模式最好通过使用ContentPresenter(针对单个对象)或ItemsPresenter对象集合来实现。
ContentPresenter借助DataTemplate将非可视对象转换为可视对象(视图)。
ContentPresenter的Content属性通常设置为非可视对象,而ContentTemplate应该设置为DataTemplate。ContentPresenter将它们组合成一个可视化对象(View),其中DataContext由ContentPresenter的Content属性给出,而visual树由DataTemplate提供。
ItemsPresenter借助DataTemplate将非可视对象的集合转换为可视对象的集合,每个对象都包含将集合中的单个视图模型项转换为可视对象的ContentPresenter。Visual对象根据ItemsPresenter.ItemsPanel属性值提供的面板排列。
ItemsPresenter的Items属性可以包含非可视对象的集合。在里面ItemTemplate有DataTemplate对象,并ItemsPresenter将它们组合成一个Visual对象的集合。
ContentPresenter示例
此示例的代码位于NP.Demos.ContentPresenterSample解决方案中。演示应用程序的行为方式与“Avalonia用户控件”和“Avalonia控件模板和自定义控件”部分中的示例完全相同。
开始在TextBox中输入。“取消”和“保存”按钮将被启用。如果按Cancel,文本将恢复为保存的值(开始时为空)。如果按Save,新保存的值将变为当前在TextBox中的值。当输入的文本与保存的文本相同时,“取消”和“保存”按钮被禁用,否则启用。
与以前的情况不同,我们没有创建用户或自定义控件来实现这一点。相反,我们使用了一个完全非可视化的视图模型和一个由ContentPresenter结合在一起的DataTemplate。
重要的代码位于ViewModel.cs和MainWindow.asaml文件中。
这是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}"/>
我们将按钮的命令绑定到Save()和Cancel()方法,同时还将按钮的IsEnabled属性绑定到ViewModel的CanSave Boolean属性:
<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拉到不同的文件甚至不同的项目中,并在许多地方重用它。
ItemsPresenter 例子
此示例描述了如何使用ItemsPresenter来显示非可视对象的集合。该示例的代码位于NP.Demos.ItemsPresenterSample解决方案中。
运行示例,您将看到以下内容:
尝试使窗口更窄,名称将换行,例如:
这是因为我们使用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">
<ItemsPresenter Items="{Binding Path=People}"
ItemTemplate="{StaticResource PersonDataTemplate}">
<ItemsPresenter.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsPresenter.ItemsPanel>
</ItemsPresenter>
<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项的显示First和Last名称。
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(为了例子的目的而构建):
<ItemsPresenter Items="{Binding Path=People}"
ItemTemplate="{StaticResource PersonDataTemplate}">
<ItemsPresenter.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsPresenter.ItemsPanel>
</ItemsPresenter>
它的Items属性绑定到TestViewModel类的People集合,并且它的ItemTemplate属性设置为PersonDataTemplate。
它的ItemsPanel将其设置为水平方向WrapPanel只是为了证明我们可以更改Visual项目在其ItemsPresenter中的排列方式(默认情况下,它们会垂直排列)。
它还包含用于删除最后一项的按钮。Button的命令绑定到视图模型的RemoveLast()方法,其IsEnabled属性绑定到视图模型的CanRemoveLast属性:
<Button Content="Remove Last"
IsEnabled="{Binding Path=CanRemoveLast}"
Command="{Binding Path=RemoveLast}"
Grid.Row="2"
HorizontalAlignment="Right"
Margin="10"/>
最后,我们使用以下ContentPresenter方法将View Model实例和DataTemplate放在一起:
<ContentPresenter Content="{StaticResource TheViewModel}"
ContentTemplate="{StaticResource TestViewModelDataTemplate}"
Margin="10"/>
结论
在本文中,我们介绍了大部分Avalonia功能,省略了一些主题:
- 样式
- 动画
- 转换
- 行为
这些主题将在本系列的下一篇文章中介绍。
https://www.codeproject.com/Articles/5317055/Multiplatform-Avalonia-NET-Framework-Programming-A