[Objective-C] iOS Scroll Viewプログラミングガイドを読んだメモ


Appleのドキュメントもだいぶ読み進めてきました。
今回は「[[PDF] iOS Scroll Viewプログラミングガイド](https://developer.apple.com/jp/devcenter/ios/library/documentation/UIScrollView_pg.pdf)」を読んだメモです。

実はまだ案件で使ったことがない`UIScrollView`。
経験の浅さを痛感します。

##ScrollViewで大事なプロパティたち

| プロパティ | 意味 |
|----------|--------|
| contentSize | ScrollViewが内包するコンテンツのサイズ。スクロール領域 |
| contentInset | いわゆる余白。`contentSize`に`contentInset`を足したものが最終的なScrollViewのサイズになる |
| scrollIndicatorInsets | スクロール時に表示されるインジケータのinset。`contentInset`を設定したらこちらも設定しないとスクロールバーが変なところに表示されるので注意 |


##UIScrollViewDelegate

スクロールに関するイベントに関してはデリゲートメソッドで受け取ります。
デリゲートメソッドには以下のものが用意されています。

| デリゲートメソッド | 説明 |
|-----------------|--------|
| - (void)scrollViewDidScroll:(UIScrollView *)scrollView | UIScrollViewが **スクロールしている間** 呼び出される。 |
| - (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView | アニメーション完了後に呼び出される。（`setContentOffset:animated:`メソッドの`animated`が`NO`の場合は呼び出されません。 |
| - (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView | ドラッグ開始時に呼ばれる |
| - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate | ドラッグ後、指がデバイスが離れた時に呼ばれる。慣性が効いて動く場合は`decelerate`が`YES`になる |
| - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView | ドラッグ後、慣性が効いて動いたあとに止まった場合に呼ばれる。（慣性なしでドラッグを終えた場合は呼ばれない） |

----------------------------------------------------

##プログラムからスクロールさせる

プログラムからスクロールさせるには、`setContentOffset:animated:`メソッドを利用します。

```objc
// CGPointの位置が左上角になるようにスクロールする
[aScrollView setContentOffset:CGPointMake(50, 50) animated:YES];
```

```objc
// 以下はどちらも意味は同じ、アニメーションなしでスクロール
[aScrollView setContentOffset:CGPoint(50, 50) animated:NO];

// -------------------------- or --------------------------

aScrollView.contentOffset = CGPoint(50, 50);
```

----------------------------------------------------

##矩形範囲の表示

```objc
[aView scrollRectToVisible:(CGRect) animated:(BOOL)]
```

矩形が見える位置までスクロールします。その際、追跡プロパティとドラッグプロパティはどちらも`NO`になります。

----------------------------------------------------

##最上部へのスクロール

ステータスバーをタップしたときに一番上までスクロールする機能です。
この動作も`UIScrollView`の処理です。
デリゲートメソッドをオーバーライドすることによって、この機能をオフにすることもできます。

例えばこれを利用することで、画面内に複数のScrollViewがある場合に、ステータスバータップ時にどのScrollViewをスクロールさせるかを制御することが可能となります。

```objc
- (BOOL)scrollViewShouldScrollToTop:(UIScrollView *)scrollView;
```

また、この動作によってアニメーションが完了したときに以下のデリゲートメソッドが呼ばれます。

```objc
- (void)scrollViewDidScrollToTop:(UIScrollView *)scrollView;
```

----------------------------------------------------

##スクロール中の状態追跡

スクロールにはユーザ操作のものもあれば、プログラムによる操作もあります。
しかしデリゲートメソッドはどれも同様にメッセージを受信するので、状態を把握する必要が出てきます。
そのときに使えるのが以下の状態プロパティです。

| 状態プロパティ | 説明 |
|--------------|--------|
| tracking | ユーザの指がデバイスに触れている場合に`YES`（つまりリアルタイムな状態） |
| dragging | ユーザの指がデバイスに触れていた場合に`YES`（フリップなど、ユーザの指が離れても、ユーザ操作によるものと判断できる状態） |
| decelerating | フリックジェスチャ後、またはScrollViewのフレームを超えてドラッグし、バウンスしてScrollViewが減速している場合に`YES` |
| zooming | zoomScaleプロパティを変更するためにScrollViewがピンチジェスチャを追跡している場合に`YES` |
| contentOffset | ScrollViewの境界の左上角を定義するCGPointの値 |

----------------------------------------------------

##スクロールの開始と完了の追跡

開始と完了もデリゲートメソッドで追跡することができます。
*※デリゲートメソッド一覧に記載しています。*

----------------------------------------------------

##デリゲートメッセージシーケンスの全体

デリゲートシーケンスはユーザの画面タッチから開始されます。
シーケンスは以下の状態をたどります。

1. trackingプロパティが`YES`に
2. ユーザの指がデバイスに触れた状態で静止していて、コンテンツビューがタッチイベントに応答するものの場合、シーケンスは完了しています
3. その後、ユーザがデバイスに触れたまま指を動かせばシーケンスが継続します
4. スクロールを開始すると、進行中のタッチ操作をすべて取り消すよう試みます。（筆者注：例えばボタンなどをタッチし反応している状態で、指を動かすとそのアクションが取り消されるのをイメージすると分かりやすい）
5. ScrollViewの`dragging`プロパティが`YES`に設定される
6. `scrollViewWillBeginDragging:`メッセージが送信される
7. （ユーザのドラッグ中）`scrollViewDidScroll:`メッセージがデリゲートに送り続けられる
8. ユーザがフリックジェスチャを行うと、`tracking`プロパティは`NO`に設定される
9. その後、`scrollViewDidEndDragging:willDecelerate:`メッセージがデリゲートに送られる


###減速スピードの制御

フリックジェスチャなどで慣性で動いている状態のScrollViewの減速については、以下のプロパティで制御することができます。

```objc
aScrollView.decelerationRate = UIScrollViewDecelerationRateNormal;

// -------------------- or -----------------------

aScrollView.decelerationRate = UIScrollViewDecelerationRateFast;
```


##ピンチジェスチャを使った基本的なズーム

UIScrollViewにはピンチジェスチャを簡単に扱う仕組みが実装されています。
ピンチジェスチャを使うには以下のデリゲートメソッドを実装する必要があります。
また同時に

* `minimumZoomScale`プロパティ
* `maximumZoomScale`プロパティ

のどちらか、あるいは両方を設定する必要があります。（設定しないとデリゲートメソッドが呼ばれません）

```objc
- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView
{
	// ズーム対象のビュー
	return self.aView;
}
```


###プログラミングによるズーム

上記はユーザ操作によるズームでした。
ScrollViewは、ダブルタップなどの操作によりプログラムからズームを実行する実装を用意しています。

* `setZoomScale:animated:`
* `zoomToRect:animated:`

というふたつのメソッドです。
どちらのメソッドもズームを操作するメソッドです。
ズームは、ズーム対象の「中心位置」の周りをズームします。
そのため、意図した箇所をズームする場合は位置と拡大率の調整を行う必要があります。


###デリゲートへのズーム完了の通知

ズーム操作や処理が完了したとき、`scrollViewDidEndZooming:withView:atScale:`メソッドが呼ばれます。
このメソッドは、ScrollViewインスタンス、スクロールされたScrollViewのサブビュー、ズーム完了時の拡大縮小率が引数として渡されます。


###ズームでハマった話

Appleのドキュメントからははずれますが、実際にドキュメントを読みつつ作っていてハマった点を書いておきます。
（おそらく、しっかりと理由があると思うんですが原因が分かっていないので書いていることは間違っているかもしれません）

####ズーム後、`contentSize`が変更される

動作の仕組みを理解するために色々なViewを、ScrollViewに適当に`addSubview:`してたんですが、どうもズームをすると表示がおかしくなる場合が。
ズームすると、どうやら`contentSize`が変更されている模様。理由分からず。

ちなみにScrollViewは以下のような感じになってました。

* UIImageViewを3つ並列に追加
* UIViewを最後に追加
* 最初に`addSubview:`したUIImageViewがScrollView内で一番サイズが大きい
* 上のimageのサイズを`contentSize`に設定

という状況。
で、`viewForZoomingInScrollView:`メソッドで一番サイズが大きいUIImageViewを返している場合は想定通り動くものの、それ以外を返すと`contentSize`の値が想定通りになっていない。
よくよく見てみると、ズーム対象のコンテンツのサイズが`contentSize`になるように変更されていました。

なのでズーム完了時に、改めてscrollViewの`contentSize`を一番サイズの大きいUIImageViewのimageのサイズにするようにしたところ、想定通りの動きとなりました。
（多分、ズーム対象のViewのサイズにする、みたいな動作がデフォルトなのだと思います）

微妙に想像していた動作と違うので要注意です。

----------------------------------------------------

##タップによるズーム

ピンチジェスチャに関しては基本的な実装をフレームワーク側で行っていますが、タップによるズーム（ダブルタップでズーム、など）を実装するにはアプリケーション側での実装が必要です。

タップによるズームを適切に実装すれば、ユーザビリティを向上させることもできると思います。（2本指でダブルタップするとズームアウトとか）

###実装方法

アプリケーション側での実装が必要ですが、ScrollViewのサブクラス化は必要なく、`viewForZoomingScrollView:`デリゲートメソッドで返すクラス側でタッチ処理を実装します。

*※コードサンプルも書いていて長くなったので[別記事](http://qiita.com/edo_m18/items/9400bf8fa37ceb7077d7)にしました。*


----------------------------------------------------

##ページングモードを使用したスクロール

UIScrollViewは、iOSでよく見られるページングタイプのスクロールもサポートしています。
（ある決まった幅（1画面分）のコンテンツをスクロールするようなやつ。App Storeのスクリーンショットとかの感じです）

###ページングモードの設定

ページングモードをサポートするには、ScrollViewの`pagingEnabled`プロパティに`YES`を設定する必要があります。
`contentSize`は表示したいコンテンツがちょうど収まるサイズにしておくといいでしょう。
また、通常はインジケータ（スクロールバー）は必要なくなるので非表示にしておいたほうがより自然になります。

```objc
UIScrollView *aScrollView = [[UIScrollView alloc] initWithFrame:self.view.bounds];

aScrollView.pagingEnabled = YES;

// スクロールインジケータを非表示に
aScrollView.showsHorizontalScrollIndicator = NO;
aScrollView.showsVerticalScrollIndicator   = NO;
```


###ページングモードのScroll Viewのサブビューの設定

Appleのプログラミングガイドでは、サブビューの設定についても言及されています。
色々書いてありますが、要は小さいコンテンツはひとつのサブビュー、大きいコンテンツやページ数の多いコンテンツはページ単位でビューを分け、表示すべきビューと隣接するビューのみ生成し、それ以外は位置を動的に判断して生成しよう、というものです。

一言で言えば、メモリ使用量を減らそう、ってことですね。
TableViewのセルも似たような仕組みで動作しています。（Identifierを渡してインスタンス生成を省略する方法ですね）
モバイルはメモリが潤沢にあるとは言えないので、できるだけ最適化しよう、というようなことが書いてあります。