Edited at

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


はじめに

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

本記事は詳解UIScrollView 〜フォトクロップ編〜に続きます。


UIScrollViewとは

UIScrollViewとは、subviewされている内容物をスクロールしたりズームしたりできるUIコンポーネントです。ユーザが画面上をスクロールするとUIScrollViewが移動し、表示領域が変化します。つまり、UIScrollViewは画像の一部分を表示する窓のような部品と言えます。


基礎知識

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


  • contentSize

  • contentInset

  • contentOffset


contentSize

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


contentInset

contentInsetはUIEdgeInsets型のプロパティです。これを指定してあげると、スクロール可能領域に余白をつけることができます。余白については、実際に見てみると理解しやすいと思います。

まずはcontentInsetのrightに50を指定してみましょう。そうすると、右端に余白が現れます。スクロール可能領域が画像の右端から50pt分追加されているのです。(わかりやすいようにUIScrollViewの背景に色をつけています)

contentInsetのrightを-200にすると、今度は一番右端までスクロールすることができません。スクロール可能領域が画像の右端から200pt分減っています。

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

では、contentInsetとはどのような時に使うのでしょうか?例えばviewいっぱいにUIScrollViewを乗せた場合に、ナビゲーションバーやツールバーなどが上に被さってしまう可能性があります。この時、ユーザはコンテンツを端まで見ることができません。こういった場面でcontentInsetを設定します。

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


contentOffset

contentOffsetは、コンテンツ上でUIScrollViewがどこにあるかを示すCGPointです。UIScrollViewの左上角の座標が入っています。一番最初に記述した通り、UIScrollViewはコンテンツを表示する窓で、その窓を移動させることでスクロールを表現しています。コンテンツ上での窓の座標を示すのがこのcontentOffsetになります。



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


フォトビューワの実装

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


実装フローチャート

この記事ではInterfaceBuilder使用/不使用の2パターンの実装方法を紹介しています。それぞれで重複する箇所が出てきますので、開発の際にはこのフローチャートを参考にしてください。


UIScrollViewの作成

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


A. IB (InterfaceBuilder) で作成

ライブラリからUIScrollViewを選択し、view上に配置しましょう。レイアウトはどのようにしても構いませんが、ここでは画面いっぱいにUIScrollViewが配置されるように制約を張り付けます。


B. コードで作成

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

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を入れます。



追加したUIImageViewに対してAutoLayoutの制約をつけていきます。まず、UIScrollViewとの4辺のspacingを0に設定します。



この状態ではNeed constraints for: X(Y) position or width(height)というエラーが出るので、UIImageViewとUIScrollViewに対しEqual Height, Equal Widthの制約をつけます。



この2つの制約のpriorityを250(Low)に指定すれば完了です。

あとは、コード上でUIImageViewにUIImageをセットしましょう。画像が固定で、その設定もIB上でやりたい!という場合は、Equal Height, Equal Widthの制約を削除し、UIImageViewに画像を指定しましょう。自動的に画像の大きさに合わせてリサイズされます。


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と比べると随分シンプルに配置できます。

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が実装できます。ページングを加えれば、チュートリアルなどで利用できそうですね。


ズームの実装

次に、ピンチジェスチャによるズームについて解説します。ピンチジェスチャとは、二本指を開いたり閉じたりするジェスチャで、コンテンツの拡大縮小を行う際のApple標準のジェスチャです。



ピンチイン・アウトやズームジェスチャを実装する際にはデリゲードの実装と、 minimumZoomScale または maximumZoomScale の設定が必要になります。デリゲートはコードでしか扱えないので、コード側でUIScrollViewを宣言しなければいけません。


デリゲートの実装

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

extension ViewController: UIScrollViewDelegate {

func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return imageView
}
}


プロパティの設定

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

scrollView.minimumZoomScale = 0.5

scrollView.maximumZoomScale = 5
scrollView.delegate = self

以上の設定で、ズーム可能なUIScrollViewが作成できます。


subviewの調整

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


サイズの調整

このままでは、画面を開いた時に画像の左上一部分しか表示されません。フォトビューワのように実装したい場合には、画像の全体が見えるサイズで画面の中央に表示させたいですよね。そこで、コンテンツの調整を行なっていきます。

まずは画像の大きさを画面サイズに合わせるために、UIScrollViewのzoomScaleを更新します。ここはこれまでIBで設定したかどうかに限らずコードで書きます。

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で余白を指定し、位置の調整を行いましょう。

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/heightInsetmax()関数を使って0を下回らないようにしています。これは、ズーム後にUIImageViewがUIScrollViewよりも大きくなった場合、contentInsetに負の値がセットされ、画像の端までスクロールできない挙動を防ぐためです。

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

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

func scrollViewDidZoom(_ scrollView: UIScrollView) {

updateContentInset()
}


完成

これでスクロールとズームが可能なフォトビューワが完成しました!


まとめ

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

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


参考文献

Scroll View Programming Guide for iOS

UIScrollView And Autolayout

Swift3で画像を拡大縮小、スクロールさせる