解消法なんてタイトルに付けましたが、世界の諸先輩方の知恵を借りて解決しただけのことです。今後のための備忘録としてまとめてみます。まだ動作確認も甘いので利用される方は自己責任ということでご了承ください。
概要
皆さんもTextBoxを含んだListBoxItemでTextBoxにFocusされたらListBoxItemのIsSelectedプロパティもTrueにしたいって事ありますよね。私もそうです。ネットで調べれば引っかかるのがListBoxItemのStyle.TriggersでIsKeyboardFocusWithinがTrueならばIsSelectedもTrueにするという方法です。こんな感じです。
~略~
<Style>
<Style.Trigger>
<Trigger Property="IsKeyboardFocusWithin" Value="True">
<Setter Property="IsSelected" Value="True"/>
</Trigger>
</Style.Trigger>
</Style>
確かにこれでTextBoxにFocusが来た時に親のListBoxItemも選択されるようになりました。しかし、これにはちょっとした罠がありました…。
サンプルアプリケーションを用意
試しに動かします。検証した環境は以下の通りです。
- Windows7 64bit
- VisualStudio 2013
- .NET Framework 4.5
- Livet 1.1.0.0
プロジェクトの新規作成でLivet WPF4.5 MVVMアプリケーションを選択します。プロジェクトが作成されたら、今回検証で使うデータを用意していきます。
using System;
using Livet;
namespace Sample.Models
{
public class Person : NotificationObject
{
#region Name変更通知プロパティ
private string _Name;
public string Name
{
get
{ return _Name; }
set
{
if (_Name == value)
return;
_Name = value;
RaisePropertyChanged();
}
}
#endregion
public Person()
{
_Name = String.Empty;
}
public Person(Person src)
{
_Name = src.Name;
}
}
}
Nameというプロパティを持つだけのクラスです。次にNationというクラスを作成します。
using System;
using Livet;
namespace Sample.Models
{
public class Nation : NotificationObject
{
#region Name変更通知プロパティ
private string _Name;
public string Name
{
get
{ return _Name; }
set
{
if (_Name == value)
return;
_Name = value;
RaisePropertyChanged();
}
}
#endregion
#region Members変更通知プロパティ
private ObservableSynchronizedCollection<Person> _Members;
public ObservableSynchronizedCollection<Person> Members
{
get
{ return _Members; }
set
{
if (_Members == value)
return;
_Members = value;
RaisePropertyChanged();
}
}
#endregion
public Nation()
{
_Name = String.Empty;
_Members = new ObservableSynchronizedCollection<Person>();
}
public Nation(Nation src)
{
_Name = src.Name;
_Members = new ObservableSynchronizedCollection<Person>(src.Members);
}
}
}
こちらは先程定義したPersonクラスのコレクションとNameというプロパティを持つだけのクラスです。これらを使いListBoxにデータバインディングしていきます。MainWindowViewModel.csで以下のようにします。
namespace Sample.ViewModels
{
public class MainWindowViewModel : ViewModel
{
#region Nations変更通知プロパティ
private ObservableSynchronizedCollection<Nation> _Nations;
public ObservableSynchronizedCollection<Nation> Nations
{
get
{ return _Nations; }
set
{
if (_Nations == value)
return;
_Nations = value;
RaisePropertyChanged();
}
}
#endregion
#region SelectedNation変更通知プロパティ
private Nation _SelectedNation;
public Nation SelectedNation
{
get
{ return _SelectedNation; }
set
{
if (_SelectedNation == value)
return;
_SelectedNation = value;
RaisePropertyChanged();
}
}
#endregion
#region SelectedPerson変更通知プロパティ
private Person _SelectedPerson;
public Person SelectedPerson
{
get
{ return _SelectedPerson; }
set
{
if (_SelectedPerson == value)
return;
_SelectedPerson = value;
RaisePropertyChanged();
}
}
#endregion
public void Initialize()
{
ObservableSynchronizedCollection<Person> members = new ObservableSynchronizedCollection<Person>();
members.Add(new Person(){
Name = "曹操",
});
members.Add(new Person(){
Name = "夏侯惇",
});
members.Add(new Person(){
Name = "夏侯淵",
});
members.Add(new Person()
{
Name = "曹仁",
});
Nation nation = new Nation();
nation.Name = "魏";
nation.Members = new ObservableSynchronizedCollection<Person>(members);
Nations.Add(nation);
members = new ObservableSynchronizedCollection<Person>();
members.Add(new Person()
{
Name = "孫堅",
});
members.Add(new Person()
{
Name = "黄蓋",
});
members.Add(new Person()
{
Name = "程普",
});
members.Add(new Person()
{
Name = "韓当",
});
nation = new Nation();
nation.Name = "呉";
nation.Members = new ObservableSynchronizedCollection<Person>(members);
Nations.Add(nation);
members = new ObservableSynchronizedCollection<Person>();
members.Add(new Person()
{
Name = "劉備",
});
members.Add(new Person()
{
Name = "関羽",
});
members.Add(new Person()
{
Name = "張飛",
});
members.Add(new Person()
{
Name = "趙雲",
});
nation = new Nation();
nation.Name = "蜀";
nation.Members = new ObservableSynchronizedCollection<Person>(members);
Nations.Add(nation);
}
public MainWindowViewModel()
{
_Nations = new ObservableSynchronizedCollection<Nation>();
}
}
}
Nationクラスのコレクションと選択されたNation、Personをバインディングさせるプロパティを用意します。そしてInitializeで適当なデータを作成します。次はXAMLをいじります。インターフェースとしてはWindowに2つのListBoxを載せます。左にNationの一覧、右に選択されたNationの持つMembersの一覧が出るようにします。
~省略~
<Window.Resources>
<Style x:Key="editableListBoxItemStyle" TargetType="{x:Type ListBoxItem}">
<Setter Property="OverridesDefaultStyle" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ContentControl}">
<Border Background="{TemplateBinding Background}">
<ContentPresenter />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="LightBlue" />
</Trigger>
<Trigger Property="IsMouseOver" Value="False">
<Setter Property="Background" Value="LightGray" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="Plum" />
</Trigger>
</Style.Triggers>
</Style>
</Window.Resources>
~省略~
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<!--Nationリスト-->
<ListBox
Grid.Column="0"
Name="lbNation"
Margin="2, 2, 4, 2"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
ItemsSource="{Binding Path=Nations}"
SelectedItem="{Binding Path=SelectedNation}"
ItemContainerStyle="{StaticResource editableListBoxItemStyle}">
<ItemsControl.Template>
<ControlTemplate TargetType="ItemsControl">
<Border
BorderThickness="1"
BorderBrush="Black"
Background="White">
<ScrollViewer
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
Margin="1">
<ItemsPresenter Margin="10"/>
</ScrollViewer>
</Border>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid HorizontalAlignment="Stretch">
<TextBox
Margin="4"
Text="{Binding Path=Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ListBox>
<!--Memberリスト-->
<ListBox
Grid.Column="1"
Name="lbPerson"
Margin="2, 2, 4, 2"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
ItemsSource="{Binding Path=SelectedNation.Members}"
SelectedItem="{Binding Path=SelectedPerson}"
ItemContainerStyle="{StaticResource editableListBoxItemStyle}">
<ItemsControl.Template>
<ControlTemplate TargetType="ItemsControl">
<Border
BorderThickness="1"
BorderBrush="Black"
Background="White">
<ScrollViewer
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
Margin="1">
<ItemsPresenter Margin="10"/>
</ScrollViewer>
</Border>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid HorizontalAlignment="Stretch">
<TextBox
Margin="4"
Text="{Binding Path=Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ListBox>
</Grid>
~省略~
長いので一部省略してます。説明用にListBoxItemのスタイルをリソースとして切り出しています。このスタイル定義ではListBoxItemは選択時には紫、マウスオーバー時は水色、それ以外はグレーとなります。この状態でビルドして実行するとこんな感じにNationの一覧が表示されます。
動作確認
右のListBoxにMemberが展開されます。そして、Memberを選択するとこんな感じ。
そしておもむろに左のListBox内のTextBoxを編集します。
TextBoxにフォーカスは移りましたが、ListBoxItemが選択されません。ここまではTextBox in ListBoxItemの標準動作です。
実験1 ListBoxItemのStyle.TriggersにIsKeyboardFocusWithinを追加してみる
これを解決するために冒頭で載せたスタイル定義を追加します。
~省略~
<Window.Resources>
<Style x:Key="editableListBoxItemStyle" TargetType="{x:Type ListBoxItem}">
<Setter Property="OverridesDefaultStyle" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ContentControl}">
<Border Background="{TemplateBinding Background}">
<ContentPresenter />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<!--追加!-->
<Trigger Property="IsKeyboardFocusWithin" Value="True">
<Setter Property="IsSelected" Value="True"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="LightBlue" />
</Trigger>
<Trigger Property="IsMouseOver" Value="False">
<Setter Property="Background" Value="LightGray" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="Plum" />
</Trigger>
</Style.Triggers>
</Style>
</Window.Resources>
~省略~
今度はTexBoxのフォーカスと連動してListBoxItemが選択されるようになりました。右のMemberリストも更新されています。ではMemberのリストを選択してみます。関羽を選択してみます。
おわかりいただけたでしょうか。「催眠術だとか超スピードだとかそんなチャチなもんじゃあ断じてねえ」事が起きました。別のコントロールにフォーカスが移ると、ListBoxのSelectedItemのバインディングがnullになってしまいます。最初の動作確認を振り返ってください。IsKeyboardFocusWithinのトリガーを実装する前はたとえフォーカスが外れても選択情報は維持されたままでした。このケースにおいてはIsKeyboardFocusWithinを利用したフラグ設定では不十分なようです。
実験2 よろしい、ならばBehaviorだ
困った時はstackoverflowです。調べてみると同じ問題にぶつかった先輩が居ました。幾つかソリューションが提案されていますが、最後のBehaviorを実装する方法が他のコントロールにも流用できそうなので採用してみます。この先はstackoverflowに書かれている内容と同じです。先ほどのページに書かれている通りにクラスを作成します。
using System.Windows;
using System.Windows.Controls.Primitives;
namespace Sample.Behaviors
{
public class AutoSelectWhenAnyChildGetsFocus
{
public static readonly DependencyProperty EnabledProperty = DependencyProperty.RegisterAttached(
"Enabled",
typeof(bool),
typeof(AutoSelectWhenAnyChildGetsFocus),
new UIPropertyMetadata(false, Enabled_Changed));
public static bool GetEnabled( DependencyObject obj ) { return (bool)obj.GetValue(EnabledProperty); }
public static void SetEnabled( DependencyObject obj, bool value ) { obj.SetValue(EnabledProperty, value); }
private static void Enabled_Changed(DependencyObject sender, DependencyPropertyChangedEventArgs e)
{
var attachEvents = (bool)e.NewValue;
var targetUiElement = (UIElement)sender;
if (attachEvents)
{
targetUiElement.IsKeyboardFocusWithinChanged += TargetUiElement_IsKeyboardFocusWithinChanged;
}
else
{
targetUiElement.IsKeyboardFocusWithinChanged -= TargetUiElement_IsKeyboardFocusWithinChanged;
}
}
static void TargetUiElement_IsKeyboardFocusWithinChanged(object sender, DependencyPropertyChangedEventArgs e)
{
var targetUiElement = (UIElement)sender;
if(targetUiElement.IsKeyboardFocusWithin)
{
Selector.SetIsSelected(targetUiElement, true);
}
}
}
}
そして、ListBoxItemのStyleを以下のように変更します。
<Style x:Key="editableListBoxItemStyle" TargetType="{x:Type ListBoxItem}">
<Setter Property="OverridesDefaultStyle" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ContentControl}">
<Border Background="{TemplateBinding Background}">
<ContentPresenter />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<!--ビヘイビア追加-->
<Setter Property="bh:AutoSelectWhenAnyChildGetsFocus.Enabled" Value="True"></Setter>
<Style.Triggers>
<!--この子は廃止-->
<!--<Trigger Property="IsKeyboardFocusWithin" Value="True">
<Setter Property="IsSelected" Value="True"/>
</Trigger>-->
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="LightBlue" />
</Trigger>
<Trigger Property="IsMouseOver" Value="False">
<Setter Property="Background" Value="LightGray" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="Plum" />
</Trigger>
</Style.Triggers>
</Style>
これでビルドして実行します。TextBoxのフォーカスと連動してListBoxItemが選択されます。
今度は大丈夫そうです。
まとめ
TextBoxを含んだListBoxItemでTextBoxのフォーカスに連動してListBoxItemも選択させたい。だけどSelectedItem等を使って色々と処理もしたい。そういう場合はIsKeyboardFocusWithinと連動させてIsSelectedを操作するのでは無くBehaviorやコードビハインドを使う。