第 5 部分: 从数据绑定到 MVVM

Download Sample下载示例

模型-视图-视图模型 (MVVM) 体系结构模式是用 XAML 发明的。 该模式强制在三个软件层之间实施隔离:XAML 用户界面,称为视图;基础数据,称为模型;视图和模型之间的中介,称为 ViewModel。 视图和 ViewModel 通常通过 XAML 文件中定义的数据绑定进行连接。 视图的 BindingContext 通常是 ViewModel 的实例。

简单 ViewModel

作为 ViewModels 简介,让我们先看一个没有 ViewModels 的的程序。 前面介绍了如何定义新的 XML 命名空间声明,以允许 XAML 文件引用其他程序集中的类。 下面是定义 System 命名空间的 XML 命名空间声明的程序:

xmlns:sys="clr-namespace:System;assembly=netstandard"

该程序可以使用 x:Static 从静态 DateTime.Now 属性获取当前日期和时间,并将该 DateTime 值设置为 StackLayout 上的 BindingContext

<StackLayout BindingContext="{x:Static sys:DateTime.Now}" …>

BindingContext 是一个特殊属性:在元素上设置 BindingContext 时,它由该元素的所有子级继承。 这意味着 StackLayout 的所有子级具有相同的 BindingContext,并且它们可以包含对该对象的属性的简单绑定。

One-Shot DateTime 程序中,两个子级包含到该 DateTime 值属性的绑定,但另外两个子级包含似乎缺少绑定路径的绑定。 这意味着 DateTime 值本身用于 StringFormat

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:sys="clr-namespace:System;assembly=netstandard"
             x:Class="XamlSamples.OneShotDateTimePage"
             Title="One-Shot DateTime Page">

    <StackLayout BindingContext="{x:Static sys:DateTime.Now}"
                 HorizontalOptions="Center"
                 VerticalOptions="Center">

        <Label Text="{Binding Year, StringFormat='The year is {0}'}" />
        <Label Text="{Binding StringFormat='The month is {0:MMMM}'}" />
        <Label Text="{Binding Day, StringFormat='The day is {0}'}" />
        <Label Text="{Binding StringFormat='The time is {0:T}'}" />

    </StackLayout>
</ContentPage>

问题是在首次生成页面时设置日期和时间,并且永远不会更改:

View Displaying Date and Time

XAML 文件可以显示始终显示当前时间的时钟,但它需要一些代码来提供帮助。在思考 MVVM 时,Model 和 ViewModel 是完全用代码编写的类。 视图通常是一个 XAML 文件,它通过数据绑定引用 ViewModel 中定义的属性。

适当的模型对 ViewModel 未知,并且正确的 ViewModel 对视图是未知的。 但是,程序员通常会定制 ViewModel 向与特定用户界面关联的数据类型公开的数据类型。 例如,如果模型访问包含 8 位字符 ASCII 字符串的数据库,ViewModel 需要将这些字符串转换为 Unicode 字符串,以适应用户界面中 Unicode 的独占使用。

在 MVVM 的简单示例中(例如此处所示),通常根本不存在模型,并且模式只涉及与数据绑定链接的 View 和 ViewModel。

下面是一个具有名为 DateTime 的单个属性的 ViewModel,该属性每一秒更新一次 DateTime 属性:

using System;
using System.ComponentModel;
using Xamarin.Forms;

namespace XamlSamples
{
    class ClockViewModel : INotifyPropertyChanged
    {
        DateTime dateTime;

        public event PropertyChangedEventHandler PropertyChanged;

        public ClockViewModel()
        {
            this.DateTime = DateTime.Now;

            Device.StartTimer(TimeSpan.FromSeconds(1), () =>
                {
                    this.DateTime = DateTime.Now;
                    return true;
                });
        }

        public DateTime DateTime
        {
            set
            {
                if (dateTime != value)
                {
                    dateTime = value;

                    if (PropertyChanged != null)
                    {
                        PropertyChanged(this, new PropertyChangedEventArgs("DateTime"));
                    }
                }
            }
            get
            {
                return dateTime;
            }
        }
    }
}

ViewModels 通常实现 INotifyPropertyChanged 接口,这意味着每当其中一个属性发生更改时,类将触发 PropertyChanged 事件。 Xamarin.Forms 中的数据绑定机制将处理程序附加到此 PropertyChanged 事件,以便在属性发生更改并用新值保持目标更新时收到通知。

基于此 ViewModel 的时钟可以像这样简单:

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:XamlSamples;assembly=XamlSamples"
             x:Class="XamlSamples.ClockPage"
             Title="Clock Page">

    <Label Text="{Binding DateTime, StringFormat='{0:T}'}"
           FontSize="Large"
           HorizontalOptions="Center"
           VerticalOptions="Center">
        <Label.BindingContext>
            <local:ClockViewModel />
        </Label.BindingContext>
    </Label>
</ContentPage>

请注意如何使用属性元素标记将 ClockViewModel 设置为 LabelBindingContext。 或者,可以在 Resources 集合中实例化 ClockViewModel,并通过 StaticResource 标记扩展将其设置为 BindingContext。 或者,代码隐藏文件可以实例化 ViewModel。

LabelText 属性上的 Binding 标记扩展会格式化 DateTime 属性。 下面是显示内容:

View Displaying Date and Time via ViewModel

还可以通过用句点将属性分开来访问 ViewModel DateTime 属性的各个属性:

<Label Text="{Binding DateTime.Second, StringFormat='{0}'}" … >

交互式 MVVM

MVVM 通常与双向数据绑定一起用于实现基于基础数据模型的交互式视图。

下面是一个名为 HslViewModel 的类,它将 Color 值转换为 HueSaturationLuminosity 值,反之亦然:

using System;
using System.ComponentModel;
using Xamarin.Forms;

namespace XamlSamples
{
    public class HslViewModel : INotifyPropertyChanged
    {
        double hue, saturation, luminosity;
        Color color;

        public event PropertyChangedEventHandler PropertyChanged;

        public double Hue
        {
            set
            {
                if (hue != value)
                {
                    hue = value;
                    OnPropertyChanged("Hue");
                    SetNewColor();
                }
            }
            get
            {
                return hue;
            }
        }

        public double Saturation
        {
            set
            {
                if (saturation != value)
                {
                    saturation = value;
                    OnPropertyChanged("Saturation");
                    SetNewColor();
                }
            }
            get
            {
                return saturation;
            }
        }

        public double Luminosity
        {
            set
            {
                if (luminosity != value)
                {
                    luminosity = value;
                    OnPropertyChanged("Luminosity");
                    SetNewColor();
                }
            }
            get
            {
                return luminosity;
            }
        }

        public Color Color
        {
            set
            {
                if (color != value)
                {
                    color = value;
                    OnPropertyChanged("Color");

                    Hue = value.Hue;
                    Saturation = value.Saturation;
                    Luminosity = value.Luminosity;
                }
            }
            get
            {
                return color;
            }
        }

        void SetNewColor()
        {
            Color = Color.FromHsla(Hue, Saturation, Luminosity);
        }

        protected virtual void OnPropertyChanged(string propertyName)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

HueSaturationLuminosity 属性的更改会导致 Color 属性发生更改,对 Color 的更改会导致其他三个属性发生更改。 这似乎是无限循环,除非该属性已更改,否则类不会调用 PropertyChanged 事件。 这将结束否则无法控制的反馈循环。

以下 XAML 文件包含一个 BoxView,其 Color 属性绑定到 ViewModel 的 Color 属性,以及绑定到 HueSaturationLuminosity 属性的三个 Slider 和三个 Label 视图:

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:XamlSamples;assembly=XamlSamples"
             x:Class="XamlSamples.HslColorScrollPage"
             Title="HSL Color Scroll Page">
    <ContentPage.BindingContext>
        <local:HslViewModel Color="Aqua" />
    </ContentPage.BindingContext>

    <StackLayout Padding="10, 0">
        <BoxView Color="{Binding Color}"
                 VerticalOptions="FillAndExpand" />

        <Label Text="{Binding Hue, StringFormat='Hue = {0:F2}'}"
               HorizontalOptions="Center" />

        <Slider Value="{Binding Hue, Mode=TwoWay}" />

        <Label Text="{Binding Saturation, StringFormat='Saturation = {0:F2}'}"
               HorizontalOptions="Center" />

        <Slider Value="{Binding Saturation, Mode=TwoWay}" />

        <Label Text="{Binding Luminosity, StringFormat='Luminosity = {0:F2}'}"
               HorizontalOptions="Center" />

        <Slider Value="{Binding Luminosity, Mode=TwoWay}" />
    </StackLayout>
</ContentPage>

每个 Label 上的绑定都是默认的 OneWay。 它只需要显示该值。 但每个 Slider 绑定都是 TwoWay。 这允许从 ViewModel 初始化 Slider。 请注意,实例化 ViewModel 时,Color 属性设置为 Aqua。 但是,Slider 中的更改还需要为 ViewModel 中的属性设置新值,然后计算新颜色。

MVVM using Two-Way Data Bindings

使用 ViewModels 运行命令

在许多情况下,MVVM 模式仅限于对数据项的操作:ViewModel 中视图并行数据对象中的用户界面对象。

但是,有时视图需要包含触发 ViewModel 中各种操作的按钮。 但是 ViewModel 不得包含按钮的 Clicked 处理程序,因为这会将 ViewModel 绑定到特定的用户界面范例。

若要使 ViewModel 独立于特定的用户界面对象,但仍允许在 ViewModel 中调用方法,存在 命令 接口。 Xamarin.Forms中的以下元素支持此命令接口:

  • Button
  • MenuItem
  • ToolbarItem
  • SearchBar
  • TextCell(因此也是 ImageCell
  • ListView
  • TapGestureRecognizer

除了 SearchBarListView 元素之外,这些元素定义了两个属性:

  • System.Windows.Input.ICommand 类型的 Command
  • Object 类型的 CommandParameter

SearchBar 定义 SearchCommandSearchCommandParameter 属性,而 ListView 定义 ICommand 类型的 RefreshCommand属性。

ICommand 接口定义两种方法和一个事件:

  • void Execute(object arg)
  • bool CanExecute(object arg)
  • event EventHandler CanExecuteChanged

ViewModel 可以定义 ICommand 类型的属性。 然后,可以将这些属性绑定到每个 Button 或其他元素的 Command 属性,或者绑定到实现此接口的自定义视图。 可以选择设置 CommandParameter 属性来标识绑定到此 ViewModel 属性的单个 Button 对象(或其他元素)。 在内部,只要用户点击 ButtonButton 就会调用 Execute 方法,并将其 CommandParameter 传递给 Execute 方法。

CanExecute 方法和 CanExecuteChanged 事件用于 Button 点击当前可能无效的情况,在这种情况下,Button 应自行禁用。 首次设置 Command 属性时以及每当触发 CanExecuteChanged 事件时,Button 会调用 CanExecute 。 如果 CanExecute 返回 false,则 Button 将自行禁用,并且不会生成 Execute 调用。

为了帮助将命令添加到 ViewModels,Xamarin.Forms 定义了两个实现 ICommand的类:CommandCommand<T>,其中 T 是要 ExecuteCanExecute 的参数的类型。 这两个类定义了多个构造函数以及 ViewModel 可以调用的 ChangeCanExecute 方法,以强制 Command 对象触发 CanExecuteChanged 事件。

下面是用于输入电话号码的简单键盘的 ViewModel。 请注意,ExecuteCanExecute 方法在构造函数中直接定义为 lambda 函数:

using System;
using System.ComponentModel;
using System.Windows.Input;
using Xamarin.Forms;

namespace XamlSamples
{
    class KeypadViewModel : INotifyPropertyChanged
    {
        string inputString = "";
        string displayText = "";
        char[] specialChars = { '*', '#' };

        public event PropertyChangedEventHandler PropertyChanged;

        // Constructor
        public KeypadViewModel()
        {
            AddCharCommand = new Command<string>((key) =>
                {
                    // Add the key to the input string.
                    InputString += key;
                });

            DeleteCharCommand = new Command(() =>
                {
                    // Strip a character from the input string.
                    InputString = InputString.Substring(0, InputString.Length - 1);
                },
                () =>
                {
                    // Return true if there's something to delete.
                    return InputString.Length > 0;
                });
        }

        // Public properties
        public string InputString
        {
            protected set
            {
                if (inputString != value)
                {
                    inputString = value;
                    OnPropertyChanged("InputString");
                    DisplayText = FormatText(inputString);

                    // Perhaps the delete button must be enabled/disabled.
                    ((Command)DeleteCharCommand).ChangeCanExecute();
                }
            }

            get { return inputString; }
        }

        public string DisplayText
        {
            protected set
            {
                if (displayText != value)
                {
                    displayText = value;
                    OnPropertyChanged("DisplayText");
                }
            }
            get { return displayText; }
        }

        // ICommand implementations
        public ICommand AddCharCommand { protected set; get; }

        public ICommand DeleteCharCommand { protected set; get; }

        string FormatText(string str)
        {
            bool hasNonNumbers = str.IndexOfAny(specialChars) != -1;
            string formatted = str;

            if (hasNonNumbers || str.Length < 4 || str.Length > 10)
            {
            }
            else if (str.Length < 8)
            {
                formatted = String.Format("{0}-{1}",
                                          str.Substring(0, 3),
                                          str.Substring(3));
            }
            else
            {
                formatted = String.Format("({0}) {1}-{2}",
                                          str.Substring(0, 3),
                                          str.Substring(3, 3),
                                          str.Substring(6));
            }
            return formatted;
        }

        protected void OnPropertyChanged(string propertyName)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

此 ViewModel 假定 AddCharCommand 属性绑定到多个按钮的 Command 属性(或具有命令接口的任何其他按钮),每个按钮都由 CommandParameter 标识。 这些按钮将字符添加到 InputString 属性,然后格式化为 DisplayText 属性的电话号码。

还有名为 DeleteCharCommandICommand 类型的第二个属性。 它绑定到退格按钮,但如果没有要删除的字符,则应禁用该按钮。

以下键盘不像视觉上那么复杂。 相反,标记已减少到最小值,以更清楚地演示命令接口的使用:

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:XamlSamples;assembly=XamlSamples"
             x:Class="XamlSamples.KeypadPage"
             Title="Keypad Page">

    <Grid HorizontalOptions="Center"
          VerticalOptions="Center">
        <Grid.BindingContext>
            <local:KeypadViewModel />
        </Grid.BindingContext>

        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>

        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="80" />
            <ColumnDefinition Width="80" />
            <ColumnDefinition Width="80" />
        </Grid.ColumnDefinitions>

        <!-- Internal Grid for top row of items -->
        <Grid Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*" />
                <ColumnDefinition Width="Auto" />
            </Grid.ColumnDefinitions>

            <Frame Grid.Column="0"
                   OutlineColor="Accent">
                <Label Text="{Binding DisplayText}" />
            </Frame>

            <Button Text="&#x21E6;"
                    Command="{Binding DeleteCharCommand}"
                    Grid.Column="1"
                    BorderWidth="0" />
        </Grid>

        <Button Text="1"
                Command="{Binding AddCharCommand}"
                CommandParameter="1"
                Grid.Row="1" Grid.Column="0" />

        <Button Text="2"
                Command="{Binding AddCharCommand}"
                CommandParameter="2"
                Grid.Row="1" Grid.Column="1" />

        <Button Text="3"
                Command="{Binding AddCharCommand}"
                CommandParameter="3"
                Grid.Row="1" Grid.Column="2" />

        <Button Text="4"
                Command="{Binding AddCharCommand}"
                CommandParameter="4"
                Grid.Row="2" Grid.Column="0" />

        <Button Text="5"
                Command="{Binding AddCharCommand}"
                CommandParameter="5"
                Grid.Row="2" Grid.Column="1" />

        <Button Text="6"
                Command="{Binding AddCharCommand}"
                CommandParameter="6"
                Grid.Row="2" Grid.Column="2" />

        <Button Text="7"
                Command="{Binding AddCharCommand}"
                CommandParameter="7"
                Grid.Row="3" Grid.Column="0" />

        <Button Text="8"
                Command="{Binding AddCharCommand}"
                CommandParameter="8"
                Grid.Row="3" Grid.Column="1" />

        <Button Text="9"
                Command="{Binding AddCharCommand}"
                CommandParameter="9"
                Grid.Row="3" Grid.Column="2" />

        <Button Text="*"
                Command="{Binding AddCharCommand}"
                CommandParameter="*"
                Grid.Row="4" Grid.Column="0" />

        <Button Text="0"
                Command="{Binding AddCharCommand}"
                CommandParameter="0"
                Grid.Row="4" Grid.Column="1" />

        <Button Text="#"
                Command="{Binding AddCharCommand}"
                CommandParameter="#"
                Grid.Row="4" Grid.Column="2" />
    </Grid>
</ContentPage>

此标记中显示的第一个 ButtonCommand 属性绑定到 DeleteCharCommand;其余项绑定到 AddCharCommand,其 CommandParameterButton 人脸上显示的字符相同。 下面是正在执行操作的程序:

Calculator using MVVM and Commands

调用异步方法

命令还可以调用异步方法。 在指定 Execute 方法时,可以使用 asyncawait 关键字来实现此目的:

DownloadCommand = new Command (async () => await DownloadAsync ());

这表示 DownloadAsync 方法是 Task,并且应等待:

async Task DownloadAsync ()
{
    await Task.Run (() => Download ());
}

void Download ()
{
    ...
}

实现导航菜单

XamlSamples 程序,其中包含本系列文章中的所有源代码都使用 ViewModel 作为主页。 此 ViewModel 是一个短类的定义,其中包含三个名为 TypeTitleDescription 的属性,其中包含每个示例页面的类型、标题和简短说明。 此外,ViewModel 还定义了一个名为 All 的静态属性,该属性是程序中所有页面的集合:

public class PageDataViewModel
{
    public PageDataViewModel(Type type, string title, string description)
    {
        Type = type;
        Title = title;
        Description = description;
    }

    public Type Type { private set; get; }

    public string Title { private set; get; }

    public string Description { private set; get; }

    static PageDataViewModel()
    {
        All = new List<PageDataViewModel>
        {
            // Part 1. Getting Started with XAML
            new PageDataViewModel(typeof(HelloXamlPage), "Hello, XAML",
                                  "Display a Label with many properties set"),

            new PageDataViewModel(typeof(XamlPlusCodePage), "XAML + Code",
                                  "Interact with a Slider and Button"),

            // Part 2. Essential XAML Syntax
            new PageDataViewModel(typeof(GridDemoPage), "Grid Demo",
                                  "Explore XAML syntax with the Grid"),

            new PageDataViewModel(typeof(AbsoluteDemoPage), "Absolute Demo",
                                  "Explore XAML syntax with AbsoluteLayout"),

            // Part 3. XAML Markup Extensions
            new PageDataViewModel(typeof(SharedResourcesPage), "Shared Resources",
                                  "Using resource dictionaries to share resources"),

            new PageDataViewModel(typeof(StaticConstantsPage), "Static Constants",
                                  "Using the x:Static markup extensions"),

            new PageDataViewModel(typeof(RelativeLayoutPage), "Relative Layout",
                                  "Explore XAML markup extensions"),

            // Part 4. Data Binding Basics
            new PageDataViewModel(typeof(SliderBindingsPage), "Slider Bindings",
                                  "Bind properties of two views on the page"),

            new PageDataViewModel(typeof(SliderTransformsPage), "Slider Transforms",
                                  "Use Sliders with reverse bindings"),

            new PageDataViewModel(typeof(ListViewDemoPage), "ListView Demo",
                                  "Use a ListView with data bindings"),

            // Part 5. From Data Bindings to MVVM
            new PageDataViewModel(typeof(OneShotDateTimePage), "One-Shot DateTime",
                                  "Obtain the current DateTime and display it"),

            new PageDataViewModel(typeof(ClockPage), "Clock",
                                  "Dynamically display the current time"),

            new PageDataViewModel(typeof(HslColorScrollPage), "HSL Color Scroll",
                                  "Use a view model to select HSL colors"),

            new PageDataViewModel(typeof(KeypadPage), "Keypad",
                                  "Use a view model for numeric keypad logic")
        };
    }

    public static IList<PageDataViewModel> All { private set; get; }
}

MainPage 的 XAML 文件定义一个 ListBox,该 ItemsSource 属性设置为该 All 属性,其中包含用于显示每个页面的 TitleDescription 属性的 TextCell

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:XamlSamples"
             x:Class="XamlSamples.MainPage"
             Padding="5, 0"
             Title="XAML Samples">

    <ListView ItemsSource="{x:Static local:PageDataViewModel.All}"
              ItemSelected="OnListViewItemSelected">
        <ListView.ItemTemplate>
            <DataTemplate>
                <TextCell Text="{Binding Title}"
                          Detail="{Binding Description}" />
            </DataTemplate>
        </ListView.ItemTemplate>
    </ListView>
</ContentPage>

页面显示在可滚动列表中:

Scrollable list of pages

当用户选择项时,将触发代码隐藏文件中的处理程序。 处理程序将 ListBoxSelectedItem 属性设置为 null,然后实例化所选页面并导航到它:

private async void OnListViewItemSelected(object sender, SelectedItemChangedEventArgs args)
{
    (sender as ListView).SelectedItem = null;

    if (args.SelectedItem != null)
    {
        PageDataViewModel pageData = args.SelectedItem as PageDataViewModel;
        Page page = (Page)Activator.CreateInstance(pageData.Type);
        await Navigation.PushAsync(page);
    }
}

视频

Xamarin Evolve 2016:使用 Xamarin.Forms 和 Prism使 MVVM 变得简单

总结

XAML 是一个功能强大的工具,用于在 Xamarin.Forms 应用程序中定义用户界面,尤其是在使用数据绑定和 MVVM 时。 结果是用户界面具有代码中所有后台支持的干净、优雅和可能的工具表示形式。

第 9 频道YouTube 上查找更多 Xamarin 视频。