.NET5.0/C#9.0でオートシェイプ風図形描画ライブラリを作ろう！(Chapter3-2)

※本記事は下記のエントリから始まる連載記事となります。
[.NET5.0/C#9.0でオートシェイプ風図形描画ライブラリを作ろう！(Chapter0)](https://qiita.com/pierusan2010/items/9cd5af84d70fe345c968)

# Chapter3 ドラッグ操作で図形のサイズを変更してみよう(その2）
前回 [ドラッグ操作で図形のサイズを変更してみよう(その1）](
https://qiita.com/pierusan2010/items/3a1e49ee0edb71b36368) からの続きです。

## ソースコード

Capter3の内容は下記ブランチにて実装されています。実装の詳細はこちらをご確認ください。
https://github.com/pierre3/CoreShape/tree/blog/capter3

## 前回のおさらい
前回は図形のサイズ変更用のつまみ「リサイズハンドル」を作成して、図形のサイズ変更をドラッグ操作で行えるようにしました。
しかし、まだ下記のような問題が残っています。

1. 図形が選択状態の時のみリサイズハンドルを表示したいが、常に表示されている
2. ドラッグしているハンドルの反対側の境界を越えてドラッグするとBoundsの幅高さがマイナスになる。  
   その状態になると当たり判定が正常に動作しない

今回はこれらの問題を解決していきたいと思います。

## 選択した図形のみにリサイズハンドルを表示しよう

> 1. 図形が選択状態の時のみリサイズハンドルを表示したいが、常に表示されている

の問題ですが、こちらはIShapeに選択状態か否かを示すプロパティを追加することで対応します。

```cs
public interface IShape
{
    bool IsSelected { get; set; }
    void Draw(IGraphics g);
    HitResult HitTest(Point p);
    void Drag(Point oldPointer, Point currentPointer);
}
```

としたいところなのですが、その前に`IShape`の実装を見直したいと思います。

### `IShape`の機能を分解する
前回、リサイズハンドルも`IShape`を実装するようにしましたが、こちらは選択状態か否かのパラメータを持つ必要がありません。

リサイズハンドルに必要なのは今のところ`Draw()`メソッドと`HitTest()`メソッドのみで、`IsSelected`プロパティや`Drag()`メソッドは不要です。（前回、`Drag()`メソッドは`NotImplementException`をThrowする実装としていました。）

そこで、`IShape`インターフェースを分解して利用するもののみを実装できるようにします。


```cs
//描画可能なオブジェクトのインターフェース
public interface IDrawable
{
    void Draw(IGraphics g);
}
//ドラッグ可能なオブジェクトのインターフェース
public interface IDraggable
{
    void Drag(Point oldPointer, Point currentPointer);
}
//当たり判定チェック機能を提供するインターフェース
public interface IHitTest
{
    HitResult HitTest(Point p);
}
```

そして、`IShape`は上記全て＋`IsSelected`プロパティを実装します。

```cs
public interface IShape : IDrawable, IDraggable, IHitTest
{
    bool IsSelected { get; set; }
}
```

一方、リサイズハンドルは`IShape`ではなく、`IDrawable`と`IHitTest`のみを実装するように変更します。

```cs
public abstract class ResizeHandleBase : IDrawable, IHitTest
{
    //...
}
```

### 選択状態の時のみリサイズハンドルを表示する

それでは、`RectangleShape`に`IsSelected`プロパティを追加してみましょう。

追加したら、`IsSelected`がTrueの場合の時のみリサイズハンドルの `HitTest()`と`Draw()`を実施するように変更します。

```cs
public class RectangleShape : IShape
{
    public bool IsSelected { get; set; }

    //(省略)...
    
    public virtual HitResult HitTest(Point p)
    {
        if (IsSelected) //選択状態の場合のみ
        {
            //リサイズハンドルの当たり判定を実施
            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 Draw(IGraphics g)
    {
        if (Fill is not null)
        {
            g.FillRectangle(Bounds, Fill);
        }
        if (Stroke is not null)
        {
            g.DrawRectangle(Bounds, Stroke);
        }
        if (IsSelected)  //選択状態の場合のみ
        {
            //選択枠とリサイズハンドルを描画
            if (Stroke is null)
            {
                g.DrawRectangle(Bounds, new Stroke(Color.Black, 1));
            }
            ResizeHandles.Draw(g);
        }
    }
}
```

※リサイズハンドルに加えて図形に外接する四角形（=`Bounds`）を選択枠として表示します。
ただし、`RectangleShape`の場合は図形の輪郭と一致するため、輪郭を描画しない（Strokeがnull）の場合のみ描画するようにしています。

後はサンプルアプリ（SampleWPF）側でIsSelectedプロパティを設定する処理を追加するだけですね。

### サンプルアプリ側の実装

SkElementにMouseDownイベントを追加して、マウスの左ボタンをクリックした際にマウスカーソルにヒットしている図形（ActiveShape）のみを選択状態（IsSelected=true）とします。


```xml
[Mainwindow.xml]
...
<Grid>
    <skiaSharp:SKElement x:Name="skElement" 
        PaintSurface="sKElement_PaintSurface" 
        MouseMove="sKElement_MouseMove"
        MouseDown="skElement_MouseDown" />
</Grid>
```

```cs
public partial class MainWindow : Window
{
    //....

    private void skElement_MouseDown(object sender, MouseButtonEventArgs e)
    {
        if (e.LeftButton != MouseButtonState.Pressed)
        {
            return;
        }
        //ActiveShapeだけTrue、それ以外はFalse
        foreach (var shape in shapes)
        {
            shape.IsSelected = shape == activeShape;
        }

        skElement.InvalidateVisual();
    }
```

### 動作確認

これで図形の選択処理の実装は完了しました。アプリを実行して確認してみましょう！

![coreShape_capter3-2a.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/28805/e3fc686b-926a-ac49-0360-807e8faed20d.gif)

うん、いい感じですね。


## ドラック終了時の処理を追加して後処理を実施しよう

次に、下記問題について考えましょう。

> 2. ドラッグしているハンドルの反対側の境界を越えてドラッグするとBoundsの幅高さがマイナスになる。  
>   その状態になると当たり判定が正常に動作しない

実際に動きを見てみると、下記の問題があることがわかります。

- 角リサイズハンドルでマウスカーソルの矢印の向きがおかしい。
- 図形内部にマウスカーソルが入っていてもカーソルの形状が変化しない。

![coreShape_capter3-2b.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/28805/79f71b0d-daf8-1e91-3963-667597177ab9.gif)

- 前者の問題は、幅、高さが反転したことでリサイズハンドルの位置関係が入れ替わってしまうのが原因です。
- 後者の問題は、当たり判定のロジックが、幅、高さがマイナスとなった場合を考慮していないことが原因です。

どちらも、幅・高さがマイナスにならなければ問題は発生しません。また、マイナスになった場合でも図形自体の描画には影響がありません。
ですので、ドラッグ処理を終了した時点で、マイナスになった幅または高さがプラスになるように座標を入れ替えてあげればよさそうです。

 
### `Drop()`処理で座標を補正する

下記の処理を追加して幅・高さが常にプラスの値となるように調整するようにします。

- `IDraggable`インターフェースにドラッグ終了時に実行する処理`Drop()`メソッドを追加
- `Drop()`実行時に幅・高さがマイナスだった場合、（見た目はそのままで）幅高さがプラスの値になるようにBoundsの座標を補正する


```cs
public interface IDraggable
{
    void Drag(Point oldPointer, Point currentPointer);
    void Drop();
}
``` 

```cs
public class RectangleShape : IShape
{
    //...

    public void Drop()
    {
        var (left, top, width, height) = (Bounds.Left, Bounds.Top, Bounds.Width, Bounds.Height);
        //幅がマイナスの場合
        if (Bounds.Width < 0)
        {
            //左右の座標を入れ替えて、幅の符号(-)を取る
            left = Bounds.Right;
            width = Math.Abs(Bounds.Width);
        }
        //高さがマイナスの場合
        if (Bounds.Height < 0)
        {
            //上下の座標を入れ替えて、高さの符号(-)を取る
            top = Bounds.Bottom;
            height = Math.Abs(Bounds.Height);
        }
        SetBounds(new Rectangle(left, top, width, height));
    }
}
```

ちなみに`SetBounds()`メソッドではBoundsに値を設定後、リサイズハンドルの座標をBoundsに合わせて再設定しています。ここでリサイズハンドルの位置関係も正常に戻るはずです。

```cs
protected void SetBounds(Rectangle bounds)
{
    Bounds = bounds;
    ResizeHandles.SetLocation(bounds);
}
```


これでCoreShape側の実装は完了です。サンプルアプリ側で`Drop()`メソッドを実行するように変更して確認してみましょう!

### サンプルアプリ側の実装

SkElementにMouseUpイベントを追加して、ドラッグしていた図形（ActiveShape）の`Drop()`メソッドを呼ぶだけです。


```xml
[Mainwindow.xml]
...
<Grid>
    <skiaSharp:SKElement x:Name="skElement" 
        PaintSurface="sKElement_PaintSurface" 
        MouseMove="sKElement_MouseMove"
        MouseDown="skElement_MouseDown"
        MouseUp="skElement_MouseUp" />
</Grid>
```

```cs
public partial class MainWindow : Window
{
    //....

    private void skElement_MouseUp(object sender, MouseButtonEventArgs e)
    {
        if (e.LeftButton != MouseButtonState.Released)
        {
            return;
        }
        if (activeShape is not null)
        {
            activeShape.Drop();
        }
    }
```

### 動作確認

では、実行してみましょう。

![coreShape_capter3-2c.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/28805/b2c83e8a-a7ac-519b-5012-bc2984734f67.gif)

いい感じです。
マウスカーソルの矢印の向きが入れ替わることがなくなりました。当たり判定もできていますね。

## 次回
次回は、サンプルアプリ（SampleWPF）側の実装について考えてみたいと思います。

- [Chapter4 図形を配置するためのクラスShapePenを作ろう!](https://qiita.com/pierusan2010/items/60f3e63bee5966d7ec20)