2012年4月16日月曜日

wpf : ユーザーコントロールでDataContextを使うときの定石

wpfで独自のコントロールを作るには3通りのやり方があります。

  • カスタムコントロール ... 既存のコントロールを改造(継承)する。
  • ユーザーコントロール ... 複数の既存のコントロールを組み合わせて作る。
  • 自作 ... Controlクラスを継承して1から作る。

今回はユーザーコントロールについてのお話。

ユーザーコントロールでDataContextを使うとき、

public partial class TestControl : UserControl
{
    public TestControl()
    {
        InitializeComponent();

        this.DataContext = new TestData();
    }
}

というようにthis.DataContextにインスタンスを設定してはいけません。 ユーザーコントロールを使う側にDataContextを変更されると挙動が変わってしまいます。

上に書いたとおり、ユーザーコントロールは複数のコントロールを組み合わせて作ります。 そのため必ずGridやStackPanelのようなコンテナとなるコントロールが含まれます。

<UserControl
        x:Class="UserControlDataContextTest.TestControl"
        ~その他属性
>
    <Grid Name="baseContainer"> ←例えばコレ
        ~中身~
    </Grid>
</UserControl>

そのコンテナのDataContextを使いましょう。

public partial class TestControl : UserControl
{
    public TestControl()
    {
        InitializeComponent();

        baseContainer.DataContext = new TestData();
    }
}

「なぜそんなことをするか?」と言うのは、フレームワークのバインド対象となるDataContextの探し方が関係します。

バインドソースが省略されたとき、バインド対象はDataContextになります。 対象となるDataContextは、まずBindingが書かれたコントロールのDataContextを見て、nullなら親コントロールを見て...というように順番に見て決定されます。 例えば次のようなxamlの場合、

<UserControl
        x:Class="UserControlDataContextTest.TestControl"
        ~その他属性
>
    <Grid>
        <TextBlock Text="{Binding DataText}"/>
    </Grid>
</UserControl>

まずTextBlockのDataContextをチェック。 それがnullならば親のGridのDataContextをチェック。 それがnullならばさらに親のUserControlのDataContextをチェックの順です。

では、ユーザーコントロールを使う側はどうでしょう?

<Window
        xmlns:app="clr-namespace:UserControlDataContextTest"
        ~その他属性
>
    <app:TestControl TextB="{Binding DataText}"/>
</Window>

まずはユーザーコントロール(app:TestControl)のDataContextをチェック ... ユーザーコントロール内部でUserControl.DataContextが設定されていた場合ここで引っかかりますね。 使う側でWindow.DataContextが別に設定されていても、そちらはバインドに使用されません。 ユーザーコントロール内部で設定したDataContextの方が使用されてしまいます。

そうなるのはWindow側でUserControl.DataContextを触らなかったときのお話。 Window側でユーザーコントロールのDataContextが書きかえられると、今度はユーザーコントロール内部のバインドが崩れてしまいます。

ユーザーコントロール内部でコンテナのDataContextを使った場合は、そういう副作用はなくなります。 使う側から見て自分の設定したWindow.DataContextがふさがれる事がなくなり、ユーザーコントロールから見てWindowに中身を荒らされる事はなくなります。

なので必要な場合はコンテナのDataContextを使いましょう。

  1. xamlでUserControl要素の直下にあるコンテナにbaseContainerという名前を付ける。
  2. UserControlのコンストラクタでInitializeComponentの後に「baseContainer.DataContext = なんたら」と書く。

これ定石。

...このことに気付いたの、前のネタを投稿した後なんですよね。 もうちょっと早ければ。

以下、上の動作を確認するためのサンプルコードです。 TestControlという名前のユーザーコントロールを作ります。 TestControl.xamlは、

<UserControl
        x:Class="UserControlDataContextTest.TestControl"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
        mc:Ignorable="d" 
        d:DesignHeight="48" d:DesignWidth="300"
>
    <Grid Name="baseContainer">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="auto"/>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition/>
            <RowDefinition/>
        </Grid.RowDefinitions>
        <TextBlock Grid.Column="0" Grid.Row="0">A :</TextBlock>
        <TextBlock Grid.Column="1" Grid.Row="0" Text="{Binding DataText}"/>
        <TextBlock Grid.Column="0" Grid.Row="1">B :</TextBlock>
        <TextBlock Grid.Column="1" Grid.Row="1" Name="textBlockB"/>
    </Grid>
</UserControl>

AにはデータバインドでTestControl内部のDataContextから「DataText」というプロパティを探してきて表示します。 Bは使う側のコントロールから渡された値を表示します。

TestControl.xaml.csです。

// TestControl.xaml.cs
using System.Windows;
using System.Windows.Controls;

namespace UserControlDataContextTest
{
    public partial class TestControl : UserControl
    {
        public TestControl()
        {
            InitializeComponent();

            //this.DataContext = new TestData("UserControlのDataContext");
            baseContainer.DataContext = new TestData("UserControlのDataContext");
        }

        public static readonly DependencyProperty TextBProperty = DependencyProperty.Register(
                "TextB",
                typeof(string),
                typeof(TestControl),
                new FrameworkPropertyMetadata(
                        new PropertyChangedCallback(TestControl.OnTextBChanged)
                )
        );

        public string TextB
        {
            get { return (string)GetValue(TextBProperty); }
            set { SetValue(TextBProperty, value); }
        }

        private static void OnTextBChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
        {
            TestControl thisCtrl = (TestControl)obj;
            thisCtrl.textBlockB.Text = (string)args.NewValue;
        }
    }
}

TextBというプロパティを用意して、使う側でバインドできるようにしています。 TextBが変更されたらtextBlockBに反映します。

DataContextに設定しているTestDataクラスはコレです。

// TestData.cs
namespace UserControlDataContextTest
{
    public class TestData
    {
        public TestData(string text)
        {
            DataText = text;
        }

        public string DataText { get; set; }
    }
}

DataTextというプロパティがあるだけです。 このプロパティでどのDataContextが使われているのか確かめます。

使う側のMainWindow.xamlのコードです。

<Window
        x:Class="UserControlDataContextTest.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:app="clr-namespace:UserControlDataContextTest"
        Title="MainWindow" Height="80" Width="300"
>
    <app:TestControl x:Name="testControl" TextB="{Binding DataText}"/>
</Window>

バインド対象はもちろん、TestControlで使われているDataContextではなくMainWindowが持つDataContextです。

// MainWindow.xaml.cs
using System.Windows;

namespace UserControlDataContextTest
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();

            this.DataContext = new TestData("MainWindowのDataContext");
            //testControl.DataContext = new TestData("MainWindowのDataContext");
        }
    }
}

サンプルコードをそのまま実行すると、AにはUserControlのDataContext、BにはMainWindowのDataContextと表示されます。 TestControlのコンストラクタには、定石通りにコンテナのDataContextを使うコードと、コメントアウトされたthis.DataContextを使うコードが書かれています。 切り替えて動作を確認してみてください。

MainWindowのコンストラクタにも2通りのコードが書かれています。 こちらも切り替えて動作を確認してみてください。