在使用 Model-View-ViewModel(MVVM)模式的 .NET 多平台应用 UI(.NET MAUI)应用中,数据绑定在 viewmodel 中的属性(通常是派生自 INotifyPropertyChanged 的类)和视图中的属性(通常是 XAML 文件)之间定义。有时,应用的需求超越这些属性绑定,需要用户启动命令来影响视图模型中的某些内容。这些命令通常通过按钮点击或手指触碰来产生信号,传统上,这些命令在后置代码文件中处理,用于处理 Clicked 的 Button 事件或 Tapped 的 TapGestureRecognizer 事件。
命令接口提供了实现更适合 MVVM 体系结构的命令的替代方法。viewmodel 可以包含命令,这些命令是在响应视图中的特定活动(例如 Button 单击)时执行的方法。这些命令和 Button 之间定义了数据绑定。
若要允许在 Button 和 viewmodel 之间绑定数据,Button 会定义两个属性:Command 为类型 System.Windows.Input.ICommand 和 CommandParameter 为类型 Object。若要使用命令接口,请定义一个数据绑定,用于定位 Command 源是类型 Button viewmodel 中的属性 ICommand 的属性。viewmodel 包含与单击按钮时执行的该 ICommand 属性关联的代码。可以将 CommandParameter 属性设置为任意数据,以便区分多个按钮(如果它们都绑定到视图模型中的同一 ICommand 属性)。许多其他视图也定义 Command 和 CommandParameter 属性,这些命令都可以在 viewmodel 中使用不依赖于视图中用户界面对象的方法进行处理。
ICommands
该 ICommand 接口在 System.Windows.Input 命名空间中定义,由两种方法和一个事件组成:
public interface ICommand
{
public void Execute (Object parameter);
public bool CanExecute (Object parameter);
public event EventHandler CanExecuteChanged;
}
若要使用命令接口,viewmodel 应包含类型为 ICommand 的属性:
public ICommand MyCommand { private set; get; }
viewmodel 还必须引用实现接口的 ICommand 类。在视图中,Command 的 Button 属性被绑定到该属性:
<Button Text="Execute command"
Command="{Binding MyCommand}" />
当用户按下 Button 时,Button 会调用绑定到其 Command 属性的 ICommand 对象中的 Execute 方法。当绑定首先在 Command 的 Button 属性上定义时,并且当数据绑定以某种方式更改时,Button 在 CanExecute 对象中调用 ICommand 方法。如果 CanExecute 返回 false,则 Button 禁用自身,表示特定命令当前不可用或无效。
此外,Button 也会在 CanExecuteChanged 的 ICommand 事件上附加处理程序。每当影响结果的条件发生更改时,都必须从 viewmodel 中手动引发该 CanExecute 事件。引发该事件时,Button 再次调用 CanExecute,如果 CanExecute 返回 true 则启用自身,返回 false 则禁用自身。
重要提示:与某些 UI 框架(如 WPF)不同,.NET MAUI 不会自动检测可能更改的 CanExecute 返回值。每当任何影响 CanExecute 结果的条件更改时,都必须手动引发 CanExecuteChanged 事件(或者在 Command 类上调用 ChangeCanExecute())。这通常是在修改依赖的属性 CanExecute 时完成的。
注意:还可以使用 IsEnabled 属性 Button 而不是 CanExecute 方法,也可以与该方法结合使用。在 .NET MAUI 7 及更早版本中,不能在使用 IsEnabled 命令接口时使用 Button 属性,因为 CanExecute 方法的返回值总是会覆盖 IsEnabled 属性。在 .NET MAUI 8 及其更高版本中,此问题已得到修复;现在基于命令的 IsEnabled 可以使用 Button 属性。但是,请注意,IsEnabled 属性和 CanExecute 方法现在必须同时返回 true 才能启用 Button(而且父控件也必须启用)。
当 viewmodel 定义类型的 ICommand 属性时,viewmodel 还必须包含或引用实现接口的 ICommand 类。此类必须包含或引用 Execute 和 CanExecute 方法,并且每当 CanExecute 方法可能返回不同值时,手动触发 CanExecuteChanged 事件。可以使用 .NET MAUI 中包含的 Command 类或 Command<T> 类来实现 ICommand 接口。这些类允许您在类构造函数中为 Execute 和 CanExecute 方法指定主体。
提示:使用 Command<T> 是为了在使用 CommandParameter 属性时区分绑定到同一 ICommand 属性的多个视图;如果不需要此特性,则使用 Command 类。
基本命令
以下示例演示在 viewmodel 中实现的基本命令。PersonViewModel 类定义了三个属性 Name、Age 和 Skills,这些属性定义了一个人:
public class PersonViewModel : INotifyPropertyChanged
{
string name;
double age;
string skills;
public event PropertyChangedEventHandler PropertyChanged;
public string Name
{
set { SetProperty(ref name, value); }
get { return name; }
}
public double Age
{
set { SetProperty(ref age, value); }
get { return age; }
}
public string Skills
{
set { SetProperty(ref skills, value); }
get { return skills; }
}
public override string ToString()
{
return Name + ", age " + Age;
}
bool SetProperty<T>(ref T storage, T value, [CallerMemberName] string propertyName = null)
{
if (Object.Equals(storage, value))
return false;
storage = value;
OnPropertyChanged(propertyName);
return true;
}
protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
PersonCollectionViewModel 类将创建新的类型 PersonViewModel 对象,并允许用户填充数据。为此,类定义了 IsEditing 类型、bool 类型的属性,以及 PersonEdit 类型、PersonViewModel 类型的属性。此外,该类还定义了三个类型 ICommand 属性和一个名为 Persons 类型的 IList<PersonViewModel> 属性:
public class PersonCollectionViewModel : INotifyPropertyChanged
{
PersonViewModel personEdit;
bool isEditing;
public event PropertyChangedEventHandler PropertyChanged;
public bool IsEditing
{
private set { SetProperty(ref isEditing, value); }
get { return isEditing; }
}
public PersonViewModel PersonEdit
{
set { SetProperty(ref personEdit, value); }
get { return personEdit; }
}
public ICommand NewCommand { private set; get; }
public ICommand SubmitCommand { private set; get; }
public ICommand CancelCommand { private set; get; }
public IList<PersonViewModel> Persons { get; } = new ObservableCollection<PersonViewModel>();
bool SetProperty<T>(ref T storage, T value, [CallerMemberName] string propertyName = null)
{
if (Object.Equals(storage, value))
return false;
storage = value;
OnPropertyChanged(propertyName);
return true;
}
protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
在此示例中,对三个 ICommand 属性和 Persons 属性的更改不会导致 PropertyChanged 事件被触发。这些属性都是在首次创建类时设置的,并且不会更改。
以下示例展示了如何在 XAML 中使用 PersonCollectionViewModel:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:DataBindingDemos"
x:Class="DataBindingDemos.PersonEntryPage"
Title="Person Entry"
x:DataType="local:PersonCollectionViewModel">
<ContentPage.BindingContext>
<local:PersonCollectionViewModel />
</ContentPage.BindingContext>
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- New Button -->
<Button Text="New"
Grid.Row="0"
Command="{Binding NewCommand}"
HorizontalOptions="Start" />
<!-- Entry Form -->
<Grid Grid.Row="1"
IsEnabled="{Binding IsEditing}">
<Grid x:DataType="local:PersonViewModel"
BindingContext="{Binding PersonEdit}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Label Text="Name: " Grid.Row="0" Grid.Column="0" />
<Entry Text="{Binding Name}"
Grid.Row="0" Grid.Column="1" />
<Label Text="Age: " Grid.Row="1" Grid.Column="0" />
<StackLayout Orientation="Horizontal"
Grid.Row="1" Grid.Column="1">
<Stepper Value="{Binding Age}"
Maximum="100" />
<Label Text="{Binding Age, StringFormat='{0} years old'}"
VerticalOptions="Center" />
</StackLayout>
<Label Text="Skills: " Grid.Row="2" Grid.Column="0" />
<Entry Text="{Binding Skills}"
Grid.Row="2" Grid.Column="1" />
</Grid>
</Grid>
<!-- Submit and Cancel Buttons -->
<Grid Grid.Row="2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button Text="Submit"
Grid.Column="0"
Command="{Binding SubmitCommand}"
VerticalOptions="Center" />
<Button Text="Cancel"
Grid.Column="1"
Command="{Binding CancelCommand}"
VerticalOptions="Center" />
</Grid>
<!-- List of Persons -->
<ListView Grid.Row="3"
ItemsSource="{Binding Persons}" />
</Grid>
</ContentPage>
在此示例中,页面 BindingContext 的属性设置为 PersonCollectionViewModel。包含 Grid,其中有一个 Button,文本为 New,并将其 Command 属性绑定到viewmodel中的 NewCommand 属性;一个输入表单,其属性绑定到 IsEditing 属性,以及 PersonViewModel,将其属性绑定到viewmodel中的相关属性;两按钮分别绑定到viewmodel的 SubmitCommand 和 CancelCommand 属性。显示已经输入的人员集合 ListView。当用户首次按下 New 按钮时,这将启用输入窗体,但禁用 New 按钮。然后,用户输入名称、年龄和技能。在编辑期间,用户可以随时按 Cancel 按钮开始。仅当输入名称和有效年龄时,才启用 Submit 按钮。按下此 Submit 按钮会将该人员转移到由 ListView 显示的集合。按下 Cancel 或 Submit 按钮后,将清除输入表单,并再次启用 New 按钮。
New、Submit 和 Cancel 按钮的所有逻辑都通过属性的定义 PersonCollectionViewModel NewCommand、SubmitCommand 和 CancelCommand 进行处理。构造函数将这三个属性设置为类型为 Command 的对象。
类的 Command 构造函数允许您传递类型为 Action 和 Func<bool> 的参数,分别对应于 Execute 和 CanExecute 方法。此操作和函数可以在 Command 构造函数中定义为 lambda 函数。
public class PersonCollectionViewModel : INotifyPropertyChanged
{
···
public PersonCollectionViewModel()
{
NewCommand = new Command(
execute: () =>
{
PersonEdit = new PersonViewModel();
PersonEdit.PropertyChanged += OnPersonEditPropertyChanged;
IsEditing = true;
RefreshCanExecutes();
},
canExecute: () =>
{
return !IsEditing;
});
···
}
void OnPersonEditPropertyChanged(object sender, PropertyChangedEventArgs args)
{
(SubmitCommand as Command).ChangeCanExecute();
}
void RefreshCanExecutes()
{
(NewCommand as Command).ChangeCanExecute();
(SubmitCommand as Command).ChangeCanExecute();
(CancelCommand as Command).ChangeCanExecute();
}
···
}
当用户单击 New 按钮时,execute 将执行传递给构造函数的 Command 函数。这将创建一个新 PersonViewModel 对象,在该对象的 PropertyChanged 事件上设置一个处理程序,将 IsEditing 设置为 true,并调用在构造函数后定义的 RefreshCanExecutes 方法。
除了实现 ICommand 接口外,Command 该类还定义了一个名为 ChangeCanExecute 的方法。每当发生任何可能更改 CanExecute 方法返回值的事情时,viewmodel 必须为 ICommand 属性调用 ChangeCanExecute。对 ChangeCanExecute 的调用导致 Command 类触发 CanExecuteChanged 事件。已为该事件附加了一个处理程序,并通过再次调用 Button 作出响应,然后根据该方法的返回值启用自身。
当 execute 的方法调用 NewCommand 时,RefreshCanExecutes 的属性将获取对 NewCommand 的调用,接着 ChangeCanExecute 调用 Button 方法。现在,Button 方法返回 false,因为 IsEditing 的属性现在是 true。
PropertyChanged 新 PersonViewModel 对象的处理程序调用 ChangeCanExecute 的 SubmitCommand 方法:
public class PersonCollectionViewModel : INotifyPropertyChanged
{
···
public PersonCollectionViewModel()
{
···
SubmitCommand = new Command(
execute: () =>
{
Persons.Add(PersonEdit);
PersonEdit.PropertyChanged -= OnPersonEditPropertyChanged;
PersonEdit = null;
IsEditing = false;
RefreshCanExecutes();
},
canExecute: () =>
{
return PersonEdit != null &&
PersonEdit.Name != null &&
PersonEdit.Name.Length > 1 &&
PersonEdit.Age > 0;
});
···
}
···
}
当编辑对象中 canExecute 发生属性更改时,都会调用 SubmitCommand 的 PersonViewModel 函数。仅当 true 属性长度至少为一个字符且 Name 大于 0 时,才返回 Age。此时,将启用 Submit 按钮。
execute Submit 的函数从 PersonViewModel 中删除属性更改处理程序,将对象添加到 Persons 集合,并将所有内容返回到其初始状态。
execute Cancel 按钮的函数执行 Submit 按钮执行除将对象添加到集合之外的所有内容:
public class PersonCollectionViewModel : INotifyPropertyChanged
{
···
public PersonCollectionViewModel()
{
···
CancelCommand = new Command(
execute: () =>
{
PersonEdit.PropertyChanged -= OnPersonEditPropertyChanged;
PersonEdit = null;
IsEditing = false;
RefreshCanExecutes();
},
canExecute: () =>
{
return IsEditing;
});
}
···
}
在任何对 canExecute 进行编辑的情况下,方法 true 都会返回 PersonViewModel。
注意:不需要将 execute 和 canExecute 方法定义为 lambda 函数。可以在 viewmodel 中将其编写为私有方法,并在构造函数中 Command 引用它们。然而,这种方法可能会导致许多方法在视图模型中只被引用一次。
使用命令参数
有时,一个或多个按钮或其他用户界面对象可以在 viewmodel 中共享同一 ICommand 属性,这有时很方便。在这种情况下,可以使用 CommandParameter 该属性来区分按钮。
可以继续使用 Command 类来处理这些共享的 ICommand 属性。类定义一个替代构造函数,该构造函数接受 execute 和 canExecute 具有类型 Object 参数的方法。CommandParameter 就是传递给这些方法的方式。在指定 CommandParameter 时,最容易使用泛型 Command<T> 类来指定设置为 CommandParameter 的对象集的类型。execute 指定的方法和 canExecute 方法具有该类型的参数。
以下示例演示用于输入十进制数字的键盘:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:DataBindingDemos"
x:Class="DataBindingDemos.DecimalKeypadPage"
Title="Decimal Keyboard"
x:DataType="local:DecimalKeypadViewModel">
<ContentPage.BindingContext>
<local:DecimalKeypadViewModel />
</ContentPage.BindingContext>
<ContentPage.Resources>
<Style TargetType="Button">
<Setter Property="FontSize" Value="32" />
<Setter Property="BorderWidth" Value="1" />
<Setter Property="BorderColor" Value="Black" />
</Style>
</ContentPage.Resources>
<Grid WidthRequest="240"
HeightRequest="480"
ColumnDefinitions="80, 80, 80"
RowDefinitions="Auto, Auto, Auto, Auto, Auto, Auto"
ColumnSpacing="2"
RowSpacing="2"
HorizontalOptions="Center"
VerticalOptions="Center">
<Label Text="{Binding Entry}"
Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3"
Margin="0,0,10,0"
FontSize="32"
LineBreakMode="HeadTruncation"
VerticalTextAlignment="Center"
HorizontalTextAlignment="End" />
<Button Text="CLEAR"
Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2"
Command="{Binding ClearCommand}" />
<Button Text="⇦"
Grid.Row="1" Grid.Column="2"
Command="{Binding BackspaceCommand}" />
<Button Text="7"
Grid.Row="2" Grid.Column="0"
Command="{Binding DigitCommand}"
CommandParameter="7" />
<Button Text="8"
Grid.Row="2" Grid.Column="1"
Command="{Binding DigitCommand}"
CommandParameter="8" />
<Button Text="9"
Grid.Row="2" Grid.Column="2"
Command="{Binding DigitCommand}"
CommandParameter="9" />
<Button Text="4"
Grid.Row="3" Grid.Column="0"
Command="{Binding DigitCommand}"
CommandParameter="4" />
<Button Text="5"
Grid.Row="3" Grid.Column="1"
Command="{Binding DigitCommand}"
CommandParameter="5" />
<Button Text="6"
Grid.Row="3" Grid.Column="2"
Command="{Binding DigitCommand}"
CommandParameter="6" />
<Button Text="1"
Grid.Row="4" Grid.Column="0"
Command="{Binding DigitCommand}"
CommandParameter="1" />
<Button Text="2"
Grid.Row="4" Grid.Column="1"
Command="{Binding DigitCommand}"
CommandParameter="2" />
<Button Text="3"
Grid.Row="4" Grid.Column="2"
Command="{Binding DigitCommand}"
CommandParameter="3" />
<Button Text="0"
Grid.Row="5" Grid.Column="0" Grid.ColumnSpan="2"
Command="{Binding DigitCommand}"
CommandParameter="0" />
<Button Text="·"
Grid.Row="5" Grid.Column="2"
Command="{Binding DigitCommand}"
CommandParameter="." />
</Grid>
</ContentPage>
在此示例中,页面 BindingContext 是一个 DecimalKeypadViewModel。Entry 此视图模型的属性绑定到 Text Label 的属性。Button 所有对象都绑定到 viewmodel 中的命令:ClearCommand、BackspaceCommand 和 DigitCommand。10 位数字和小数点的 11 个按钮共享绑定到 DigitCommand。CommandParameter 区分了这些按钮。设置为 CommandParameter 的值通常与按钮显示的文本相同,但小数点除外,为了清楚起见,它用中间点字符显示。
定义 DecimalKeypadViewModel 一个类型 Entry 属性和三个 string 类型的 ICommand 属性:
public class DecimalKeypadViewModel : INotifyPropertyChanged
{
string entry = "0";
public event PropertyChangedEventHandler PropertyChanged;
public string Entry
{
private set
{
if (entry != value)
{
entry = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Entry"));
}
}
get
{
return entry;
}
}
public ICommand ClearCommand { private set; get; }
public ICommand BackspaceCommand { private set; get; }
public ICommand DigitCommand { private set; get; }
}
与该按钮对应的 ClearCommand 按钮始终处于启用状态,并将条目设置回“0”:
public class DecimalKeypadViewModel : INotifyPropertyChanged
{
···
public DecimalKeypadViewModel()
{
ClearCommand = new Command(
execute: () =>
{
Entry = "0";
RefreshCanExecutes();
});
···
}
void RefreshCanExecutes()
{
((Command)BackspaceCommand).ChangeCanExecute();
((Command)DigitCommand).ChangeCanExecute();
}
···
}
由于该按钮始终处于启用状态,因此不需要在 canExecute 构造函数中指定 Command 参数。
仅当条目长度大于 1 或不等于字符串“0”时,才启用 Entry 按钮:
public class DecimalKeypadViewModel : INotifyPropertyChanged
{
···
public DecimalKeypadViewModel()
{
···
BackspaceCommand = new Command(
execute: () =>
{
Entry = Entry.Substring(0, Entry.Length - 1);
if (Entry == "")
{
Entry = "0";
}
RefreshCanExecutes();
},
canExecute: () =>
{
return Entry.Length > 1 || Entry != "0";
});
···
}
···
}
execute 函数用于 Backspace 按钮的逻辑可确保 Entry 至少是一串"0"。
该 DigitCommand 属性绑定到 11 个按钮,每个按钮使用 CommandParameter 属性标识自身。DigitCommand 被设为 Command<T> 类的实例。将命令接口与 XAML 配合使用时,CommandParameter 属性通常是字符串,这是泛型参数的类型。然后,execute 和 canExecute 函数的参数为类型 string:
public class DecimalKeypadViewModel : INotifyPropertyChanged
{
···
public DecimalKeypadViewModel()
{
···
DigitCommand = new Command<string>(
execute: (string arg) =>
{
Entry += arg;
if (Entry.StartsWith("0") && !Entry.StartsWith("0."))
{
Entry = Entry.Substring(1);
}
RefreshCanExecutes();
},
canExecute: (string arg) =>
{
return !(arg == "." && Entry.Contains("."));
});
}
···
}
该方法 execute 将字符串参数追加到 Entry 属性。但是,如果结果以零(但不是零和小数点)开头,则必须使用 Substring 函数删除初始零。canExecute 仅当参数是小数点(指示按下小数点)并且 false 已包含小数点时,该方法才返回 Entry。execute 的所有方法调用 RefreshCanExecutes,然后调用 ChangeCanExecute 用于 DigitCommand 和 ClearCommand。这可确保根据输入的数字的当前序列启用或禁用小数点和后空按钮。