2012年3月18日日曜日

wpf : ContextMenuの項目クリックイベントからオーナーのコントロールItemを探す

次のような場合に、どのコントロールItemがクリックされてContextMenuが表示されたかを探す方法です。

  • TabControlやListBoxなどにItemsSourceを設定。
  • 各項目(TabItemやListBoxItemなど)のレイアウトはテンプレートで指定。
  • 各項目をクリックするとContextMenuが表示される。

ちなみに、TabControlの場合はアクティブなタブがクリックされたとは限らないし、ListBoxの場合は複数選択されているかもしれません。 よってTabControl.SelectedItemは使用不可。

とりあえずxamlのサンプルコードを載せます。 MainWindow.xamlです。

<Window x:Class="ContextMenuOwnerTest.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Width="400" Height="300">
    <Window.Resources>
        <ContextMenu x:Key="menuTab">
            <MenuItem Header="タブクリック" Click="OnMenuItemTabClick"/>
        </ContextMenu>
        <ContextMenu x:Key="menuList">
            <MenuItem Header="リストボックスクリック" Click="OnMenuItemListClick"/>
        </ContextMenu>
    </Window.Resources>
    <StackPanel>
        <TabControl Name="tabControl" HorizontalAlignment="Stretch" Height="150">
            <TabControl.ItemTemplate>
                <DataTemplate>
                    <TextBlock
                        Text="{Binding TestProperty}"
                        ContextMenu="{DynamicResource menuTab}"
                    />
                </DataTemplate>
            </TabControl.ItemTemplate>
            <TabControl.ContentTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding TestProperty}"/>
                </DataTemplate>
            </TabControl.ContentTemplate>
        </TabControl>
        <ListBox Name="listBox" HorizontalAlignment="Stretch" Height="150">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <TextBlock
                        Text="{Binding TestProperty}"
                        ContextMenu="{DynamicResource menuList}"
                    />
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>
    </StackPanel>
</Window>

ItemsSourceに渡されたコレクションを元にTextBlockを作るだけの単純なサンプルです。

tabControlの子のTabItemが右クリックされたらContextMenuのmenuTabが表示されます。 menuTabのMenuItemがクリックされたら、csのコードの方で右クリックされたTabItemを探します。 listBoxも同じ感じで。

tabControl、listBoxともItemsSourceにはサンプルの簡単なコレクションを突っ込んでいます。

で、肝心のコントロールItemを探すコードはこうなりました。

private DependencyObject
        GetOwnerControlFromMenuItem(MenuItem menuItem, Type controlType)
{
    DependencyObject obj = menuItem;
    while (!(obj is ContextMenu))
        obj = LogicalTreeHelper.GetParent(obj);

    FrameworkElement elem =
            (FrameworkElement)((ContextMenu)obj).PlacementTarget;
    obj = elem.TemplatedParent;

    while (!(obj.GetType() == controlType))
        obj = VisualTreeHelper.GetParent(obj);

    return obj;
}

クリックイベントで渡されたMenuItemを引数にして呼び出します。 controlTypeには探す対象のコントロールItemの型を渡します。 例えばTabItemを探す場合は、

TabItem tabItem =
    (TabItem)GetOwnerControlFromMenuItem(menuItem, typeof(TabItem));

とします。

LogicalTreeHelperを使ってMenuItemの祖先のContextMenuを探すところはそのまんまですね。 LogicalTreeHelperの代わりにVisualTreeHelperを使っても探せますが、LogicalTreeHelperの方が少ない呼び出し回数で探せました。 まぁ、どちらもたいした負荷ではなかったのでどっちでも良いかもしれません。

ContextMenuのオーナーのコントロールはPlacementTargetを見れば分かります。 このサンプルの場合はTextBlockが見つかります。

デバッガでここで見つかったTextBlockの親(Parentプロパティ)を見るとnullになっています。 これはテンプレートを使っているからです。 テンプレートで色々頑張っていたらもうちょっとたどれるんですが、どちらにしてもItemTemplateの土台となっているコントロールまでしかたどれません。 そこから先はTemplatedParentを見ます。

TemplatedParentにたどり着いたら、あとはVisualTreeHelperで目的の型(TabItemとかListBoxItemとか)が見つかるまで先祖がえりするだけです。 こちらはLogicalTreeHelperではダメ。 みごと見つかったら、目的の処理をしましょう。

ちなみに、コントロールItemのContentにバインドしたコレクションの項目が入っています。 コントロールItem自体ではなくデータの処理をしたいときはそちらを対象にしましょう。 コントロールとデータを分けて処理する方がwpfっぽいので、そういう方向で。

最後に、サンプルのMainWindow.xaml.cs全文はこんなんでした。

using System;
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;

namespace ContextMenuOwnerTest
{
    public partial class MainWindow : Window
    {
        public ObservableCollection<TestItem> TestCollection { get; set; }

        public MainWindow()
        {
            InitializeComponent();

            TestCollection = new ObservableCollection<TestItem>()
            {
                new TestItem("うな丼"),
                new TestItem("カツ丼"),
                new TestItem("牛丼"),
                new TestItem("親子丼"),
                new TestItem("海鮮丼"),
                new TestItem("天丼")
            };
            
            tabControl.ItemsSource = TestCollection;
            listBox.ItemsSource = TestCollection;
        }

        private void OnMenuItemTabClick(object sender, RoutedEventArgs e)
        {
            TabItem tabItem = (TabItem)GetOwnerControlFromMenuItem((MenuItem)sender, typeof(TabItem));
            TestItem clickedItem = (TestItem)tabItem.Content;
            int index = TestCollection.IndexOf(clickedItem);

            MessageBox.Show(this, "[" + index + "]" + clickedItem.TestProperty + "をクリック");
        }

        private void OnMenuItemListClick(object sender, RoutedEventArgs e)
        {
            ListBoxItem tabItem = (ListBoxItem)GetOwnerControlFromMenuItem((MenuItem)sender, typeof(ListBoxItem));
            TestItem clickedItem = (TestItem)tabItem.Content;
            int index = TestCollection.IndexOf(clickedItem);

            MessageBox.Show(this, "[" + index + "]" + clickedItem.TestProperty + "をクリック");
        }

        private DependencyObject GetOwnerControlFromMenuItem(MenuItem menuItem, Type controlType)
        {
            DependencyObject obj = menuItem;
            while (!(obj is ContextMenu))
                obj = LogicalTreeHelper.GetParent(obj);

            FrameworkElement elem = (FrameworkElement)((ContextMenu)obj).PlacementTarget;
            obj = elem.TemplatedParent;

            while (!(obj.GetType() == controlType))
                obj = VisualTreeHelper.GetParent(obj);

            return obj;
        }
    }
}

TestItem.csの中身はこうなってました。

namespace ContextMenuOwnerTest
{
    public class TestItem
    {
        public TestItem(string str)
        {
            TestProperty = str;
        }

        public string TestProperty { get; set; }
    }
}