命令
在使用模型-视图-视图模型 (MVVM) 模式的 .NET Multi-platform App UI (.NET MAUI) 应用中,数据绑定是在 viewmodel 中的属性(通常是派生自 INotifyPropertyChanged
的类)和视图中的属性(通常是 XAML 文件)之间定义的。 有时,应用的需求超越了属性绑定层面,它要求用户启动影响 viewmodel 中某些内容的命令。 这些命令通常通过点击按钮或手指敲击触发信号,往往是以下两个事件的处理程序中的代码隐藏文件中处理它们:Button 的 Clicked
事件或 TapGestureRecognizer 的 Tapped
事件。
命令接口提供了另一种实现命令的方法,这种方法更适合 MVVM 体系结构。 viewmodel 可以包含命令,这些命令是针对视图中的特定活动(例如 Button 单击)而执行的方法。 在这些命令和 Button 之间定义了数据绑定。
为允许在 Button 和 viewmodel 之间进行数据绑定,Button 定义了两个属性:
Command
类型的System.Windows.Input.ICommand
CommandParameter
类型的Object
若要使用命令接口,需定义面向 Button 的 Command
属性的数据绑定,其中源是 viewmodel 中的属性,类型为 ICommand。 viewmodel 包含与单击按钮时执行的 ICommand 属性关联的代码。 如果多个按钮都绑定到 viewmodel 中的同一个 ICommand 属性,可以将 CommandParameter
属性设置为任意数据,以区分这些按钮。
许多其他视图也会定义 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 接口的类。 在视图中,Button 的 Command
属性绑定到该属性:
<Button Text="Execute command"
Command="{Binding MyCommand}" />
用户按下 Button 时,Button 调用绑定到其 Command
属性的 ICommand 对象中的 Execute
方法。
首次在 Button 的 Command
属性上定义绑定时,以及数据绑定以某种方式更改时,Button 调用 ICommand 对象中的 CanExecute
方法。 如果 CanExecute
返回 false
,则 Button 将禁用其自身。 这表示特定命令当前不可用或无效。
Button 还在 ICommand 的 CanExecuteChanged
事件上附加处理程序。 该事件是从 viewmodel 内引发的。 引发该事件时,Button 将再次调用 CanExecute
。 如果 CanExecute
返回 true
,Button 启用其自身;如果 CanExecute
返回 false
,则禁用其自身。
警告
如果使用命令接口,请勿使用 Button 的 IsEnabled
属性。
viewmodel 定义类型为 ICommand 的属性时,它还必须包含或引用实现 ICommand 接口的类。 该类必须包含或引用 Execute
和 CanExecute
方法,并且每当 CanExecute
方法返回不同的值时均触发 CanExecuteChanged
事件。 可以使用 .NET MAUI 中包含的 Command
或 Command<T>
类来实现 ICommand 接口。 通过这些类,可以在类构造函数中指定 Execute
和 CanExecute
方法的主体。
提示
需要使用 CommandParameter
属性区分绑定到同一 ICommand 属性的多个视图时,使用 Command<T>
,而在没有必要进行区分时,使用 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
的新对象,并允许用户填写数据。 为此,该类定义了类型为 bool
的 IsEditing
属性和类型为 PersonViewModel
的 PersonEdit
属性。 此外,该类还定义了 ICommand 类型的三个属性和 IList<PersonViewModel>
类型的一个名为 Persons
的属性:
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
事件。 这些属性都是在类首次创建时设置的,此后不会更改。
以下示例展示了使用 PersonCollectionViewModel
的 XAML:
<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">
<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 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 的 SubmitCommand
和 CancelCommand
属性的按钮。 ListView 显示已录入的人员的集合:
以下屏幕截图展示了设置年龄之后启用的“提交”按钮:
当用户首次按“新建”按钮时,此操作将启用输入窗体,但会禁用“新建”按钮。 然后用户输入姓名、年龄和技能。 在编辑过程中,用户随时都可以按下“取消”按钮重新开始。 只有在输入了姓名和有效年龄后,才启用“提交”按钮。 按“提交”按钮可将人员转移到 ListView 显示的集合中。 按“取消”或“提交”按钮后,会清除输入窗体中的内容并再次启用“新建”按钮。
“新建”、“提交”和“取消”按钮的所有逻辑都通过定义 NewCommand
、SubmitCommand
和 CancelCommand
属性在 PersonCollectionViewModel
中处理。 PersonCollectionViewModel
的构造函数将这三个属性设置为 Command
类型的对象。
借助 Command
类的构造函数,你可以传递与 Execute
和 CanExecute
相对应的 Action
和 Func<bool>
类型的参数。 此操作和函数可以在 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();
}
···
}
用户单击“新建”按钮时,执行传递给 Command
构造函数的 execute
函数。 这将创建一个新的 PersonViewModel
对象,为该对象的 PropertyChanged
事件设置一个处理程序,将 IsEditing
设置为 true
,并调用在构造函数之后定义的 RefreshCanExecutes
方法。
除了实现 ICommand 接口外,Command
类还定义了名为 ChangeCanExecute
的方法。 每当发生任何可能更改 CanExecute
方法的返回值的事件时,viewmodel 都应为 ICommand 属性调用 ChangeCanExecute
。 调用 ChangeCanExecute
将导致 Command
类触发 CanExecuteChanged
方法。 Button 已为该事件附加了一个处理程序,并通过再次调用 CanExecute
进行响应,然后根据该方法的返回值启用自身。
当 NewCommand
的 execute
方法调用 RefreshCanExecutes
时,NewCommand
属性得到对 ChangeCanExecute
的调用,Button 调用 canExecute
方法,该方法现在返回 false
,因为 IsEditing
的属性现在是 true
。
新 PersonViewModel
对象的 PropertyChanged
处理程序调用 SubmitCommand
的 ChangeCanExecute
方法:
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;
});
···
}
···
}
每当编辑的 PersonViewModel
对象中的属性发生更改时,都会调用 SubmitCommand
的 canExecute
函数。 仅当 Name
属性的长度至少为一个字符且 Age
大于 0 时,它返回 true
。 此时,“提交”按钮将变为启用状态。
“提交”的 execute
函数从 PersonViewModel
中删除属性已更改的处理程序,将对象添加到 Persons
集合中,并使所有内容返回到初始状态。
“取消”按钮的 execute
函数执行“提交”按钮所执行的所有操作,但不将对象添加到集合中:
public class PersonCollectionViewModel : INotifyPropertyChanged
{
···
public PersonCollectionViewModel()
{
···
CancelCommand = new Command(
execute: () =>
{
PersonEdit.PropertyChanged -= OnPersonEditPropertyChanged;
PersonEdit = null;
IsEditing = false;
RefreshCanExecutes();
},
canExecute: () =>
{
return IsEditing;
});
}
···
}
canExecute
方法在编辑 PersonViewModel
时随时返回 true
。
注意
不必将 execute
和 canExecute
方法定义为 lambda 函数。 可以在 viewmodel 中将它们作为专用方法写入,并在 Command
构造函数中引用。 但是,这种方式往往会导致许多方法在 viewmodel 中只得到一次引用。
使用命令参数
有时,一个或多个按钮(或其他用户界面对象)可以方便地在 viewmodel 中共享相同的 ICommand 属性。 在这种情况下,可以使用 CommandParameter
属性来区分按钮。
可以继续为这些共享 ICommand 属性使用 Command
类。 该类定义了一个替代构造函数,该构造函数接受具有类型为 Object
的参数的 execute
和 canExecute
方法。 这就是将 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">
<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
。 此 viewmodel 的 Entry 属性绑定到 Label 的 Text
属性。 所有 Button 对象都绑定到 viewmodel 中的命令:ClearCommand
、BackspaceCommand
和 DigitCommand
。 表示 10 位数和小数点的共计 11 个按钮共享与 DigitCommand
的绑定。 CommandParameter
区分这些按钮。 设置为 CommandParameter
的值通常与按钮显示的文本相同,但小数点除外,为清晰起见,小数点以中间点字符显示:
DecimalKeypadViewModel
定义类型为 string
的 Entry 属性和三个类型为 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();
}
···
}
由于始终启用按钮,因此不必在 Command
构造函数中指定 canExecute
参数。
退格按钮仅在输入的长度大于 1 或 Entry 不等于字符串“0”时启用:
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
函数的逻辑确保 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
方法仅在参数为小数点(指示按下小数点)且 Entry 已经包含小数点时才返回 false
。 所有 execute
方法都调用 RefreshCanExecutes
,然后它再为 DigitCommand
和 ClearCommand
调用 ChangeCanExecute
。 这确保根据当前输入的数字的序列启用或禁用小数点和退格按钮。