[iOS] 8個のコントロールポイントでリサイズできるビューを作ってみた

写真アプリのマークアップ機能では、自由にリサイズ可能な図形が用意されています。
図形には8個のコントロールポイントがあり、それをタッチしてドラッグすると、図形の形を変化させることができます。
また、コントロールポイントではない部分をドラッグすることで図形を移動させることもできます。

![markup.gif](https://qiita-image-store.s3.amazonaws.com/0/81715/32d852ed-d41d-d267-027b-9f01129d80c8.gif)

これと同じようなものを作ってみたので、どうやって作ったかを紹介します。

## 作ったもの
![resizableView.gif](https://qiita-image-store.s3.amazonaws.com/0/81715/e31cb833-dd7d-8030-8689-10fc3cd6f105.gif)

ソースコードはGistに上げました。

[ResizableView.swift](https://gist.github.com/takehilo/c9abbedc5adb7dfda638a581beb4823d)

## 解説
### ビューの構成
四角形部分のビューの構成は以下の通りです。

<img src="https://docs.google.com/drawings/d/e/2PACX-1vSIh_oaD9XK5ndnOO1pWzTkbnrBcYA0pL1ZmLKM892BOEJV7BLbbKH05QNMyCciksAUQBMsWY3vAMor/pub?w=826&amp;h=192">

黒の枠線は背景が黒色のサブビューにCAShapeLayerでマスクをかけることによって表現しています。

なぜルートビューの背景を黒にしてマスクをかけるのではなく、サブビューに対してマスクをかけるのでしょうか？
実際にやってみるとわかりますが、ルートビューに対してマスクをかけてしまうと、マスク部分から少しはみだしているコントロールポイントの円形が欠けてしまいます。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/81715/b7d9921c-945e-3c10-86ac-70bc218b910b.png)

ルートビューに対してマスクをかけると、サブビューであるコントロールポイントもマスクの対象になってしまいます。
そこで、コントロールポイントと同階層のビューを配置し、そのビューにだけマスクをかけるようにします。
こうすることでコントロールポイントのビューはマスクの対象になりません。

### マスク部分のソース
マスクはUIBezierPathとCAShapeLayerで作成します。
作成したレイヤーをマスクをかけたいビューの`layer.mask`プロパティにセットすると、マスクをかけることができます。
注意点としては、**UIBezierPathのパスで描いた部分が表示されること**です。
マスクというとパスで描いた部分で隠すようにイメージしがちですが、そうではありません。

枠線を表現するために、`fillColor`を`nil`にして塗りつぶしをしないようにしています。

```swift
let path = UIBezierPath()
path.move(to: CGPoint(x: halfOfBorderWidth, y: halfOfBorderWidth))
path.addLine(to: CGPoint(x: frame.width - halfOfBorderWidth, y: halfOfBorderWidth))
path.addLine(to: CGPoint(x: frame.width - halfOfBorderWidth, y: frame.height - halfOfBorderWidth))
path.addLine(to: CGPoint(x: halfOfBorderWidth, y: frame.height - halfOfBorderWidth))
path.close()

let maskLayer = CAShapeLayer()
maskLayer.path = path.cgPath
maskLayer.strokeColor = UIColor.black.cgColor
maskLayer.lineWidth = borderWidth
maskLayer.fillColor = nil

borderView.layer.mask = maskLayer
```

### リサイズ部分のソース
ビューをドラッグして移動したりリサイズしたりする処理は、`touchesBegan(_:with)`、`touchesMoved(_:with)`、`touchesEnded(_:with)`に書きました。

ドラッグして動かすだけの実装は前に記事を書きました。

[[iOS] ビューをドラッグして動かす](https://qiita.com/takehilo/items/0ce4d681b009c56d2b1e)

## ルートビューのフレーム計算
リサイズ処理では、ドラッグ位置を使ってルートビューのフレームの計算を行います。
※ここでいうルートビューとは、**リサイズを行うビューの階層におけるルートのこと**です。ViewControllerにおけるルートビューではありません。

ドラッグ位置を移動するたびにフレームを変化させていくので、**リサイズ前のフレームをもとにリサイズ後のフレームを計算する**というところがポイントです。
幅や高さの計算では、リサイズ前の位置からどれくらいの距離ドラッグしたか、というような計算を行うためです。

リサイズ前のフレームは`touchesBegan(_:with)`で取得できます。

```swift
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    guard let touch = touches.first,
        touch.view != nil else { return }

    // リサイズ前のフレームを保持
    frameAtTouchesBegan = frame
}
```

座標の計算でややこしいのは、**どのビューの座標系で計算を行っているかを意識しなければならないこと**です。

例えば、リサイズ処理を行う関数(`resize(control:locationInParentCoordinate:)`)は、ドラッグ位置を親ビューの座標系にしたものを受け取るようにしています。

```swift
let location = touch.location(in: superview)
resize(control: control, locationInParentCoordinate: location)
```

フレームの計算はドラッグ位置とリサイズ前のフレームをもとに行います。
リサイズ前のフレームは親ビューの座標系を使っているので、ドラッグ位置も同じ座標系を使ったほうが計算がしやすくなります。

フレームの計算処理は、どのコントロールポイントをドラッグしているかによって変わります。
コントロールポイント用のビュークラスを用意し、どの位置のコントロールポイントであるかを示す`position`というプロパティを用意することによってこれを実現しています。

```swift
switch control.position {
case .top:
    frame = CGRect(
    ...
case .bottom:
    frame = CGRect(
   ...
```

実際の計算処理はコードを見てもらえればと思いますが、`resize(control:locationInParentCoordinate:)`の中の`isLocationUpperSide`という変数についてだけ説明しておきます。

```swift
let isLocationUpperSide = locationInParentCoordinate.y < frameAtTouchesBegan.origin.y + frameAtTouchesBegan.height
```

例えば、中央上のコントロールポイントを下にドラッグしていくとき、ドラッグ位置がリサイズ前のフレームの下端を越えると図形が反転したような形になります。
ドラッグ位置がリサイズ前のフレームの下端より上にある時(`isLocationUpperSide == true`)は、ドラッグ位置がフレームのY軸上の始点だったのに対し、リサイズ前のフレームの下端を越えた時(`isLocationUpperSide == false`)はドラッグ位置はフレームのY軸上の終点になります。

<img src="https://docs.google.com/drawings/d/e/2PACX-1vToGHd07c6m5BVWdk-GZ4MIOte74FcVCZIbzwaa3WEqfE4zK_05E5yw8uN0Z_xDi89R0VUR7hticcs_/pub?w=592&amp;h=386">

`isLocationUpperSide`はこのような計算を行うための変数です。
`isLocationLeftSide`はX軸についてフレームのリサイズを行う時に使用します。

## コントロールポイントの配置
コントロールポイントの配置処理はシンプルで、ルートビューのフレームが変更されたのに合わせて、それぞれ固定の位置に配置するだけです。
つまり、左上のコントロールポイントであれば、リサイズ後のフレームの左上に位置するように配置すればOKです。
ルートビューのフレームの変更に追随してコントロールポイントの配置を行うには、`layoutSubviews()`でコントロールポイントの再配置を行うメソッド(`relocateControls()`)を呼べばOKです。

一つ注意点、というか認識しておくべきことがあります。
以下のGIFを見てください。

![controlpoint.gif](https://qiita-image-store.s3.amazonaws.com/0/81715/4ce9b839-3c22-30d2-68df-188f5e30de0c.gif)

右下の赤いコントロールポイントをドラッグしていて、それがリサイズ前のフレームの左端を越えたとき、赤いコントロールポイントが右下に戻ってしまっています。
赤いコントロールポイントはフレームの右下に来るように配置しているのでこのような挙動になります。
全てのコントロールポイントの色が同じであれば、ユーザはあたかも同じコントロールポイントをずっとドラッグしているように見えます。