詳解UIScrollView 〜フォトビューワ編〜

# はじめに
iOS開発をしていて手こずるものの一つに`UIScrollView`の実装があります。例としては、フォトビューワやプロフィール画像の切り抜きなどが挙げられますね。サービスをリッチするにあたって実装したくなる機能ですが、かなり複雑で、綺麗に動くよう実装するのは非常に困難です。[iOS標準アプリですら微妙な挙動だったりします](https://twitter.com/shipaaan/status/1072764717586821121)。しかしながら、学習コストの割には機能の重要度が低いかもしれません。したがってこの記事では、UIScrollViewについて説明するとともに、フォートビューワを例に、様々な実装方法（IB使用/不使用）を解説するガイドラインを目指します。

本記事は[詳解UIScrollView 〜フォトクロップ編〜](https://qiita.com/shiba1014/items/eba945b8d2d41322d545)に続きます。

# UIScrollViewとは
UIScrollViewとは、subviewされている内容物をスクロールしたりズームしたりできるUIコンポーネントです。ユーザが画面上をスクロールするとUIScrollViewが移動し、表示領域が変化します。つまり、**UIScrollViewは画像の一部分を表示する窓**のような部品と言えます。
![NSImageView_UIScrollView.jpg](https://qiita-image-store.s3.amazonaws.com/0/64665/f950447d-3ec3-47d8-9542-307cc0084b7d.jpeg)



## 基礎知識
UIScrollViewを実装するにあたって、次の3つのプロパティが重要になってきます。

- `contentSize`
- `contentInset`
- `contentOffset`

### contentSize
contentSizeとは、スクロールさせたいコンテンツがどれくらいの大きさか示す`CGSize`です。このプロパティにより、スクロール可能領域が決定されます。UIScrollViewを実装するには最低限このプロパティは指定しなければいけません。（とAppleのドキュメントには記載がありますが、特殊なことをしなければよしなにやってくれます）

### contentInset
contentInsetは`UIEdgeInsets`型のプロパティです。これを指定してあげると、スクロール可能領域に**余白**をつけることができます。余白については、実際に見てみると理解しやすいと思います。
まずはcontentInsetのrightに`50`を指定してみましょう。そうすると、右端に余白が現れます。スクロール可能領域が画像の右端から50pt分追加されているのです。(わかりやすいようにUIScrollViewの背景に色をつけています)
![right=50.gif](https://qiita-image-store.s3.amazonaws.com/0/64665/dcd59b34-e76f-034e-a23e-8744675a137e.gif)

contentInsetのrightを`-200`にすると、今度は一番右端までスクロールすることができません。スクロール可能領域が画像の右端から200pt分減っています。
![right=-200.gif](https://qiita-image-store.s3.amazonaws.com/0/64665/0d49ab4b-3185-1843-f692-337cce44cf20.gif)

これがスクロール可能領域の余白です。

では、contentInsetとはどのような時に使うのでしょうか？例えばviewいっぱいにUIScrollViewを乗せた場合に、ナビゲーションバーやツールバーなどが上に被さってしまう可能性があります。この時、ユーザはコンテンツを端まで見ることができません。こういった場面でcontentInsetを設定します。
<img width="703" alt="スクリーンショット 2018-12-19 12.52.13.png" src="https://qiita-image-store.s3.amazonaws.com/0/64665/20067683-ab30-d054-1be7-a1949da53b72.png">

64pt（ナビゲーションバーの高さ）をcontentInsetのtopに指定すると、コンテンツの上端から64ptだけ余分にスクロールできるようになります。こうすることで、コンテンツがナビゲーションバーに被ることなく全て表示させることができるのです。

### contentOffset
contentOffsetは、コンテンツ上でUIScrollViewがどこにあるかを示す`CGPoint`です。UIScrollViewの左上角の座標が入っています。一番最初に記述した通り、UIScrollViewはコンテンツを表示する窓で、その窓を移動させることでスクロールを表現しています。コンテンツ上での窓の座標を示すのがこのcontentOffsetになります。
<img width="445" alt="contentOffset.png" src="https://qiita-image-store.s3.amazonaws.com/0/64665/b05b3e0b-d7bd-bb2e-45b2-965d28146c24.png">
基本的には、現在UIScrollViewがどの領域を表示しているのかを取得するために使いましょう。ScrollToTopのような挙動をさせたい場合を除いて、**プログラムから変更すべきではありません**。

# フォトビューワの実装
それでは実際にUIScrollViewを用いてフォトビューワを実装してみましょう。

## 実装フローチャート
この記事ではInterfaceBuilder使用/不使用の2パターンの実装方法を紹介しています。それぞれで重複する箇所が出てきますので、開発の際にはこのフローチャートを参考にしてください。
<img width="600" alt="flowchart.png" src="https://qiita-image-store.s3.amazonaws.com/0/64665/a0d04c07-6619-160f-0b5c-d83921208370.png">

## UIScrollViewの作成
まず、土台となるUIScrollViewを作成します。IBを使う方法・使わない方法の二種類を紹介します。

### A. IB (InterfaceBuilder) で作成
ライブラリからUIScrollViewを選択し、view上に配置しましょう。レイアウトはどのようにしても構いませんが、ここでは画面いっぱいにUIScrollViewが配置されるように制約を張り付けます。
<img width="445" alt="scrollView_addConstraints.png" src="https://qiita-image-store.s3.amazonaws.com/0/64665/1a1c9437-6bce-ccd5-a912-291a39702b84.png">


### B. コードで作成
`viewDidLoad()`なり任意のメソッド内でUIScrollViewを作成します。subviewの作成については次のセクションで説明します。上述の通り、サイズはなんでも良いですがここでは画面いっぱいで作成します。

```swift
let scrollView = UIScrollView(frame: CGRect(origin: .zero, size: view.bounds.size))
// 後ほどsubviewの作成をここで行う
view.addSubview(scrollView)
```

## subviewの作成
UIScrollViewの作成後、その上に載せるsubviewを作成します。このsubviewは画像などの**スクロール対象のコンテンツ**のことで、今回は画像を表示するUIImageViewになります。

### A. IBで作成
ライブラリからUIImageViewを選択し、先ほど設置したUIScrollViewの上にドラッグ&ドロップしましょう。画像のように、UIScrollViewの中にUIImageViewを入れます。
<img width="311" alt="subview_IB_1.png" src="https://qiita-image-store.s3.amazonaws.com/0/64665/dcc3de52-0245-2811-40fc-cb78db9b19cd.png">
追加したUIImageViewに対してAutoLayoutの制約をつけていきます。まず、UIScrollViewとの4辺のspacingを0に設定します。
<img width="552" alt="subview_IB_2.png" src="https://qiita-image-store.s3.amazonaws.com/0/64665/2dd424ac-0a81-993d-6315-4c784707182e.png">
この状態では`Need constraints for: X(Y) position or width(height)`というエラーが出るので、UIImageViewとUIScrollViewに対し`Equal Height`, `Equal Width`の制約をつけます。
<img width="383" alt="subview_IB_3.png" src="https://qiita-image-store.s3.amazonaws.com/0/64665/1171d08a-4785-489a-4a97-44c3b17f177f.png">
この2つの制約のpriorityを`250(Low)`に指定すれば完了です。
<img width="270" alt="subview_IB_4.png" src="https://qiita-image-store.s3.amazonaws.com/0/64665/f80e732e-105b-25dc-421a-8ba81c44d3c7.png">

あとは、コード上でUIImageViewにUIImageをセットしましょう。画像が固定で、その設定もIB上でやりたい！という場合は、`Equal Height`, `Equal Width`の制約を削除し、UIImageViewに画像を指定しましょう。自動的に画像の大きさに合わせてリサイズされます。
<img width="582" alt="subview_IB_5.png" src="https://qiita-image-store.s3.amazonaws.com/0/64665/466f5455-eda4-d41c-e877-c55c772962cc.png">


#### AutoLayoutの解説
親のviewがUIViewであれば、subviewとの制約は4辺のspacingゼロ指定のみでエラーが発生することなく配置できます。これは、UIViewにおけるspacingが「対象のview同士の距離」を示しているため、4辺の制約のみでsubviewの大きさを決定することができるからです。しかしながら、UIScrollViewにおけるspacingの設定は少し異なります。

はじめに述べたように、UIScrollViewとはコンテンツを表示する枠です。なので、通常UIScrollViewのサイズはsubview（コンテンツ）よりも小さくなります。なのでUIScrollViewにおいては、spacingは対象のview同士の距離ではなく、**「subviewの端から何ptスクロールできるか」**を示しています。したがって、rightのspacingを`200`にすると、200ptだけ余分にスクロールすることができます。逆もまた然りです。つまり、**UIScrollViewとsubview間のspacingは、`contentInset`のような役割**をしています。
contentInsetだけではsubviewの大きさは確定できませんから、先ほどのように`Equal Height`, `Equal Width`の制約をつける必要があります。しかしながらこのままだと、UIImageViewに画像をセットした後にも`Equal Width/Height`の制約が優先されてしまうので、UIImageViewがUIScrollViewとぴったり同じ大きさになってしまいスクロールができません。これを解決するために`Equal Width/Height`のPriority(優先度)を下げ、後から設定する画像のサイズを採用するようにします。

### B. コードで作成

先ほど記述したコードに追加して、このようにsubviewを作成します。IBと比べると随分シンプルに配置できます。

```swift
let scrollView = UIScrollView(frame: CGRect(origin: .zero, size: view.bounds.size))
let imageView = UIImageView(image: UIImage(named: "Image"))
scrollView.contentSize = imageView.bounds.size
scrollView.addSubview(imageView)
view.addSubview(scrollView)
```

UIScrollViewをIBで作成した場合には、initやaddSubviewのコードは必要ありません。

以上の手順で、スクロールのみが行えるUIScrollViewが実装できます。ページングを加えれば、チュートリアルなどで利用できそうですね。
![scroll_only.gif](https://qiita-image-store.s3.amazonaws.com/0/64665/12ef96cb-bf74-c0b0-249e-75183799af20.gif)


## ズームの実装
次に、ピンチジェスチャによるズームについて解説します。ピンチジェスチャとは、二本指を開いたり閉じたりするジェスチャで、コンテンツの拡大縮小を行う際のApple標準のジェスチャです。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/64665/e092d613-7b11-4866-fc7f-3a9617e758c4.png)
ピンチイン・アウトやズームジェスチャを実装する際にはデリゲードの実装と、 `minimumZoomScale` または `maximumZoomScale` の設定が必要になります。デリゲートはコードでしか扱えないので、コード側でUIScrollViewを宣言しなければいけません。

### デリゲートの実装
ズームを行うにはUIViewControllerに`UIScrollViewDelegate`を継承し、`viewForZooming`メソッドを実装する必要があります。`viewForZooming`ではズーム対象のビューを返り値に渡してあげます。

```swift
extension ViewController: UIScrollViewDelegate {
    func viewForZooming(in scrollView: UIScrollView) -> UIView? {
        return imageView
    }
}
```

### プロパティの設定
デリゲートを実装した後は、UIScrollViewのプロパティを設定します。ズームするには`minimumZoomScale`または `maximumZoomScale`どちらかの設定が必須です。デリゲート先の指定も忘れずに行いましょう。

```swift
scrollView.minimumZoomScale = 0.5
scrollView.maximumZoomScale = 5
scrollView.delegate = self
```

以上の設定で、ズーム可能なUIScrollViewが作成できます。
![zoom.gif](https://qiita-image-store.s3.amazonaws.com/0/64665/10919905-c8f7-5fcc-8ad5-9b6cc395d070.gif)


## subviewの調整
スクロールとズームだけでは実際のフォトビューワとして至らない点がいくつかあるので、調整していきます。

### サイズの調整
このままでは、画面を開いた時に画像の左上一部分しか表示されません。フォトビューワのように実装したい場合には、画像の全体が見えるサイズで画面の中央に表示させたいですよね。そこで、コンテンツの調整を行なっていきます。
まずは画像の大きさを画面サイズに合わせるために、UIScrollViewの`zoomScale`を更新します。ここはこれまでIBで設定したかどうかに限らずコードで書きます。

```swift
let widthScale = scrollView.bounds.width / image.size.width
let heightScale = scrollView.bounds.height / image.size.height
let scale = min(widthScale, heightScale)
        
scrollView.minimumZoomScale = scale
scrollView.maximumZoomScale = scale * 5

// After setting minimumZoomScale, maximumZoomScale and delegate.
scrollView.zoomScale = scrollView.minimumZoomScale
```

`scale`では画像の大きさをどちらの辺に合わせるかを決定しています。上記のコードは`.scaleAspectFit`のような表示になり、`min()`を`max()`に置き換えると`.scaleAspectFill`のような表示になります。
`zoomScale`には`minimumZoomScale`より小さい値、もしくは`maximumZoomScale`より大きい値を設定することはできません。IB側で設定しない限りデフォルトの値はどちらも`1.0`なので、先にこれらを更新してあげる必要があることに注意してください。
また、delegateを指定する前に`zoomScale`を設定することはできないので、こちらも先にdelegateを指定します。

### 位置の調整
最後に、画像の位置が中央に表示されるようにしましょう。横方向もしくは縦方向に画像が存在しない余白がある場合、その軸の真ん中に画像を表示させます。`contentInset`で余白を指定し、位置の調整を行いましょう。

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

UIScrollViewとUIImageViewのセットアップ後にこちらのメソッドを呼び出すと、それぞれの大きさに応じて余白を作ってくれます。`width/heightInset`は`max()`関数を使って`0`を下回らないようにしています。これは、ズーム後にUIImageViewがUIScrollViewよりも大きくなった場合、contentInsetに負の値がセットされ、画像の端までスクロールできない挙動を防ぐためです。

しかしながらこのままでは、contentInsetが初期状態のままなのでズーム後も余白が残ってしまいます。なので、ズームをするたびにcontentInsetを更新してあげる必要があります。

![photo_viewer_margin.gif](https://qiita-image-store.s3.amazonaws.com/0/64665/02fe5a68-549b-e31c-632b-5eb9528cd0f6.gif)

先ほど実装したUIScrollViewDelegateのextensionの中に、`scrollViewDidZoom()`メソッドを追加し、その中で`updateContentInset()`を呼んであげます。`scrollViewDidZoom()`はユーザがピンチジェスチャを終了した時に呼び出されるメソッドです。

```swift
func scrollViewDidZoom(_ scrollView: UIScrollView) {
    updateContentInset()
}
```

## 完成
これでスクロールとズームが可能なフォトビューワが完成しました！
![photo_viewer.gif](https://qiita-image-store.s3.amazonaws.com/0/64665/d7728c30-f652-a006-b491-bd05f582ff6c.gif)


# まとめ
この記事では、UIScrollViewの基礎知識を抑えつつ、フォートビューワを例に様々な実装方法について解説しました。UIScrollViewは特殊な挙動が多く実装が困難ですが、一番の近道はその仕組みを理解することだと思います。これからUIScrollViewを利用する方の手助けになれれば嬉しいです :tada: 

続編：[詳解UIScrollView 〜フォトクロップ編〜](https://qiita.com/shiba1014/items/eba945b8d2d41322d545)

# 参考文献
[Scroll View Programming Guide for iOS](https://developer.apple.com/library/archive/documentation/WindowsViews/Conceptual/UIScrollView_pg/Introduction/Introduction.html)
[UIScrollView And Autolayout](https://developer.apple.com/library/archive/technotes/tn2154/_index.html)
[Swift3で画像を拡大縮小、スクロールさせる](https://blog.firstfournotes.com/tech/swift3-zoom-scroll/)