第 5 部分: 從資料繫結到 MVVM
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>
問題是,第一次建置頁面時,日期和時間會設定一次,且永遠不會變更:
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
如何使用屬性項目標記將 設定為 BindingContext
的 Label
。 或者,您可以在集合中Resources
具現化 ClockViewModel
,並透過StaticResource
標記延伸將其設定為 BindingContext
。 或者,程序代碼後置檔案可以具現化 ViewModel。
Binding
屬性上的Text
Label
標記延伸會格式化 DateTime
屬性。 以下是顯示:
您也可以使用句點分隔屬性,以存取 ViewModel 屬性的個別屬性 DateTime
:
<Label Text="{Binding DateTime.Second, StringFormat='{0}'}" … >
互動式MVVM
MVVM 通常會與雙向數據系結搭配使用,以根據基礎數據模型進行互動式檢視。
以下是名為 HslViewModel
的類別,可將值Hue
轉換成 Color
、 Saturation
和 Luminosity
值,反之亦然:
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
屬性的Saturation
Hue
變更會導致 Color
屬性變更,而變更Color
會導致其他三個屬性變更。 這似乎是無限迴圈,除非 屬性已變更,否則類別不會叫用 PropertyChanged
事件。 這會結束否則無法控制的意見反應迴圈。
下列 XAML 檔案包含 BoxView
,其 Color
屬性系結至 ViewModel 的 屬性,以及Slider
三個和三Label
個系結至 Color
、 Saturation
和 Luminosity
屬性的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 中的 屬性設定新的值,然後計算新的色彩。
使用 ViewModels 進行命令
在許多情況下,MVVM 模式僅限於操作數據項:ViewModel 中檢視平行數據物件中的使用者介面物件。
不過,有時候 View 需要包含觸發 ViewModel 中各種動作的按鈕。 但是 ViewModel 不得包含 Clicked
按鈕的處理程式,因為這會將 ViewModel 系結至特定的使用者介面範例。
若要讓 ViewModel 與特定使用者介面物件更獨立,但仍允許在 ViewModel 內呼叫方法, 命令 介面存在。 下列元素 Xamarin.Forms支援此指令介面:
Button
MenuItem
ToolbarItem
SearchBar
TextCell
(因此也ImageCell
)ListView
TapGestureRecognizer
除了 和 ListView
元素之外SearchBar
,這些元素會定義兩個屬性:
- 型別
Command
的System.Windows.Input.ICommand
- 型別
CommandParameter
的Object
會SearchBar
SearchCommand
定義 和 SearchCommandParameter
屬性,而 ListView
會RefreshCommand
定義 型別 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
方法 CanExecute
與 CanExecuteChanged
事件用於 Button
點選目前可能無效的情況,在此情況下 Button
,應該停用本身。 當Button
屬性第一次設定時,以及每當引發事件時,就會CanExecuteChanged
呼叫 CanExecute
Command
。 如果 CanExecute
傳 false
回 ,則會 Button
停用本身,而且不會產生 Execute
呼叫。
如需將命令新增至 ViewModels 的說明,Xamarin.Forms請定義兩個實作 的類別ICommand
:Command
,其中 Command<T>
T
是 和 CanExecute
的自變數Execute
類型。 這兩個類別會定義數個 ChangeCanExecute
建構函式,再加上 ViewModel 可以呼叫的方法,以強制 Command
對象引發 CanExecuteChanged
事件。
以下是用於輸入電話號碼之簡單鍵盤的 ViewModel。 請注意, Execute
和 CanExecute
方法在建構函式中定義為 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="⇦"
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
的 屬性會系結至 ;其餘的 會系結至 AddCharCommand
DeleteCharCommand
,其與CommandParameter
出現在臉部上的Button
字元相同。 以下是運作中的程式:
叫用異步方法
命令也可以叫用異步方法。 指定 方法時Execute
,會使用 async
和 await
關鍵詞來達成此目的:
DownloadCommand = new Command (async () => await DownloadAsync ());
這表示 DownloadAsync
方法為 Task
,且應該等候:
async Task DownloadAsync ()
{
await Task.Run (() => Download ());
}
void Download ()
{
...
}
實作導覽功能表
包含此系列文章中所有原始程式碼的範例程式會使用 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>
頁面會顯示在可捲動清單中:
當使用者選取專案時,就會觸發程式代碼後置檔案中的處理程式。 處理程式會將 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時。 結果是一個乾淨、優雅且可能具工具的使用者介面表示法,且具有程式代碼中所有背景支援。