2024年4月6日土曜日

WPFでFrameworkElementを直接継承したコントロールを作成する

WPFでコントロールを自作することはさほど多くありません。標準コントロールのほかExtended WPF Toolkitなどのライブラリが充実していることに加え、テンプレートやスタイル、その他もろもろの強力な機能により自作コントロールが無くてもかなりの表現ができてしまうからです。
それでももし何かコントロールが必要になったらカスタムコントロールでいくつかのコントロールを集めたコントロールを作ることができます。これでだいたい事足りてしまうのです。

それでも、もっと原始的なコントロールを作りたいことがあったらどうすれば良いでしょう。そんなことは普通は無いと思っていたのですが、WPF縦書きライブラリを作る過程で必要になってしまったので、そのようなときの手段を今回はまとめておきたいと思います。

 

WPFのレイアウトプロセス

まず最初に理解しなければならないのは、WPFにおけるコントロールの配置の仕組みです。多くの人は、Grid / StackPanel / Canvasの3種類のパネルで、コントロールに与えるパラメーターは同じなのにコントロールのサイズなどの配置のされ方が全く違うことを経験したことがあるでしょう。

Grid

<Grid ShowGridLines="True">
    <Grid.RowDefinitions>
        <RowDefinition Height="1*" />
        <RowDefinition Height="1*" />
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="1*" />
        <ColumnDefinition Width="1*" />
    </Grid.ColumnDefinitions>

    <TextBlock Grid.Row="0" Grid.Column="0" Text="Red" Foreground="White" Background="Red" />
    <TextBlock Grid.Row="0" Grid.Column="1" Text="Yellow" Foreground="Black" Background="Yellow" />
    <TextBlock Grid.Row="1" Grid.Column="0" Text="Red" Foreground="White" Background="Blue" />
    <TextBlock Grid.Row="1" Grid.Column="1" Text="Green" Foreground="White" Background="Green" />
</Grid>

StackPanel

<StackPanel Orientation="Vertical">
    <TextBlock Text="Red" Foreground="White" Background="Red" />
    <TextBlock Text="Yellow" Foreground="Black" Background="Yellow" />
    <TextBlock Text="Red" Foreground="White" Background="Blue" />
    <TextBlock Text="Green" Foreground="White" Background="Green" />
</StackPanel>

Canvas

<Canvas>
    <TextBlock Canvas.Left="50" Canvas.Top="50" Text="Red" Foreground="White" Background="Red" />
    <TextBlock Canvas.Left="100" Canvas.Top="50" Text="Yellow" Foreground="Black" Background="Yellow" />
    <TextBlock Canvas.Left="50" Canvas.Top="100" Text="Red" Foreground="White" Background="Blue" />
    <TextBlock Canvas.Left="100" Canvas.Top="100" Text="Green" Foreground="White" Background="Green" />
</Canvas>

それぞれのパネルに4つのTextBlockを配置してみました。一部パネル内のどこに配置するかの添付プロパティは追加していますが、それ以外はどのパネルに対しても同じパラメーターでTextBlockを配置しています。ですが、Gridの場合は右/下方向に引き伸ばされ、StackPanelは右方向のみに引き伸ばされ、Canvasは一切引き伸ばされていません。TextBlockからはパネルのどの位置に配置するのかしか指定しておらず、寸法はパネルの種類によって自動で決まるのです。それ以外にもHorizontalAlignmentプロパティVerticalAlignmentプロパティも配置に影響するのはご存じのとおりです。
これは一体どうやって実現されているのでしょうか。

MeasureOverrideとArrangeOverride

WPFのレイアウトは、Measure(測量)とArrange(配置)という2つのプロセスを経て決定されます。

1. Meausre

親要素が子要素の配置を考えるうえで、子要素に必要な大きさを申告してもらうための手続きです。 

親要素はMeasureが必要になった時に子要素のMeasureメソッドを呼びます。そうすると子要素はDesiredSizeプロパティを更新します。FrameworkElementを継承したクラスを実装するうえでは、MeasureOverrideメソッドをオーバーライドすることでその手続きを実装します。

protected override System.Windows.Size MeasureOverride(System.Windows.Size availableSize);

availableSizeは親要素が提供可能なサイズですので、これをもとに必要サイズを計算し、その値を返します。

2. Arrange

親要素はDesiredSizeをもとに各子要素の配置を決定し、子要素に通知する手続きです。

親要素は子要素の配置が決まると、子要素のArrangeメソッドを呼びます。そうすると子要素はRenderSizeプロパティを更新します。FrameworkElementを継承したクラスを実装するうえでは、ArrangeOverrideメソッドをオーバーライドすることでその手続きを実装します。

protected virtual System.Windows.Size ArrangeOverride(System.Windows.Size finalSize);

finalSizeは実際に自分に割り当てられた大きさですので、これをもとに自身の配置を制御します。返却値は実際のサイズになりますが、まあ、普通は割り当てられたサイズそのままになると思いますので、そのような場合はこのメソッドを敢えてオーバーライドする必要はありません。FrameworkElementがこの返却値をもとにRenderSizeプロパティを更新してくれます。

子要素の申告でレイアウト変更を行う

ここまで説明してきたのは、すべて親要素起点のレイアウト変更です。例えばウィンドウサイズの変更による配置変更などですね。ただ、コントロールを実装するうえでは、自身のサイズが変わるなどして親要素にレイアウト変更を依頼しなければならないことが出てきます。例えば表示する文字列が変わった、文字サイズが変わったなどですね。そのような場合はどうすれば良いのでしょうか。

一つの方法としては、InvalidateMeasureメソッドを呼ぶことです。これにより現在のMeasureの結果を無効なものとし、再度レイアウトプロセスを実行することを促せます。
ただしMSDNの説明文にも書いてある通り、このメソッドの頻繁な呼び出しはパフォーマンスに大きな影響を与えるため、可能な限り呼び出しは避けるべきと書かれております。もっと良い方法がほかにあるのでしょうか。

前述したとおり、レイアウト変更が必要になるケースとして、文字列の変更や文字サイズの変更などが考えられます。それらは、一般にコントロールの依存関係プロパティ(Dependency Property)として外に公開されていることが多いです。そこに、そのプロパティがMeasureに影響する項目かどうかを設定する機能があるのです。

public bool AffectsMeasure
{
    get { return (bool)GetValue(AffectsMeasureProperty); }
    set { SetValue(AffectsMeasureProperty, value); }
}
public static readonly DependencyProperty AffectsMeasureProperty =
    DependencyProperty.Register(nameof(AffectsMeasure), typeof(bool), typeof(FrameworkElementDerivedClass),
    new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.AffectsMeasure));

依存関係プロパティを実装するときはだいたいこんな形になると思います。
このFrameworkPropertyMetadataに渡している引数に注目です。FrameworkPropertyMetadataOptions.AffectsMeasureというのを渡しています。こうすることで、このプロパティが変化したときに自動的にMeasureプロセスが実施されるようになります。
他にもAffectsArrange、AffectsParentMeasure、AffectsParentArrangeがあり、名前の通りです。

 

描画処理

レイアウトに対応できるようになったところで、次は描画処理です。
これもいろいろな方法があるようですが、一番シンプルでかつ上のレイアウト変更と親和性の高い方法を紹介します。

OnRenderメソッド

FrameworkElementにはOnRenderメソッドがあり、これをオーバーライドするのが最もシンプルです。

protected virtual void OnRender(System.Windows.Media.DrawingContext drawingContext);

このメソッドでDrawingContextが渡されます。これがいわゆるWin32で言うところのデバイスコンテキストハンドルみたいなもので、これに対して描画操作をすることで画面に描画することができます。

DrawingContextが持っているメソッドのうちDrawから始まるものを見ればわかりますが、直線、四角形、楕円、ジオメトリ、画像、文字列、グリフなど一通りの描画メソッドを持っています。
また、Pushから始まるものを見ると、クリッピング、Opacity(透明性)、Transform(図形変換)などもサポートしています。Popメソッドもあある通りスタック構造をしており、PushしてからPopされるまでの間それらの処理が適用されるようです。

再描画指示

さて、上述のレイアウトプロセス(Measure→Arrange)の後に自動的にOnRenderが呼ばれるのは言うまでも無いですが、それ以外のときも再描画したいシチュエーションはあります。
これもレイアウトプロセスと同じで、InvalidateVisualメソッドを呼び出して再描画させることができますが、同様にパフォーマンスに影響を与えるので基本は呼ぶべきではないものです。
描画に影響を与えるプロパティにFrameworkPropertyMetadataOptions.AffectsRenderオプションを渡してやれば良いでしょう。


プロパティ値の継承

WPFは依存関係プロパティの値の継承機能があります。一番身近なのはDataContextで、親要素で設定したDataContextが子要素のDataContextにアクセスすることで触れるのはよく知られていると思います。これはDataContextだけでなく、例えばフォントサイズなども親要素で設定したら子要素にも伝搬します。これを「プロパティ値の継承(Property value inheritance)」と言います。

そのような依存関係プロパティを実装する際は、FrameworkPropertyMetadataのコンストラクタにFrameworkPropertyMetadataOptions.Inheritsを渡せば良いです。こうすることで、親要素で設定されたものが子要素に伝搬してきます。

ちなみに、例えばフォントファミリーやフォントサイズなどの文字列関係のプロパティは、TextElementクラスなんかが使えるようです。このクラスに文字列関係の依存関係プロパティのフィールドがありますので、それを使えば簡単にフォント関係のプロパティ値の継承ができます。

public FontFamily? FontFamily
{
    get { return (FontFamily?)GetValue(FontFamilyProperty); }
    set { SetValue(FontFamilyProperty, value); }
}
public static readonly DependencyProperty FontFamilyProperty = TextElement.FontFamilyProperty.AddOwner(typeof(TategakiText));

この依存関係プロパティはちゃんと上述のAffects***などのオプションもちゃんと実装されているようで、これだけでレイアウトやレンダリングも問題なしです。

 

まとめ

さて、多分滅多に使うことのない、WPFのレイアウトシステムと描画処理に関する実装の仕方をまとめました。滅多に使うことが無いからかちゃんとまとまって解説してくれている資料があまり無いんですよね。この点からも.NETのソースコードが公開されるようになったのは、それを見ることである程度どんな感じか追えるのでとても助かります。

最後に、こうやって作られたのがWPF縦書きライブラリ Tategakiです。こっちもよろしくね!

0 件のコメント:

コメントを投稿