こんにちは、働くC#プログラマーのさんさめです。
Expander、使っていますか?
普段は使わない項目を隠したり、
省スペース目的で使える便利なコントロールです。
今回やりたかったことは、
「Expanderの中身がすべてCollapsedで非表示
だった時にExpanderごと非表示にしたい」
というものです。
Expanderの中身が、
条件によって非表示になるUIだとします。
つまり、以下のような挙動をするコントロールです。
チェックボックスがONの時だけ表示されるコントロールが
Expanderの中に2つあり、
両方ともチェックを外すと、
「Expanderを展開しても何も表示されない」
という挙動になってしまいます。
「何かあるのかも」
と思わせておいて実は何もない…
これはちょっとUIとしてイケてません。
そこで、冒頭の、
「Expanderの中身がすべてCollapsedで非表示
だった時にExpanderごと非表示にしたい」
という要求になるわけですね。
最も単純な実装方法は全てにBindingすること
さて、このシンプルな例では、
単に2つのコントロールのVisibilityプロパティ〜を
ExpanderのVisibilityに直接Bindingしてしまえば良さそうです。
といっても、2つの要素を1つにBindingしたいわけですから、
xamlはそこまでシンプルにはなりません。
次のようになります。
<Window
x:Class="ExpanderVisibility.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ExpanderVisibility"
Title="MainWindow"
Width="300"
Height="200">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVisible" />
</Window.Resources>
<DockPanel>
<Expander DockPanel.Dock="Bottom" Header="サブ">
<Expander.Style>
<Style TargetType="{x:Type Expander}">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding Visibility, ElementName=MySlider}" Value="Visible">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
<DataTrigger Binding="{Binding Visibility, ElementName=MyButton}" Value="Visible">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</Expander.Style>
<Grid>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<Slider
x:Name="MySlider"
Grid.Row="0"
Orientation="Horizontal"
Visibility="{Binding IsChecked, ElementName=SliderVisibilityCheckBox, Converter={StaticResource BoolToVisible}}" />
<Button
x:Name="MyButton"
Grid.Row="1"
Content="ボタン"
Visibility="{Binding IsChecked, ElementName=ButtonVisibilityCheckBox, Converter={StaticResource BoolToVisible}}" />
</Grid>
</Expander>
<GroupBox Header="メイン">
<StackPanel>
<CheckBox x:Name="SliderVisibilityCheckBox" Content="スライダーVisible" />
<CheckBox x:Name="ButtonVisibilityCheckBox" Content="ボタンVisible" />
</StackPanel>
</GroupBox>
</DockPanel>
</Window>
重要な部分は、<Expander.Style>の中です。
まず、
<Setter Property="Visibility" Value="Collapsed" />
この部分で、基本的にはExpanderは非表示にしておきます。
そして、
<Style.Triggers>
<DataTrigger Binding="{Binding Visibility, ElementName=MySlider}" Value="Visible">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
<DataTrigger Binding="{Binding Visibility, ElementName=MyButton}" Value="Visible">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
DataTriggerによって、
いずれかのControlのVisibilityがVisibleならば
Expanderも表示するように制御しています。
DataTriggerによるBinding値の変換については、
【WPF】Binding入門5。DataTriggerの活用
で詳しく説明していますので、
「DataTriggerよく分からないよ~」
という方はこちらもご覧ください。
それでは、挙動を見てみましょう。
ひとまず求めていた挙動は実現できました。
ActualHeightへのBindingはダメだった
しかし、当然ながらこれは記述量が非常に多いです。
記述量が多いとヒューマンエラーも多くなります。
また、
コントロールが増えた時に追従を忘れてしまったりなど、
変更に非常に弱くなります。
統一的に1つの考え方で実現できるのが理想です。
そこで、次に考えたのが、
中身のコントロールのActualHeightに
対応させる方法です。
さきほどの<Style>の中身がこのように変わります。
<Setter Property="Visibility" Value="Visible" />
<Style.Triggers>
<DataTrigger Binding="{Binding ActualHeight, ElementName=ContentGrid}" Value="0">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
また、Bindingできるように
Gridに名前を付けておきます。
<Grid x:Name="ContentGrid">
しかしこれは、
上手くいきませんでした。
ActualHeightはExpanderが展開されているとき、
つまりIsExpanded=Trueの時しか
更新されないのです。
Expanderを閉じているときは、
全てのVisibilityがFalseになっても
非表示になりませんでした。
その挙動を一応見てみましょう。
閉じている状態でチェックを両方外しても、Expanderが非表示になりません。
それどころか、Expanderを開いた瞬間ActualHeightの計算が走り非表示になってしまいました。
なんだか
騙されたようにも感じる…
Expanderを開いている時しか正常動作しないとは、
まさに片手落ちと言えるでしょう。
動的にChildrenとマルチバインディングさせる
そこで、Expanderの中身を自動的に探索して
全てのコントロールのVisibilityと
Bindingする方法を試してみることにしました。
というわけで、次のような添付ビヘイビアを作りました。
public class ExpanderVisibilityBehavior : Behavior<Panel>
{
private static MultiVisibilityConverter VisibilityConverter
= new MultiVisibilityConverter();
protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.Loaded += AssociatedObject_Loaded;
}
private void AssociatedObject_Loaded(object sender, RoutedEventArgs e)
{
IAddChild multiBinding = new MultiBinding()
{
Converter = VisibilityConverter,
};
foreach (var child in AssociatedObject.Children.OfType<UIElement>())
{
multiBinding.AddChild(new Binding(nameof(Visibility))
{
Source = child,
Mode = BindingMode.OneWay,
});
}
AssociatedObject.SetBinding(UIElement.VisibilityProperty, multiBinding as MultiBinding);
}
protected override void OnDetaching()
{
base.OnDetaching();
AssociatedObject.Loaded -= AssociatedObject_Loaded;
}
}
Panelクラスにつける添付ビヘイビアであり、
Panel.Children全てのVisibilityを、
Panel自体のVisibilityとマルチバインディングさせます。
MultiBindingはそのままでは、
C#コード側ではBindingを追加できません。
なぜならば、追加するのに必要な
AddChildメソッドを非公開にしているからです。
そのため、一旦IAddChildインタフェースで受け取ることで
AddChildメソッドを呼べるようにしています。
IAddChild multiBinding = new MultiBinding()
また、Panel.Childrenプロパティは、
UIElementCollectionというクラスで、
そのままforeachにかけるとobjectになってしまいます。
そこで、一度OfType<UIElement>でキャストすることで
そのあとの処理を書きやすくしています。
foreach (var child in AssociatedObject.Children.OfType<UIElement>())
MultiVisibilityConverterという
マルチバインディングコンバーターの中では、
どれか一つでもVisibleならばVisibleを返すというコードを書きます。
public class MultiVisibilityConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
if ((values?.Length ?? 0) == 0) { return Visibility.Collapsed; }
var visibilities = values.OfType<Visibility>();
// 1つでも可視のコントロールがあれば可視
return visibilities.Any(x => x == Visibility.Visible)
? Visibility.Visible
: Visibility.Collapsed;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
{
// 書き戻しは無いので未実装のまま
throw new NotImplementedException();
}
}
これで、準備が整いました。
使う側はこのようになります。
<Window
x:Class="ExpanderVisibility.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
xmlns:local="clr-namespace:ExpanderVisibility"
Title="MainWindow"
Width="300"
Height="200">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVisible" />
</Window.Resources>
<DockPanel>
<Expander
DockPanel.Dock="Bottom"
Header="サブ"
Visibility="{Binding Content.Visibility, RelativeSource={RelativeSource Mode=Self}}">
<Grid>
<i:Interaction.Behaviors>
<local:ExpanderVisibilityBehavior />
</i:Interaction.Behaviors>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<Slider
x:Name="MySlider"
Grid.Row="0"
Orientation="Horizontal"
Visibility="{Binding IsChecked, ElementName=SliderVisibilityCheckBox, Converter={StaticResource BoolToVisible}}" />
<Button
x:Name="MyButton"
Grid.Row="1"
Content="ボタン"
Visibility="{Binding IsChecked, ElementName=ButtonVisibilityCheckBox, Converter={StaticResource BoolToVisible}}" />
</Grid>
</Expander>
<GroupBox Header="メイン">
<StackPanel>
<CheckBox x:Name="SliderVisibilityCheckBox" Content="スライダーVisible" />
<CheckBox x:Name="ButtonVisibilityCheckBox" Content="ボタンVisible" />
</StackPanel>
</GroupBox>
</DockPanel>
</Window>
まず、Expander自体のVisibilityは
自身のContentプロパティのVisibilityとBindingさせます。
<Expander
DockPanel.Dock="Bottom"
Header="サブ"
Visibility="{Binding Content.Visibility, RelativeSource={RelativeSource Mode=Self}}">
この書き方ならば、
中身がGridでもDockPanelでもStackPanelでも
なんでもOKです。
そして、中身のGridに対して、
先ほど作成した添付ビヘイビアを設定します。
<Grid>
<i:Interaction.Behaviors>
<local:ExpanderVisibilityBehavior />
</i:Interaction.Behaviors>
<!-- 以下省略 -->
これで、
まずGrid自体のVisibilityが、
Gridの下に配置されたコントロールのVisibilityによって決まり、
次にExpanderが、GridのVisibilityに応じて
自身のVisibilityを変化させるという構造ができました。
それでは挙動を見てみましょう。
最初は非表示だったExpanderが、
チェックを入れると表示状態に変わることが
確認できますね。
これで、中身のコントロールの数が増減しても、
ExpanderやGrid側を書き換える必要が無い実装ができました。
まとめ
まとめです。
- 中身のVisibilityに応じてExpanderごと非表示にしたかった
- 1つ1つ丁寧に手でBindingすれば実現できることを確認した
- いちいちやると大変なので自動でBindingを作るビヘイビアを作った
最後までお読みいただき
ありがとうございました。
コメント