y_uti のブログ

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

WPF の GridSplitter の実装を調べる

Windows Presentation Framework (WPF) の GridSplitter の動作を理解するため、Reference Source を調べてみました。GridSplitter の動き方は、グリッドの列幅や行高を比率で指定した場合とピクセル値で指定した場合で異なります。Reference Source を読むと、XAML の書き方に応じて GridSplitter の挙動がどう変わるのかを具体的に理解できます。なお .NET Framework のバージョンは記事作成時点での最新版である 4.7.2 を対象としています。
Reference Source - GridSplitter.cs

今回の記事では、次のコードを例として GridSplitter の動作を説明します。WPF アプリケーションプロジェクトを作成して MainWindow.xaml にコードを追加します。

<Grid>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="*"/>
        <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>
    <!-- Column=0 に Rectangle を配置。列幅を確認できるように ActualWidth を表示する -->
    <Rectangle Grid.Column="0" Fill="LightBlue" Name="Rectangle0"/>
    <Label Grid.Column="0" Content="{Binding ElementName=Rectangle0, Path=ActualWidth}"
           VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="36"/>
    <!-- Column=1 に Rectangle を配置。列幅を確認できるように ActualWidth を表示する -->
    <Rectangle Grid.Column="1" Fill="LightPink" Name="Rectangle1"/>
    <Label Grid.Column="1" Content="{Binding ElementName=Rectangle1, Path=ActualWidth}"
           VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="36"/>
    <!-- Column=0 に GridSplitter を配置 -->
    <GridSplitter Grid.Column="0" VerticalAlignment="Stretch" HorizontalAlignment="Right"
                  Width="4" Background="Black"/>
</Grid>

ソリューションをビルドしてプログラムを実行すると次のようなウィンドウが表示されます。GridSplitter を左右に動かすと列幅の数値が変わります。

f:id:y_uti:20181007203358g:plain

このコードでは、GridSplitter を動かせる範囲はウィンドウの左端から右端までになります。右端まで動かすと左側の長方形の幅が 784 になり、左端まで動かすと右側の長方形の幅が 780 になります。左右で異なるのは、左側のカラムに幅 4 ピクセルの GridSplitter を配置したためです。GridSplitter が存在するので左側のカラムは 4 ピクセル未満にはなりません。

ドラッグ開始時の処理

さて、GridSplitter のドラッグを開始すると、static メソッドの OnDragStarted が実行されます*1。ここから、同名のインスタンスメソッドである OnDragStarted を経由して InitializeData が呼ばれます。

InitializeData メソッドでは、GridSplitter 内部のプライベートクラスである ResizeDataインスタンスを生成し、そのフィールドを初期化します。特に以下のフィールドが重要ですので、順に見ていきます。

  • ResizeDirection
  • ResizeBehavior
  • SplitBehavior

ResizeDirection は GridSplitter の動作方向を表すフィールドです。GridResizeDirection 列挙型の Columns と Rows のいずれか一方が設定されます。設定される値は GetEffectiveResizeDirection メソッドによって決められます。GridSplitter の ResizeDirection プロパティに Auto (既定値) が指定されている場合は次の表に従います。Auto 以外の場合はプロパティの値がそのまま設定されます。

HorizontalAlignment VerticalAlignment 設定値
Stretch Stretch ActualWidth ≤ ActualHeight なら Columns, そうでなければ Rows
Stretch Stretch 以外 Rows
Stretch 以外 Stretch Columns
Stretch 以外 Stretch 以外 Columns

ResizeBehavior は GridSplitter によるサイズ変更の対象となる行または列を表すフィールドです。GridResizeBehavior 列挙型の値のうち BasedOnAlignment 以外の一つが設定されます。設定される値は GetEffectiveResizeBehavior メソッドによって決められます。GridSplitter の ResizeBehavior プロパティに BasedOnAlignment (既定値) が指定されている場合は次の表に従います。BasedOnAlignment 以外の場合はプロパティの値がそのまま設定されます。

ResizeDirection HorizontalAlignment VerticalAlignment 設定値
Columns Left PreviousAndCurrent
Columns Right CurrentAndNext
Columns Center または Stretch PreviousAndNext
Rows Top PreviousAndCurrent
Rows Bottom CurrentAndNext
Rows Center または Stretch PreviousAndNext

SplitBehavior は ResizeBehavior で決められた行または列のサイズをどのように変更するかを表すフィールドです。SplitBehavior 列挙型の値が設定されます。設定される値は SetupDefinitionsToResize メソッドによって決められます。上記の ResizeBehavior で決められた行または列の定義 (RowDefinition または ColumnDefinition) をそれぞれ Definition1, Definition2 として、次の表に従います。Split に設定された場合は Definition1 と Definition2 の間でサイズが増減されます。一方、Resize1, Resize2 に設定された場合はそれぞれ Definition1, Definition2 の一方のサイズのみが増減されます。詳細は「ドラッグ中の処理」で後述します。

Defeinition1 の行高または列幅 Definition2 の行高または列幅 設定値
比率での指定 比率での指定 Split
比率での指定 Auto またはピクセル値での指定 Resize2
Auto またはピクセル値での指定 比率指定 Resize1
Auto またはピクセル値での指定 Auto またはピクセル値での指定 Resize1

ここまでの内容のまとめとして、冒頭に示した XAML の場合に各フィールドの値がどのように設定されるのかを確かめてみます。まず ResizeDirection は、GridSplitter のプロパティが既定値の Auto なので、VerticalAlignment が Stretch, HorizontalAlignment が Right であることから Columns に設定されます。次に ResizeBehavior は、これも GridSplitter のプロパティが既定値の BasedOnAlignment なので、ResizeDirection が Columns, HorizontalAlignment が Right であることから CurrentAndNext に設定されます。GridSplitter は Column=0 の列に配置されているので、Definition1 は Column=0 の列に、Definition2 は Column=1 の列にそれぞれ対応付けられます。これらの列の幅はいずれも比率で指定されているので、SplitBehavior は Split に設定されます。

ドラッグ中の処理

GridSplitter のドラッグ中には static メソッドの OnDragDelta が実行されます。ここから、同名のインスタンスメソッドである OnDragDelta を経由して MoveSplitter メソッドが呼ばれます。

MoveSplitter メソッドは、主に以下の処理を行います。

  1. GetDeltaConstraints メソッドを呼び出して GridSplitter を動かせる範囲を計算する
  2. SetLengths メソッドを呼び出してグリッドの行または列のサイズを変更する

これらのメソッドは、どちらも SplitBehavior の値によって処理の内容が変わります。そのため、SplitBehavior の値ごとに一連の処理をまとめて理解するとわかりやすいと思います。以下では、まず SplitBehavior が Split の場合について説明し、次に SplitBehavior が Resize1 の場合について説明します。Resize2 の場合は Resize1 の場合と対称になるだけなので、説明を省略します。

SplitBehavior が Split の場合

SplitBehavior が Split の場合、まず GetDeltaConstraints メソッド内の下記の実装によって GridSplitter の可動範囲が計算されます。ドラッグ操作による変化量を delta として、Definition1 と Definition2 それぞれの行高または列幅に設定された MinLength と MaxLength の制約を満たす範囲を計算します*2

minDelta = -Math.Min(definition1Len - definition1Min, definition2Max - definition2Len);
maxDelta = Math.Min(definition1Max - definition1Len, definition2Len - definition2Min);

MoveSplitter メソッドに処理が戻ると、delta が上記で求めた範囲に収まるように調整します。その後、GridSplitter が delta だけ動いたとして Definition1 と Definition2 の新たなサイズを計算します。この部分の実装は下記のとおりです*3

delta = Math.Min(Math.Max(delta, min), max);
double definition1LengthNew = actualLength1 + delta;
double definition2LengthNew = actualLength1 + actualLength2 - definition1LengthNew;

最後に SetLengths メソッドを呼び出して実際にサイズを変更します。このとき、無関係な行または列に影響を与えないよう、比率で指定されているすべての行または列のサイズが実際のピクセル値に応じた比率で配分されるように変更します。ループを用いて、すべての行または列について下記の実装による処理を行います*4

if (i == _resizeData.Definition1Index) {
    SetDefinitionLength(definition, new GridLength(definition1Pixels, GridUnitType.Star));
} else if (i == _resizeData.Definition2Index) {
    SetDefinitionLength(definition, new GridLength(definition2Pixels, GridUnitType.Star ));
} else if (IsStar(definition)) {
    SetDefinitionLength(definition, new GridLength(GetActualLength(definition), GridUnitType.Star));
}

まとめると、SplitBehavior が Split の場合、つまり GridSplitter の両側の行または列のサイズがどちらも比率で指定されている場合には、それらのサイズだけが変化するよう、一方のサイズが大きくなった分だけもう一方のサイズが小さくなります。また、GridSplitter を動かせる範囲は両方の MinLength, MaxLength によって制限されるので、際限なく動かすことはできません*5

SplitBehavior が Resize1 の場合

SplitBehavior が Resize1 の場合、GetDeltaConstraints メソッドで GridSplitter の可動範囲を計算する処理の実装は以下のようになります。Split の場合とは異なり、Definition1 側の MinLength, MaxLength のみが考慮されます。

minDelta = definition1Min - definition1Len;
maxDelta = definition1Max - definition1Len;

計算された可動範囲に収まるように delta を調整し、Definition1 と Definition2 のサイズを計算する処理は Split の場合と同じです。

SetLengths メソッドでサイズを変更する処理は下記のとおりです。Definition1 のサイズだけが変更されます。Definition2 のサイズは変更されません。

SetDefinitionLength(_resizeData.Definition1, new GridLength(definition1Pixels));

こちらもまとめると、SplitBehavior が Resize1 の場合、つまり GridSplitter の上側または左側のサイズが Auto またはピクセル値で指定されている場合には、そちらのサイズだけが変更されます。GridSplitter の下側または右側のサイズは変更されません。GridSplitter の可動範囲も上側または左側の MinLength, MaxLength のみで判断されるので、MaxLength が指定されていなければ際限なく GridSplitter を動かせます。

冒頭に示した XAML の ColumnDefinition を次のように変更すると、SplitBehavior が Resize1 の場合の動作を確認できます。GridSplitter を動かすと左側のカラムの幅だけが変更されます。また、左側のカラムの MaxLength は既定値の PositiveInfinity なので、マウスカーソルがウィンドウの右端を超えた場合にもカラムの幅は大きくなり続けます。一方、MinLength は既定値の 0 なので、左側に動かした場合はウィンドウの左端で止まります。

<Grid.ColumnDefinitions>
    <ColumnDefinition Width="200"/>
    <ColumnDefinition Width="600"/>
</Grid.ColumnDefinitions>

f:id:y_uti:20181007204859g:plain

SplitBehavior が Resize1 の場合には Definition2 側のサイズ (Width または Height) は変更されませんが、画面上の実際のサイズ (ActualWidth または ActualHeight) は変わり得ることに注意が必要です。上記の XAML 例で右側のカラムの Width を比率での指定に変更します。GridSplitter を動かしても右側のカラムの Width は変更されませんが、これは元々比率で指定されているので、左側のカラムの幅の残りが割り当てられます。

<Grid.ColumnDefinitions>
    <ColumnDefinition Width="200"/>
    <ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>

f:id:y_uti:20181007205521g:plain

ドラッグ完了時の処理

GridSplitter のドラッグが完了したときには、static メソッドの OnDragCompleted が実行されます。今回の記事では ShowsPreview プロパティが true の場合については説明を省略しました。ShowsPreview が true に設定されている場合には、ドラッグ中にはプレビュー用の枠線のみを移動して、ドラッグを完了したときに実際にサイズを変更します。この実装が OnDragCompleted メソッドにあります。詳細な説明は割愛します。

まとめと複雑な例

GridSplitter の動作について Reference Source の実装を追いかけながら説明しました。最後に、これは恣意的な例ですが、グリッドに三つ以上のカラムが存在する場合の動作例を示します。次のようにカラムを定義して、Column=1 の右端に GridSplitter を配置します。この GridSplitter を動かすと Column=0 と Column=1 の境界の位置も変化するという一見奇妙な動きをするのですが、このような動作も GridSplitter の実装を考えると理解できるのではないかと思います。

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

f:id:y_uti:20181007211914g:plain

*1:イベントハンドラを登録する仕組みは GridSplitter.cs の static コンストラクタ に実装されているようです。今回はここまで追うことはできませんでした。

*2:なお、このときに GridSplitter 自身のサイズを考慮して defeinition1Min, defeinition2Min 自体が事前に調整されます。詳細はソースコードを参照してください。

*3:厳密には、連続する 3 行ではありません。コメントや空行を除外して引用しています。

*4:スペースの節約のため、改行のスタイルを変更して引用しています。

*5:MinLength や MaxLength を明示的に指定していない場合でも、一方のサイズが 0 になるところが可動範囲の限界になります。