第 5 部分: 從資料繫結到 MVVM

Download Sample 下載範例

Model-View-ViewModel (MVVM) 架構模式是考慮 XAML 所發明的。 此模式會強制執行三個軟體層之間的分隔,也就是稱為檢視的 XAML 使用者介面;基礎數據,稱為模型;和 View 與 Model 之間的媒介,稱為 ViewModel。 View 和 ViewModel 通常會透過 XAML 檔案中定義的數據系結來連接。 View 的 BindingContext 通常是 ViewModel 的實例。

簡單的 ViewModel

作為 ViewModels 的簡介,讓我們先看一個沒有一個的程式。 稍早,您已瞭解如何定義新的 XML 命名空間宣告,以允許 XAML 檔案參考其他元件中的類別。 以下是定義命名空間之 XML 命名空間宣告 System 的程式:

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

程式可用來 x:Static 從靜態 DateTime.Now 屬性取得目前的日期和時間,並將該值 DateTime 設定為 BindingContext 上的 StackLayout

<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 是完全以程式代碼撰寫的類別。 View 通常是 XAML 檔案,可透過數據系結參考 ViewModel 中定義的屬性。

適當的模型是 ViewModel 的無知,而適當的 ViewModel 則忽略 View。 不過,程式設計人員通常會針對與特定使用者介面相關聯的數據類型,量身打造 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 如何使用屬性項目標記將 設定為 BindingContextLabel 。 或者,您可以在集合中Resources具現化 ClockViewModel ,並透過StaticResource標記延伸將其設定為 BindingContext 。 或者,程序代碼後置檔案可以具現化 ViewModel。

Binding屬性上的TextLabel標記延伸會格式化 DateTime 屬性。 以下是顯示:

View Displaying Date and Time via ViewModel

您也可以使用句點分隔屬性,以存取 ViewModel 屬性的個別屬性 DateTime

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

互動式MVVM

MVVM 通常會與雙向數據系結搭配使用,以根據基礎數據模型進行互動式檢視。

以下是名為 HslViewModel 的類別,可將值Hue轉換成 ColorSaturationLuminosity 值,反之亦然:

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));
        }
    }
}

、 和 Luminosity 屬性的SaturationHue變更會導致 Color 屬性變更,而變更Color會導致其他三個屬性變更。 這似乎是無限迴圈,除非 屬性已變更,否則類別不會叫用 PropertyChanged 事件。 這會結束否則無法控制的意見反應迴圈。

下列 XAML 檔案包含 BoxView ,其 Color 屬性系結至 ViewModel 的 屬性,以及Slider三個和三Label個系結至 ColorSaturationLuminosity 屬性的Hue檢視:

<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。 這可讓 Slider 從 ViewModel 初始化 。 請注意,當 ViewModel 具現化時,屬性 Color 會設定 Aqua 為 。 但 中的 Slider 變更也需要為 ViewModel 中的 屬性設定新的值,然後計算新的色彩。

MVVM using Two-Way Data Bindings

使用 ViewModels 進行命令

在許多情況下,MVVM 模式僅限於操作數據項:ViewModel 中檢視平行數據物件中的使用者介面物件。

不過,有時候 View 需要包含觸發 ViewModel 中各種動作的按鈕。 但是 ViewModel 不得包含 Clicked 按鈕的處理程式,因為這會將 ViewModel 系結至特定的使用者介面範例。

若要讓 ViewModel 與特定使用者介面物件更獨立,但仍允許在 ViewModel 內呼叫方法, 命令 介面存在。 下列元素 Xamarin.Forms支援此指令介面:

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

除了 和 ListView 元素之外SearchBar,這些元素會定義兩個屬性:

  • 型別 CommandSystem.Windows.Input.ICommand
  • 型別 CommandParameterObject

SearchBarSearchCommand定義 和 SearchCommandParameter 屬性,而 ListViewRefreshCommand定義 型別 ICommand的屬性。

介面 ICommand 會定義兩種方法和一個事件:

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

ViewModel 可以定義 類型的 ICommand屬性。 然後,您可以將這些屬性系結至 Command 彼此 Button 或其他項目的 屬性,或是實作這個介面的自定義檢視。 您可以選擇性地設定 CommandParameter 屬性,以識別系結至這個 ViewModel 屬性的個別 Button 物件(或其他元素)。 在內部,每當用戶點選 Button時,就會Button呼叫 Execute 方法,並傳遞至 Execute 方法。CommandParameter

方法 CanExecuteCanExecuteChanged 事件用於 Button 點選目前可能無效的情況,在此情況下 Button ,應該停用本身。 當Button屬性第一次設定時,以及每當引發事件時,就會CanExecuteChanged呼叫 CanExecuteCommand 。 如果 CanExecutefalse回 ,則會 Button 停用本身,而且不會產生 Execute 呼叫。

如需將命令新增至 ViewModels 的說明,Xamarin.Forms請定義兩個實作 的類別ICommandCommand,其中 Command<T>T 是 和 CanExecute的自變數Execute類型。 這兩個類別會定義數個 ChangeCanExecute 建構函式,再加上 ViewModel 可以呼叫的方法,以強制 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 電話號碼。

另外還有名為DeleteCharCommand類型的ICommand第二個屬性。 這會系結至返回間距按鈕,但如果沒有要刪除的字元,則應該停用該按鈕。

下列按鍵板在視覺上並不像它那麼複雜。 相反地,標記已縮減為最小值,以更清楚地示範命令介面的使用:

<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>

Command出現在此標記中之第一個 Button 的 屬性會系結至 ;其餘的 會系結至 AddCharCommandDeleteCharCommand,其與CommandParameter出現在臉部上的Button字元相同。 以下是運作中的程式:

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 是一個簡短類別的定義,其中包含三個名為、 Title的屬性Type,以及Description包含每個範例頁面的類型、標題和簡短描述。 此外,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; }
}

XAML MainPage 檔案會 ListBox 定義 ,其 ItemsSource 屬性設定為該 All 屬性,且包含 TextCell 用來顯示 Title 每個頁面之 和 Description 屬性的 :

<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

當使用者選取專案時,就會觸發程式代碼後置檔案中的處理程式。 處理程式會將 SelectedItem 的屬性 ListBox 設定回 , 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 演進 2016:MVVM 使用 和 Prism 製作簡單Xamarin.Forms

摘要

XAML 是定義應用程式中使用者介面 Xamarin.Forms 的強大工具,特別是在使用數據系結和MVVM時。 結果是一個乾淨、優雅且可能具工具的使用者介面表示法,且具有程式代碼中所有背景支援。

Channel 9YouTube 上尋找更多 Xamarin 影片。