Why?

カスタムコントロールの練習のためです。
ちょっと開発中のアプリで自前で可変サイズのグリッドが必要になったのですが、XamlでGridをゴリゴリ用意するなら、カスタムコントロールのがパフォーマンスは良いだろう、という判断です。

WinFormsの時は、OnPaint を駆使して、ListView, TextBox, ComboBox をカスタマイズしていました。
Win32APIと(隠蔽されていたとはいえ)密接に関わっているため、Windowsの深い知識が必要でしたが、楽しくもありました。
あれはあれで、Windows Developerとしての腕の見せ所でしたでしょう。

Introduction

今回の仕様は

  • UIからグリッドのサイズを変更できる
  • UIから線のサイズを変更できる

の2点です。
なので、MVVMを使っていきます。

サンプルソースは下記になります。

OnRender

WinFormsでは Control.OnPaint メソッドをオーバーライドすることで、コントロールに独自の描画を実施することができます。
が、WPFの場合はControlTemplateがあるので、そういうことはしないでしょう。
では、どういうときに自前で描画するか、というと、パフォーマンスの追求ではないでしょうか?

例えば、数百数千の円を表示するようなグラフのようなコントロールの場合、Ellipse を大量に配置するのはものすごく重くなります。
Shapeオブジェクトは重いのです。ましてや、そのような用途の場合、全オブジェクトに依存関係があることは当然でしょう。さらに重いこと受けあいです。

参考

ですので、そもそもレイアウト処理が発生しない自前描画が最速になることは当然です。
無論、描画する必要の無い場所は描画しない、というのが原則ですが。

脇道にそれましたが、WPFの場合、WinFormsのOnPaintと対となるのは、System.Windows.UIElement.OnRender メソッドになります。
メソッドの名前も意味からして似ていますが、引数も似たようなものです。
引数の System.Windows.Media.DrawingContext は描画系のメソッドを持っています。WinFormsの PaintEventArgs.Graphics と同じです。
なので、このメソッドで描画すれば、そのままコントロールに独自描画できます。簡単!!

仕事で、大量の円や自由曲線、矩形を縦横数千ピクセルの画像上にWinFormsで描画してきた経験から、これをWPFでUIElementで実現しようとしたら死ぬ、というのはすぐにわかります。
というか、アメリカの子会社がUWPですが、これをやってました。無論、お察しのパフォーマンスでした。
特殊なOnPaintをふんだんに駆使したコントロールを移植する場合は、WPFでも自前で描画した方が楽なのかもしれません。

コード解説

カスタムコントロールはプロジェクトに新しい項目の追加から、カスタムコントロール (WPF) を追加するだけです。

サンプルソースのGridControlの中身です。
まずは依存関係です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public static readonly DependencyProperty CellStatesProperty =
DependencyProperty.Register(
"CellStates",
typeof(bool[,]),
typeof(Control),
new FrameworkPropertyMetadata(
null,
FrameworkPropertyMetadataOptions.AffectsRender,
PropertyChangedCallback));

public bool[,] CellStates
{
get { return (bool[,])GetValue(CellStatesProperty); }
set { SetValue(CellStatesProperty, value); }
}

public static readonly DependencyProperty BorderSizeProperty =
DependencyProperty.Register(
"BorderSize",
typeof(int),
typeof(Control),
new FrameworkPropertyMetadata(
1,
FrameworkPropertyMetadataOptions.AffectsRender,
PropertyChangedCallback));

public int BorderSize
{
get { return (int)GetValue(BorderSizeProperty); }
set { SetValue(BorderSizeProperty, value); }
}

CellStates が bool の2次元配列ですが、これは自分の本来の目的のためなので、特に意味はありません。
必要なのは任意のグリッドを表現しているだけです。
BorderSize はグリッドの線の幅です。Controlには BorderThickness プロパティがありますが、これは使いません。
次は、OnRenderの描画処理です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
protected override void OnRender(DrawingContext drawingContext)
{
base.OnRender(drawingContext);

var state = this.CellStates;
if (state == null)
return;

// 縦横の次元数
var v = state.GetLength(0);
var h = state.GetLength(1);

if (v == 0 || h == 0)
return;

// アンチエイリアスによって線によってサイズが変わるのを防ぐ
this.VisualEdgeMode = EdgeMode.Unspecified;

var width = this.ActualWidth;
var height = this.ActualHeight;
var borderSize = this.BorderSize;

// 横、縦線の本数
var hBorderCount = v + 1;
var vBorderCount = h + 1;

// 各セルの幅、高さ
var hSize = (width - (vBorderCount * borderSize)) / h;
var vSize = (height - (hBorderCount * borderSize)) / v;

// 線のブラシ
var linePen = new Pen(Brushes.Black, borderSize);

// ペンの中心だけずらす
var borderGap = borderSize / 2d;

// 線を描画 (外枠以外)
hSize += borderSize; // 横方向の座標移動量
vSize += borderSize; // 縦方向の座標移動量

var vEnd = height - vSize;
var hEnd = width - hSize;

for (var y = vSize + borderGap; y < vEnd; y += vSize)
{
for (var x = hSize + borderGap; x < hEnd; x += hSize)
{
// 縦方向
drawingContext.DrawLine(
linePen,
new Point(x, 0),
new Point(x, height - borderSize));
}

// 横方向
drawingContext.DrawLine(
linePen,
new Point(0, y),
new Point(width - borderSize, y));
}

// 外枠
drawingContext.DrawRectangle(
null,
linePen,
new Rect(borderGap, borderGap, width - borderSize, height - borderSize));
}

Xamlはこんな感じです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>

<Border Grid.Column="0" BorderBrush="Red" BorderThickness="30" VerticalAlignment="Center" HorizontalAlignment="Center">
<controls:GridControl Width="300"
Height="300"
BorderSize="{Binding BorderSize,
Mode=OneWay}"
CellStates="{Binding CellStates,
Mode=OneWay}" />
</Border>

カスタムのGridコントロールの外側に赤い枠があります。
これはカスタムコントロールが正しい位置に描画されているか、というチェックに使います。

ソース中のコメントが割と丁寧なので説明は不要ですが、ポイントは borderGap 変数です。
これがないと描画したとき結果がおかしくなります。ためしに、この変数に0を設定して実行して見てください。

GridControlの外に設置した Border を浸食しています。
左上の0,0から描画したのにこうなるわけは、コメントにあるとおり、Penで描画する際、指定した座標はペンの幅のどこに来るか、ということを考えればわかると思います。
幅が太い場合は顕著ですが、幅の中心が、指定した位置に当たるので、ペン幅の半分だけはみ出る、という訳です。

正しく実行するとこうなります。

Source Code

https://github.com/takuya-takeuchi/Demo/tree/master/WPF1