データ バインディングと MVVM

Browse sample. サンプルを参照する

Model-View-ViewModel (MVVM) パターンでは、3 つのソフトウェア レイヤー (ビューと呼ばれる XAML ユーザー インターフェイス、モデルと呼ばれる基になるデータ、ビューモデルと呼ばれるビューとモデル間の仲介) の間の分離が強制されます。 ビューとビューモデルは、多くの場合、XAML で定義されているデータ バインディングを介して接続されます。 ビューの BindingContext は通常、ビューモデルのインスタンスです。

重要

.NET マルチプラットフォーム アプリ UI (.NET MAUI) は、UI スレッドへのバインドの更新をマーシャリングします。 MVVM を使用すると、.NET MAUI のバインド エンジンによって UI スレッドに更新が適用され、データにバインディングされたビューモデルのプロパティを任意のスレッドから更新できます。

MVVM パターンを実装するには複数の方法がありますが、この記事では単純なアプローチに焦点を当てています。 モデルではなく、ビューとビューモデルを使用し、2 つのレイヤー間のデータ バインディングに焦点を当てます。 .NET MAUI で MVVM パターンを使用する方法の詳細については、「.NET MAUI を使用したエンタープライズ アプリケーション パターンでの Model-View-ViewModel (MVVM)」を参照してください。 MVVM パターンの実装に役立つチュートリアルについては、「MVVM の概念を使用したアプリのアップグレード」を参照してください。

単純な MVVM

XAML マークアップ拡張機能では、XAML ファイルが他のアセンブリ内のクラスを参照できるように、新しい XML 名前空間宣言を定義する方法について説明しました。 次の例では、x:Static マークアップ拡張機能を使用して、System 名前空間の静的 DateTime.Now プロパティから現在の日付と時刻を取得します。

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             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">

    <VerticalStackLayout BindingContext="{x:Static sys:DateTime.Now}"
                         Spacing="25" Padding="30,0"
                         VerticalOptions="Center" HorizontalOptions="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}'}" />

    </VerticalStackLayout>

</ContentPage>

この例では、取得したDateTime値は StackLayoutBindingContext として設定されます。 要素で BindingContext を設定すると、その要素のすべての子によって継承されます。 これは、BindingContext のすべての子の StackLayout が同じであり、そのオブジェクトのプロパティへのバインドを含められることを意味します。

Screenshot of a page displaying the date and time.

ただし、問題は、ページが構築および初期化されると日付と時刻が 1 回設定され、変更されないということです。

XAML ページでは、常に現在の時刻を表示するクロックを表示できますが、追加のコードが必要です。 MVVM パターンは、ビジュアル オブジェクトと基になるデータ間のプロパティからデータがバインディングされる場合、.NET MAUI アプリにとって自然な選択肢です。 MVVM の観点から考えると、モデルとビューモデルは完全にコードで記述されたクラスです。 ビューは多くの場合、データ バインディングを介してビューモデルで定義されたプロパティを参照する XAML ファイルです。 MVVMでは、モデルはビューモデルについて何も知らず、ビューモデルはビューについて何も知りません。 ただし、多くの場合、ビューモデルによって公開される型を、UI に関連付けられている型に合わせて調整します。

ここに示すような MVVM の単純な例では、多くの場合、モデルがまったくなく、パターンにはデータ バインディングとリンクされたビューとビューモデルだけが含まれます。

次の例は、クロックのビューモデルを示しています。1 秒ごとに更新される DateTime という名前の 1 つのプロパティがあります。

using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace XamlSamples;

class ClockViewModel: INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    private DateTime _dateTime;
    private Timer _timer;

    public DateTime DateTime
    {
        get => _dateTime;
        set
        {
            if (_dateTime != value)
            {
                _dateTime = value;
                OnPropertyChanged(); // reports this property
            }
        }
    }

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

        // Update the DateTime property every second.
        _timer = new Timer(new TimerCallback((s) => this.DateTime = DateTime.Now),
                           null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
    }

    ~ClockViewModel() =>
        _timer.Dispose();

    public void OnPropertyChanged([CallerMemberName] string name = "") =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

通常、ビューモデルは INotifyPropertyChanged インターフェイスを実装します。このインターフェイスを使用すると、クラスのプロパティのいずれかが変更されるたびに PropertyChanged イベントを発生させる機能が提供されます。 .NET MAUI のデータ バインディング メカニズムは、ハンドラーをこの PropertyChanged イベントに添付して、プロパティが変更されたときに通知を受け取り、ターゲットを新しい値で更新し続けることができます。 前のコード例では、OnPropertyChanged メソッドはイベントの発生を処理しながら、プロパティのソース名 DateTime を自動的に決定します。

次の例は、ClockViewModel を使用する XAML を示しています:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:XamlSamples"
             x:Class="XamlSamples.ClockPage"
             Title="Clock Page">
    <ContentPage.BindingContext>
        <local:ClockViewModel />
    </ContentPage.BindingContext>

    <Label Text="{Binding DateTime, StringFormat='{0:T}'}"
           FontSize="18"
           HorizontalOptions="Center"
           VerticalOptions="Center" />
</ContentPage>

この例では、ClockViewModel はプロパティ要素タグを使用して ContentPageBindingContext に設定されています。 また、コードビハインド ファイルでビューモデルをインスタンス化することもできます。

LabelText プロパティの Binding マークアップ拡張機能によって、DateTime プロパティが書式化されます。 次のスクリーンショットは、その結果を示しています:

Screenshot of a page displaying the date and time via a viewmodel.

さらに、プロパティをピリオドで区切ることでビューモデルの DateTime プロパティの個々のプロパティにアクセスできます。

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

インタラクティブな MVVM

MVVM は多くの場合、基になるデータ モデルに基づく対話型ビューの双方向データ バインディングで使用されます。

次の例は、Color 値を HueSaturationLuminosity 値に変換して、再び元に戻す HslViewModel を示しています。

using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace XamlSamples;

class HslViewModel: INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    private float _hue, _saturation, _luminosity;
    private Color _color;

    public float Hue
    {
        get => _hue;
        set
        {
            if (_hue != value)
                Color = Color.FromHsla(value, _saturation, _luminosity);
        }
    }

    public float Saturation
    {
        get => _saturation;
        set
        {
            if (_saturation != value)
                Color = Color.FromHsla(_hue, value, _luminosity);
        }
    }

    public float Luminosity
    {
        get => _luminosity;
        set
        {
            if (_luminosity != value)
                Color = Color.FromHsla(_hue, _saturation, value);
        }
    }

    public Color Color
    {
        get => _color;
        set
        {
            if (_color != value)
            {
                _color = value;
                _hue = _color.GetHue();
                _saturation = _color.GetSaturation();
                _luminosity = _color.GetLuminosity();

                OnPropertyChanged("Hue");
                OnPropertyChanged("Saturation");
                OnPropertyChanged("Luminosity");
                OnPropertyChanged(); // reports this property
            }
        }
    }

    public void OnPropertyChanged([CallerMemberName] string name = "") =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

この例では、HueSaturationLuminosity プロパティを変更すると、Color プロパティが変更され、Color プロパティが変更されると他の 3 つのプロパティが変更されます。 プロパティが変更されない限り、ビューモデルが PropertyChanged イベントを呼び出さない点を除いて、これは無限ループのように見えるかもしれません。

次の XAML の例には、Color プロパティがビューモデルの Color プロパティにバインドされ、3 つの Slider と 3 つの Label ビューが HueSaturationLuminosity プロパティにバインドされている BoxView が含まれています。

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

    <VerticalStackLayout Padding="10, 0, 10, 30">
        <BoxView Color="{Binding Color}"
                 HeightRequest="100"
                 WidthRequest="100"
                 HorizontalOptions="Center" />
        <Label Text="{Binding Hue, StringFormat='Hue = {0:F2}'}"
               HorizontalOptions="Center" />
        <Slider Value="{Binding Hue}"
                Margin="20,0,20,0" />
        <Label Text="{Binding Saturation, StringFormat='Saturation = {0:F2}'}"
               HorizontalOptions="Center" />
        <Slider Value="{Binding Saturation}"
                Margin="20,0,20,0" />
        <Label Text="{Binding Luminosity, StringFormat='Luminosity = {0:F2}'}"
               HorizontalOptions="Center" />
        <Slider Value="{Binding Luminosity}"
                Margin="20,0,20,0" />
    </VerticalStackLayout>
</ContentPage>

それぞれの Label のバインディングはデフォルトの OneWay です。 値を表示するだけで済みます。 ただし、それぞれの Slider のデフォルトのバインディングは TwoWay です。 これにより、Slider をビューモデルから初期化できます。 ビューモデルがインスタンス化されると、 Color プロパティは Aqua に設定されます。 Slider の変更により、ビューモデルのプロパティの新しい値が設定され、新しい色が計算されます。

MVVM using two-way data bindings.

コマンド実行

アプリでは、ビューモデル内の何かに影響を与えるコマンドをユーザーが開始しなければならないようにすることで、プロパティ バインディングの拡張が必要な場合があります。 通常、このようなコマンドはボタンのクリックや指のタップによって通知され、従来は、ButtonClicked のイベントまたは TapGestureRecognizerTapped イベントに対するハンドラーの分離コード ファイル内で処理されます。

コマンド実行インターフェイスでは、MVVM アーキテクチャにいっそうよく適した代わりのコマンド実装方法が提供されます。 ビューモデル自体にコマンドを含めることができます。その場合のコマンドは、Button クリックのようなビュー内の特定のアクティビティに対応して実行されるメソッドです。 データ バインディングは、これらのコマンドと Button の間で定義されます。

Button とビューモデル間のデータ バインディングを可能にするには、Button で 2 つのプロパティを定義します。

他の多くのコントロールも CommandParameter および Command プロパティを定義します。

ICommand インターフェイスは System.Windows.Input 名前空間で定義されており、2 つのメソッドと 1 つのイベントで構成されています。

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

ビューモデルでは、ICommand 型のプロパティを定義できます。 その後、これらのプロパティを各 Button 要素または他の要素の Command プロパティ、またはこのインターフェイスを実装するカスタム ビューにバインドできます。 必要に応じて、このビューモデル プロパティにバインドされている個々の Buttonオブジェクト (または他の要素) を識別するようにCommandParameter プロパティを設定できます。 内部では、ユーザーが Button をタップして、その CommandParameterExecute メソッドに渡すたびに ButtonExecute メソッドを呼び出します。

CanExecute メソッドと CanExecuteChanged イベントは、Button タップが現在無効である可能性がある場合に使用されます。その場合、Buttonは自らを無効にする必要があります。 Command プロパティが最初に設定されたとき、および CanExecuteChanged イベントが発生するたびに ButtonCanExecute を呼び出します。 CanExecutefalse を返す場合、Button 自体は無効になり、Execute 呼び出しは生成されません。

.NET MAUI に含まれている Command または Command<T> クラスを使用して、ICommand インターフェイスを実装できます。 これら 2 つのクラスは、複数のコンストラクターと、 ビューモデルが呼び出してCommand オブジェクトに CanExecuteChanged イベントを発生させる ChangeCanExecute メソッドを定義します。

次の例は、電話番号の入力を目的とした単純なキーパッドのビューモデルを示しています。

using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;

namespace XamlSamples;

class KeypadViewModel: INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    private string _inputString = "";
    private string _displayText = "";
    private char[] _specialChars = { '*', '#' };

    public ICommand AddCharCommand { get; private set; }
    public ICommand DeleteCharCommand { get; private set; }

    public string InputString
    {
        get => _inputString;
        private set
        {
            if (_inputString != value)
            {
                _inputString = value;
                OnPropertyChanged();
                DisplayText = FormatText(_inputString);

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

    public string DisplayText
    {
        get => _displayText;
        private set
        {
            if (_displayText != value)
            {
                _displayText = value;
                OnPropertyChanged();
            }
        }
    }

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

        // Command to delete a character from the input string when allowed
        DeleteCharCommand =
            new Command(
                // Command will strip a character from the input string
                () => InputString = InputString.Substring(0, InputString.Length - 1),

                // CanExecute is processed here to return true when there's something to delete
                () => InputString.Length > 0
            );
    }

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

        // Format the string based on the type of data and the length
        if (hasNonNumbers || str.Length < 4 || str.Length > 10)
        {
            // Special characters exist, or the string is too small or large for special formatting
            // Do nothing
        }

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


    public void OnPropertyChanged([CallerMemberName] string name = "") =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

この例では、コマンドの Execute メソッドと CanExecute メソッドがコンストラクターでラムダ関数として定義されています。 ビューモデルは、AddCharCommand プロパティが複数のボタン (またはコマンド インターフェイスを持つ他のコントロール) の Command プロパティにバインドされていることを前提としており、それぞれ CommandParameter で識別されます。 これらのボタンによって InputString プロパティに文字が追加され、DisplayText プロパティの電話番号として書式設定されます。 DeleteCharCommand という名前の ICommand 型の 2 番目のプロパティもあります。 これはバックスペーシング ボタンにバインドされますが、削除する文字がない場合はボタンを無効にする必要があります。

次の例は、KeypadViewModel を使用する XAML を示しています:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:XamlSamples"
             x:Class="XamlSamples.KeypadPage"
             Title="Keypad Page">
    <ContentPage.BindingContext>
        <local:KeypadViewModel />
    </ContentPage.BindingContext>

    <Grid HorizontalOptions="Center" VerticalOptions="Center">
        <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>

        <Label Text="{Binding DisplayText}"
               Margin="0,0,10,0" FontSize="20" LineBreakMode="HeadTruncation"
               VerticalTextAlignment="Center" HorizontalTextAlignment="End"
               Grid.ColumnSpan="2" />

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

        <Button Text="1" Command="{Binding AddCharCommand}" CommandParameter="1" Grid.Row="1" />
        <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" />
        <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" />
        <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" />
        <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 に表示されるものと同じ文字です。

Screenshot of a calculator using MVVM and commands.