※本記事は下記のエントリから始まる連載記事となります。
.NET5.0/C#9.0でオートシェイプ風図形描画ライブラリを作ろう!(Chapter0)
Chapter3 ドラッグ操作で図形のサイズを変更してみよう(その1)
今回は下準備として用意するものが多いので、複数回に分割して書きます。
ソースコード
Capter3の内容は下記ブランチにて実装されています。実装の詳細はこちらをご確認ください。
https://github.com/pierre3/CoreShape/tree/blog/capter3
リサイズハンドルを定義しよう
通常図形のサイズを変更する場合、下図の□
マークのような"つまみ"を操作すると思います。
まずは、この"つまみ"を表現するクラスを用意するところから始めましょう。
なお、以降ではこの"つまみ"の事を「リサイズハンドル」と呼ぶことにします。
リサイズハンドルの種別
リサイズハンドルは、その位置によって動作が異なります。また、マウスポインタを重ねた際のカーソルの形状も異ります。
そこで、当たり判定時でヒットした際にマウスポインタが図形のどの部分に位置しているかを示すHitResult
列挙型を定義します。
public enum HitResult
{
None,
ResizeN,
ResizeNE,
ResizeE,
ResizeSE,
ResizeS,
ResizeSW,
ResizeW,
ResizeNW,
Body
}
ResizeXXが各リサイズハンドルの位置に対応しています。
Resizeの後ろのアルファベットは、画面上を北とした際の方角の頭文字を表しています。
(例えば N = North, NE = North East)
また、None
はヒットしていない状態、Bodyは図形の本体部分にヒットした場合を示す値です。
ResizeHandleBase
クラス
次に全てのリサイズハンドルのベースとなる抽象クラスResizeHandleBase
を定義します。
リサイズハンドルも描画や当たり判定などを行う必要がありますので、IShape
インターフェースを実装して作成することとします。
基本的にはRectangleShape
と同じように作りますが、リサイズハンドル用にカスタマイズします。
-
HitResult
プロパティを追加します。 -
HitTest()
メソッドでは、Hitした際に自身のHitResultプロパティの値を返すようにします。
それに伴ってIShapeのHitTestメソッドの戻り値もbool
からHitResult
に変更します
public interface IShape
{
public HitResult HitTest(Point p);
}
public abstract class ResizeHandleBase : IShape
{
public HitResult HitResult {set; protected set;}
public Rectangle Bounds { get; protected set; }
//外観は白で塗りつぶし、黒の輪郭(既定値)
public Stroke? Stroke { get; set; } = new Stroke(Color.Black, 1f);
public Fill? Fill { get; set; } = new Fill(Color.White);
protected ResizeHandleBase(Rectangle bounds)
{
Bounds = bounds;
}
public void Draw(IGraphics g)
{
if (Fill is not null)
{
g.FillRectangle(Bounds, Fill);
}
if (Stroke is not null)
{
g.DrawRectangle(Bounds, Stroke);
}
}
public HitResult HitTest(Point p)
{
return (Bounds.Left <= p.X && p.X <= Bounds.Right
&& Bounds.Top <= p.Y && p.Y <= Bounds.Bottom)
? Type
: HitResult.None;
}
public void Drag(Point oldPointer, Point currentPointer)
{
//リサイズハンドルではDragメソッドは使用しない
throw new NotImplementedException();
}
}
リサイズ処理の追加
リサイズハンドルではDrag
メソッドを使いません。その代わりに(そのリサイズハンドルを持つ親の)図形の座標を変更するResize
メソッドを定義します。
また、図形の座標に追従してリサイズハンドルの位置も更新する必要があるため、これを行うメソッドSetLocation
メソッドも定義します。
public abstract class ResizeHandleBase : IShape
{
public abstract Rectangle Resize(Point p, Rectangle parentBounds);
public abstract void SetLocation(Rectangle parentBounds);
}
基底クラスResizeHandleBase
の定義はこれで完了です。
ResizeHandleN
の実装
それでは、ResizeHandleN
を例に具体的なハンドルの定義に取り掛かりましょう。
ResizeHanldeBase
を継承し、抽象メソッドResize()
メソッドとSetLocation()
メソッドをオーバーライドします。
Resize()
メソッドのオーバーライド
Redizeメソッドではドラッグ先のマウス座標(p)と親となる図形のBounds(parentBounds)を受け取り、リサイズ後のBounds座標を返します。
ResizeHandleN
は Bounds の上辺中央のつまみを表します。
Resizeメソッドでは 上辺の位置がマウスポインタのY座標の位置に移動し、その結果図形の高さも変化します。
public override Rectangle Resize(Point p, Rectangle parentBounds)
{
return new Rectangle(parentBounds.Left, p.Y, parentBounds.Size.Width, parentBounds.Bottom - p.Y);
}
SetLocation()
メソッドのオーバーライド
SetLocationメソッドでは、変更された親図形のBoundsに合わせて自身の位置を再設定します。
ResizeHandleN
では、親となる図形の Bounds の上辺中央に合わせるように設定します。
public override void SetLocation(Rectangle parentBounds)
{
var center = new Point(parentBounds.Left + parentBounds.Size.Width / 2, parentBounds.Top);
Bounds = new Rectangle(
center.X - Bounds.Size.Width / 2,
center.Y - Bounds.Size.Height / 2,
Bounds.Size.Width,
Bounds.Size.Height);
}
他のリサイズハンドルも同様に作成します。
- ResizeHandleN
- ResizeHandleNE
- ResizeHandleE
- ResizeHandleSE
- ResizeHandleS
- ResizeHandleSW
- ResizeHandleW
- ResizeHandleNW
リサイズハンドルをまとめるクラスを作ろう
リサイズハンドルの準備ができましたら、これら8つのハンドルをまとめて処理するためのクラスResizeHandleCollection
を定義します。
各ハンドルはコンストラクタでItemsプロパティにReadOnlyCollection<ResizeHandleBase>
として作成します。
コンストラクタでは、(今のところ)ハンドルのサイズのみが指定できるようにしています。
そして、ハンドルを操作する下記の処理を追加します。
- 親の図形に合わせて自身の位置を更新する
SetLocation()
メソッドはまとめて実行します。 - 描画処理(
Draw()
)もまとめて実施します。 - ヒットテスト(
HitTest()
) は順番に実行し、ヒットした時点でそのハンドルのHitResult
の値を返すようにします。
また、ヒットしたハンドルの参照をActiveHandle
プロパティに保持しておきます。 - リサイズ処理ではActiveHandleに設定されたハンドルのみ
Resize()
メソッドを実行します。
public class ResizeHandleCollection
{
protected IReadOnlyCollection<ResizeHandleBase> Items { get; set; }
public ResizeHandleBase? ActiveHandle { get; protected set; }
public ResizeHandleCollection(float width, float height)
{
Items = new ReadOnlyCollection<ResizeHandleBase>(
new ResizeHandleBase[]
{
new ResizeHandleN(new Rectangle(0, 0, width, height)),
new ResizeHandleNE(new Rectangle(0, 0, width, height)),
new ResizeHandleE(new Rectangle(0, 0, width, height)),
new ResizeHandleSE(new Rectangle(0, 0, width, height)),
new ResizeHandleS(new Rectangle(0, 0, width, height)),
new ResizeHandleSW(new Rectangle(0, 0, width, height)),
new ResizeHandleW(new Rectangle(0, 0, width, height)),
new ResizeHandleNW(new Rectangle(0, 0, width, height))
});
}
public void SetLocation(Rectangle parentBounds)
{
foreach (var handle in Items)
{
handle.SetLocation(parentBounds);
}
}
public HitResult HitTest(Point p)
{
foreach (var handle in Items)
{
var hitResult= handle.HitTest(p);
if (hitResult is not HitResult.None)
{
ActiveHandle = handle;
return hitResult;
}
}
ActiveHandle = null;
return HitResult.None;
}
public Rectangle Resize(Point p, Rectangle parentBounds)
{
return ActiveHandle?.Resize(p, parentBounds) ?? parentBounds;
}
public void Draw(IGraphics g)
{
foreach (var handle in Items)
{
handle.Draw(g);
}
}
}
RectangleShapeにリサイズハンドルを追加しよう
それではハンドルの準備ができましたので、RectangleShape
のメンバーにResizeHandleColection
を追加して
図形の変形(リサイズ)機能を実装してみましょう。
コンストラクタでハンドルの初期化を行ったら、 Draw()
、 HitTest()
、Drag()
メソッドの処理を下記のように書き換えます。処理の詳細はコード内のコメントを参照してください。
public class RectangleShape : IShape
{
//...(省略)
protected ResizeHandleCollection ResizeHandles { get; set; }
public RectangleShape(Rectangle bounds)
{
Bounds = bounds;
HitTestStrategy = new RectangleHitTestStrategy();
//ResizeHandleColection作成後、Boundsに合わせて配置
ResizeHandles = new ResizeHandleCollection(8, 8);
ResizeHandles.SetLocation(Bounds);
}
public RectangleShape(Rectangle bounds, IHitTestStrategy<RectangleShape> hitTestStrategy)
{
Bounds = bounds;
HitTestStrategy = hitTestStrategy;
//ResizeHandleColection作成後、Boundsに合わせて配置
ResizeHandles = new ResizeHandleCollection(8, 8);
ResizeHandles.SetLocation(Bounds);
}
//...(省略)
public virtual void Draw(IGraphics g)
{
if (Fill is not null)
{
g.FillRectangle(Bounds, Fill);
}
if (Stroke is not null)
{
g.DrawRectangle(Bounds, Stroke);
}
//リサイズハンドルをまとめて描画
ResizeHandles.Draw(g);
}
public virtual HitResult HitTest(Point p)
{
//リサイズハンドルにヒットしたらそのハンドルのHitResultを返却
//図形本体にヒットしたらHitResult.Bodyを返却
//ヒットしなかった場合はHitResult.Noneを返却
var hitResult = ResizeHandles.HitTest(p);
if (hitResult is not HitResult.None)
{
return hitResult;
}
return HitTestStrategy.HitTest(p, this) ? HitResult.Body : HitResult.None;
}
public virtual void Drag(Point oldPointer, Point currentPointer)
{
//ResizeHandleがActive(=ドラッグ対象)の場合
//ハンドルのResize()メソッドを実行して、結果をRoundsに設定する
if (ResizeHandles.ActiveHandle is not null)
{
SetBounds(ResizeHandles.Resize(currentPointer, Bounds));
return;
}
//ハンドル以外では、図形全体の移動処理を行う
var (dx, dy) = (currentPointer.X - oldPointer.X, currentPointer.Y - oldPointer.Y);
SetBounds(new Rectangle(Bounds.Left + dx, Bounds.Top + dy, Bounds.Size.Width, Bounds.Size.Height));
}
protected void SetBounds(Rectangle bounds)
{
//図形の座標(Bounds)変更時、それに合わせてリサイズハンドルの位置も更新する
Bounds = bounds;
ResizeHandles.SetLocation(bounds);
}
}
ここまででライブラリ(CoreShape)側準備はできました。
マウスポインタがヒットした部位によってカーソルの形状を変更しよう
WPFアプリ側では大きな変更はありませんが、ヒットした部位に応じてマウスカーソルの形状を変更する必要があります。
MouseMoveイベントハンドラ内でHitTest()
で返却されたHitResult
の値を見て、それに合ったカーソルに変更する処理を追加します。
private void sKElement_MouseMove(object sender, MouseEventArgs e)
{
var p = e.GetPosition(skElement);
var currentPoint = new CoreShape.Point((float)p.X, (float)p.Y);
if (e.LeftButton == MouseButtonState.Pressed)
{
if (activeShape is null)
{ return; }
activeShape.Drag(oldPoint, currentPoint);
skElement.InvalidateVisual();
}
else
{
Cursor = Cursors.Arrow;
activeShape = null;
foreach (var shape in shapes)
{
var hitResult = shape.HitTest(currentPoint);
//ヒットした部位に応じてカーソルの形状を変更する
switch (hitResult)
{
case HitResult.Body:
Cursor = Cursors.SizeAll;
break;
case HitResult.ResizeN:
case HitResult.ResizeS:
Cursor = Cursors.SizeNS;
break;
case HitResult.ResizeE:
case HitResult.ResizeW:
Cursor = Cursors.SizeWE;
break;
case HitResult.ResizeNW:
case HitResult.ResizeSE:
Cursor = Cursors.SizeNWSE;
break;
case HitResult.ResizeNE:
case HitResult.ResizeSW:
Cursor = Cursors.SizeNESW;
break;
}
if (hitResult is not HitResult.None)
{
activeShape = shape;
break;
}
}
}
oldPoint = currentPoint;
}
動作確認
これでひとまず動かせる状態にはなりました。では、実際に動かしてみましょう!
うん。ちゃんと動いていますね。
次回
ここまでで、基本的な動作の実装は完了しました。ですが、これではまだ下記のような問題があります。
- 図形が選択状態の時のみリサイズハンドルを表示したいが、常に表示されている
- ドラッグしているハンドルの反対側の境界を越えてドラッグするとBoundsの幅高さがマイナスになる
その状態になると当たり判定が正常に動作しない
次回はこのあたりの問題を解消するよう実装を進めていきたいと思います。