【WPF】表示する中身が無いときにExpanderごと非表示にする

C#

こんにちは、働く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を作るビヘイビアを作った

最後までお読みいただき
ありがとうございました。

コメント

タイトルとURLをコピーしました