詳解UIScrollView 〜フォトクロップ編〜

# はじめに
本記事は[詳解UIScrollView 〜フォトビューワ編〜](https://qiita.com/shiba1014/items/ca0ac8d3a4a82a857bba)の続編です。UIScrollViewの説明やスクロール・ズームの実装はこちらの記事をご参照ください。上述の記事で実装できるフォトビューワがこちらです。
![photo_viewer.gif](https://qiita-image-store.s3.amazonaws.com/0/64665/d7728c30-f652-a006-b491-bd05f582ff6c.gif)

本記事は、ここから少し発展したフォトクロップの実装ガイドラインです。

# フォトクロップ画面の実装
フォトクロップとは、プロフィール画像の設定時などで利用される切り抜き機能を指します。
<img width="375" alt="IMG_7889.PNG" src="https://qiita-image-store.s3.amazonaws.com/0/64665/43d1ed97-6487-5a9d-f1ea-01ad32b67f1a.png">

画像のスクロール・ズームに加え、画像を切り抜く枠内へのスクロール制限が必要になります。スクロールの制限を調整しないとうまく切り抜き範囲を指定しづらくなります。こちらの動画がわかりやすいと思います。
<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">iOS標準設定アプリのフォトクロップ画面が微妙すぎるからプルリク送りたい <a href="https://t.co/sbDdH8fDIc">pic.twitter.com/sbDdH8fDIc</a></p>&mdash; ハシバサツキ (@shipaaan) <a href="https://twitter.com/shipaaan/status/1072764717586821121?ref_src=twsrc%5Etfw">2018年12月12日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

画像の位置によってはフレーム内に余白が含まれてしまい、切り抜き後に画像が押しつぶされたり意図しない位置で切り取られたりしてしまいます。これを避けるために、`contentInset`で余白を調整し、スクロール可能領域を制限しましょう。

## クロップフレームの作成
まず、画像を切り抜く枠を作成します。これはユーザにクロップ範囲を示すとともに、UIScrollViewのスクロール可能領域の指定にも利用します。

### A. IBでの作成
UIViewを配置し、任意のAutoLayoutを貼り付けます。IB上で視認しやすいように色をつけていますが、のちにコード側で背景色を透明にするのでそのままでも構いません。
<img width="671" alt="constraints.png" src="https://qiita-image-store.s3.amazonaws.com/0/64665/e81419d1-4b1a-04a0-4bc0-bceb58a0ba35.png">

このビューはUIScrollViewと縦横を合わせましょう。よく設定する`Horizontally/Vertically in Container`を貼るとステータスバーなどが起因してクロップフレームと画像がズレたりします。中央でない場所に配置したい場合には、後述の`contentInset`の調整を変えてください。

IB上での設定が終わったらコード側でクロップフレームを宣言し、関連付けします。これの背景色を透明にして境界線をつけましょう。このままだとcropFrameView上でのピンチやスワイプがUIScrollViewに伝搬しないので、cropFrameViewの`isUserInteractionEnabled`を`false`にしてあげる必要があります。

```swift
@IBOutlet weak var cropFrameView: UIView!

override func viewDidLoad() {
    super.viewDidLoad()

    cropFrameView.backgroundColor = .clear
    cropFrameView.layer.borderWidth = 3.0
    cropFrameView.layer.borderColor = UIColor.white.cgColor
    cropFrameView.isUserInteractionEnabled = false
}
```

### B. コードでの作成


このようにクロップフレームが表示されました。ですが現状では画像の端の方は切り抜けなかったり余白が含まれたりしてしまうので、いくつか修正する必要があります。

## subviewの調整
まずは画像のサイズを調整しましょう。前回の記事では画像をUIScrollView内に`.scaleAspectFit`と同じように表示できる倍率をminimumZoomScaleにしましたが、今回はクロップフレームに対し`.scaleAspectFill`と同じように表示できる倍率をminimumZoomScaleに設定します。
<img width="500" alt="aspect.png" src="https://qiita-image-store.s3.amazonaws.com/0/64665/af7907f6-fdf7-836a-311f-1efdda61f4a1.png">


UIScrollViewのzoomScaleを設定している箇所を修正します。コメントアウトが修正前のコードです。

```swift
// let widthScale = scrollView.bounds.width / image.size.width
// let heightScale = scrollView.bounds.height / image.size.height
let widthScale = cropFrameView.bounds.width / image.size.width
let heightScale = cropFrameView.bounds.height / image.size.height

let scale = max(widthScale, heightScale)
scrollView.minimumZoomScale = scale
scrollView.maximumZoomScale = scale * 5
scrollView.zoomScale = scrollView.minimumZoomScale
```

このように、クロップフレームに合わせて画像が表示されるようになりました。
<img width="414" alt="cropFrame.png" src="https://qiita-image-store.s3.amazonaws.com/0/64665/02d22181-3873-1811-b70e-352a54dc1faf.png">

## `contentInset`の調整
このままだとUIScrollViewに余白が設定されていないので、画像をクロップフレームの端までスクロールすることができません。
![bad_contentInset.gif](https://qiita-image-store.s3.amazonaws.com/0/64665/38d3120c-3363-5f2e-8202-87dc0054b299.gif)

そこでcontentInsetを更新し、スクロール可能領域を増やしましょう。

```swift
// let widthInset = max((scrollView.frame.width - imageView.frame.width) / 2, 0)
// let heightInset = max((scrollView.frame.height - imageView.frame.height) / 2, 0)
let widthInset = max((scrollView.frame.width - cropFrameView.frame.width) / 2, 0)
let heightInset = max((scrollView.frame.height - cropFrameView.frame.height) / 2, 0)
scrollView.contentInset = .init(top: heightInset,
                                left: widthInset,
                                bottom: heightInset,
                                right: widthInset)
```

イメージとしては、画像の周りにこんな風に余白がついています。余白の端までUIScrollViewをスクロール可能になるので、結果として画像をクロップフレーム内でスクロールできる挙動になります。
<img width="600" alt="contentInset.png" src="https://qiita-image-store.s3.amazonaws.com/0/64665/b64538b1-0471-6513-5313-a3d7ca052a95.png">

[前回の記事](https://qiita.com/shiba1014/items/ca0ac8d3a4a82a857bba)では`scrollViewDidZoom`メソッド内で毎回contentInsetの更新を行なっていましたが、今回はsubviewであるUIImageViewの大きさに関わらず一定なので更新しなくて大丈夫です。メソッドごと消してしまっても構いません。

枠の端までスクロールできるようになりました！あとは、初めは画像がクロップフレームの中央に表示されるように初期位置を調整しましょう。
![without_scrollRectToVisible.gif](https://qiita-image-store.s3.amazonaws.com/0/64665/81d184b4-2f88-7230-b1ba-35fe1e9f7121.gif)

## subviewの位置調整

表示領域の調整は`scrollRectToVisible`メソッドを使って行います。これは、引数にCGRectを渡し、コンテンツ内のその領域がUIScrollViewの左上角に表示されるように移動させるメソッドです。
Apple公式ドキュメントには「コンテンツ内の指定した領域がUIScrollViewに表示されるようにスクロールする。すでに表示されている場合は何もしない」と書いてありますが、実際には指定した領域が左上に表示されるような挙動をしているように見えます。指定領域が左上に表示されるという解釈が正しいとすると、ここで指す「コンテンツ」には、**subviewした画像だけでなくcontentInsetも含まれている**と考えられます。
<img width="600" alt="scrollRectToVisible.png" src="https://qiita-image-store.s3.amazonaws.com/0/64665/f799f86a-5de1-06b6-2a10-5d857f03c2d6.png">

なのでこのように指定すると、クロップフレームのちょうど中央に画像が表示されます。

```swift
scrollView.scrollRectToVisible(.init(x: (imageView.frame.width - cropFrameView.frame.width) / 2,
                                     y: (imageView.frame.height - cropFrameView.frame.height) / 2,
                                     width: cropFrameView.frame.width,
                                     height: cropFrameView.frame.height),
                               animated: false)
```

### `scrollRectToVisible` v.s. `contentOffset`
前回の記事では触らなかった`contentOffset`を使って画像を中央に寄せることもできます。前記事の文中では

> 基本的には、現在UIScrollViewがどの領域を表示しているのかを取得するために使いましょう。ScrollToTopのような挙動をさせたい場合を除いて、プログラムから変更すべきではありません。

と説明しましたが、今回はスクロール可能領域はそのままにUIScrollViewの表示領域のみを移動させたいので、contentOffsetを変更してもよいと思います。
しかしながら、contentOffsetにはcontentInsetの値は反映されません。contentOffsetはUIScrollViewの左上角がsubview(UIImageView)のどこに存在するかを返すので、見たままの位置を指定する必要があります。どういうことか、コードを踏まえて解説します。

```swift
func updateContentOffset() {
    let widthMargin = -(scrollView.frame.width - imageView.frame.width) / 2
    let heightMargin = -(scrollView.frame.height - imageView.frame.height) / 2
    scrollView.setContentOffset(.init(x: widthMargin, y: heightMargin), animated: true)
}
```

このように、クロップフレームの中心ではなく、UIScrollViewの中央にUIImageViewを表示するというコードを書かなければなりません。これはコードの可読性を下げ、仕様変更の際にレイアウトが壊れてしまう可能性が高いです。したがって、私は`scrollRectToVisible:animated:`の利用をお勧めします。

## 完成
これでフォトクロップ画面が完成しました！
![photo_crop.gif](https://qiita-image-store.s3.amazonaws.com/0/64665/8fec8057-49e0-6a02-f02d-e0c7060add36.gif)


# まとめ
この記事では[詳解UIScrollView 〜フォトビューワ編〜](https://qiita.com/shiba1014/items/ca0ac8d3a4a82a857bba)の続編として、写真切り抜き画面などに見られるUIScrollViewの作成を解説しました。なかなか複雑な部分ですが、図を書いてみると少し理解しやすくなると思います。UIScrollViewはユーザ体験を大きく左右する箇所だと思うので、この記事が少しでも実装の手助けになれれば光栄です :angel: