y_uti のブログ

統計、機械学習、自然言語処理などに興味を持つエンジニアの技術ブログです

WPF の GridSplitter を使う

Windows Presentation Foundation (WPF) の GridSplitter を使う機会があったので、使い方について調べた内容をまとめます。

GridSplitter はグリッドの行の高さや列の幅を変更可能にするコントロールです。何となく簡単そうな印象があったのですが、実際に使ってみると意外に難しく、苦労しました。GridSplitter に関する説明やソースコードは以下にあります。

GridSplitter の配置方法には、グリッド内のコンテンツとセルを共有して重ねる方式と、GridSplitter 専用のセルに配置する方式の二通りがあります。以下に示すコードは、コンテンツに重ねて GridSplitter を配置する例です。この例では、グリッド内の二つのセルに LightBlue と LightPink の長方形を配置し、LightBlue の長方形に重ねて GridSplitter を配置しています。

<Grid>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="*"/>
        <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>
    <Rectangle Grid.Column="0" Fill="LightBlue"/>
    <GridSplitter Grid.Column="0" VerticalAlignment="Stretch" HorizontalAlignment="Right" Width="4" Background="Black"/>
    <Rectangle Grid.Column="1" Fill="LightPink"/>
</Grid>

次のコードは、GridSplitter 専用のセルに配置する例です。グリッドに三つのカラムを用意し、左から順に LighBlue の長方形、GridSplitter, LightPink の長方形を配置します。

<Grid>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="*"/>
        <ColumnDefinition Width="Auto"/>
        <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>
    <Rectangle Grid.Column="0" Fill="LightBlue"/>
    <GridSplitter Grid.Column="1" VerticalAlignment="Stretch" HorizontalAlignment="Center" Width="4" Background="Black"/>
    <Rectangle Grid.Column="2" Fill="LightPink"/>
</Grid>

二つの方式で、GridSplitter の HorizontalAlignment の指定が異なっていることに注意してください。セルを共有する場合は Right または Left を指定するのに対して、GridSplitter 専用のセルに配置する場合は Center を指定します。GridSplitter は VerticalAlignment と HorizontalAlignment の指定によって、縦横どちらの方向に動くか (ResizeDirection)、左右または自分自身のどのセルをリサイズするか (ResizeBehavior) を自動的に決定します*1。VerticalAlignment と HorizontalAlignment を正しく指定しないと、意図したとおりの動作にならない可能性があります。

私が GridSplitter を使っていて困ったのは、グリッドの列の幅 (または行の高さ) を固定値や Auto にするとグリッドの領域を超えて GridSplitter を動かせてしまうことです。次のコードは、最初に示したコードから先頭カラムの Width を 200 に変えたものです。Width = "*" の場合には GridSplitter はグリッドの右端で止まりますが、このコードでは GridSplitter を右側にどこまでも動かせます。

<Grid>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="200"/>
        <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>
    <Rectangle Grid.Column="0" Fill="LightBlue"/>
    <GridSplitter Grid.Column="0" VerticalAlignment="Stretch" HorizontalAlignment="Right" Width="4" Background="Black"/>
    <Rectangle Grid.Column="1" Fill="LightPink"/>
</Grid>

セルを共有する方式の場合には、次のように MaxWidth をグリッドの ActualWidth にバインドすることで解決します。

<Grid Name="Grid">
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="200" MaxWidth="{Binding ElementName=Grid, Path=ActualWidth}"/>
        <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>
    <Rectangle Grid.Column="0" Fill="LightBlue"/>
    <GridSplitter Grid.Column="0" VerticalAlignment="Stretch" HorizontalAlignment="Right" Width="4" Background="Black"/>
    <Rectangle Grid.Column="1" Fill="LightPink"/>
</Grid>

一方、専用のセルに配置する方式では上記の解決方法を使えません。たとえば、次のようにコードを書いても GridSplitter はグリッドの右端で止まりません。先頭カラムの MaxWidth をグリッドの ActualWidth にバインドしても、第二カラムに存在する GridSplitter の幅の分だけグリッドの ActualWidth 自体が大きくなってしまうためです。問題を解決するには、グリッドの ActualWidth から GridSplitter の ActualWidth を引いた値にバインドする必要があります。

<Grid Name="Grid">
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="200" MaxWidth="{Binding ElementName=Grid, Path=ActualWidth}"/>
        <ColumnDefinition Width="Auto"/>
        <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>
    <Rectangle Grid.Column="0" Fill="LightBlue"/>
    <GridSplitter Grid.Column="1" VerticalAlignment="Stretch" HorizontalAlignment="Center" Width="4" Background="Black"/>
    <Rectangle Grid.Column="2" Fill="LightPink"/>
</Grid>

これは二つのバインディングソースを利用するため、IMultiValueConverter を実装するコンバータを作成します。Convert メソッドに必要な計算を実装します。今回の用途では ConvertBack メソッドは使いません。

internal class MaxWidthConverter : IMultiValueConverter
{
    public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
    {
        if (values.Length == 2 && values[0] is double gridActualWidth && values[1] is double splitterActualWidth)
        {
            return gridActualWidth - splitterActualWidth;
        }
        return DependencyProperty.UnsetValue;
    }

    public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
    {
        throw new NotSupportedException();
    }
}

作成したコンバータを利用して、次のように問題を解決できます*2

<Grid Name="Grid">
    <Grid.Resources>
        <local:MaxWidthConverter x:Key="MaxWidthConverter"/>
    </Grid.Resources>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="200">
            <ColumnDefinition.MaxWidth>
                <MultiBinding Converter="{StaticResource MaxWidthConverter}">
                    <Binding ElementName="Grid" Path="ActualWidth"/>
                    <Binding ElementName="Splitter" Path="ActualWidth"/>
                </MultiBinding>
            </ColumnDefinition.MaxWidth>
        </ColumnDefinition>
        <ColumnDefinition Width="Auto"/>
        <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>
    <Rectangle Grid.Column="0" Fill="LightBlue"/>
    <GridSplitter Name="Splitter" Grid.Column="1" VerticalAlignment="Stretch" HorizontalAlignment="Center" Width="4" Background="Black"/>
    <Rectangle Grid.Column="2" Fill="LightPink"/>
</Grid>

*1:ResizeDirection や ResizeBehavior を明示的に指定することもできますが、これらのプロパティの説明を読む限りでは VerticalAlignment と HorizontalAlignment を指定する方法が推奨されているようです。実際、ResizeDirection の既定値は Auto, ResizeBehavior の既定値は BasedOnAlignment です。

*2:Grid.Resources 内にある local は MaxWidthConverter クラスを作成した名前空間を指していることとします。