UIScrollView内でPDFなどのベクター表示をうまく拡大表示する方法

# 問題点の整理 

PDFのような解像度に依存しないようなベクターコンテンツを `UIScrollView` で扱うには、問題がある場合があります。もっとも、UIScollViewの最大拡大倍率が２x程度であれば、さほど心配する事はありません。ただし、拡大倍率がそれより大きな 4x や 8x になると、いろいろな問題が出てきます。

## 1.  表示がぼやける

UIScrollView は特定の解像度で subview をラスタライズしてそれを拡大表示します。単純に実装すると、zoom の拡大率を大きくするほど、輪郭のはっきりしない画像が拡大表示されます。以下の画像にその例をあげます。左の赤い枠の部分を拡大表示しても、文字がぼやけて表示され、くっきり表示がされる訳ではありません。

<img width=600 src="https://qiita-image-store.s3.amazonaws.com/0/65634/bb9b2fef-c489-5667-a9e1-221a6034b9b9.png" />

そっかと思い、subview の `contentMode` を `.redraw` にセットしてみます。しかし、やはり解像度が上がるわけではない事に気がつきます。ズームした時の解像度をあげるには PDFなどベクターグラフィックを表示している `UIScrollView` の `subview` に `contentScaleFactor` を設定してあげます。実際には、Retina ディスプレイの場合もあるので、`UIScrollView` の `zoomScale` と `UIScreen` の `scale` の積を設定してあげます。

```.swift
class PDFPageViewController: UIViewController, UIScrollViewDelegate {
	@IBOutlet weak var scrollView: UIScrollView!
	@IBOutlet weak var pdfPageView: PDFPageView!

	// snip

	func viewForZooming(in scrollView: UIScrollView) -> UIView? {
		return self.pdfPageView
	}

	func scrollViewDidZoom(_ scrollView: UIScrollView) {
		self.pdfPageView.contentScaleFactor = self.scrollView.zoomScale * UIScreen.main.scale
	}
}	
```

これで、zoom しても解像度は確保できるようになりました。これで一件落着です。と…言いたいところですが、まだ問題は残っています。さて、さらにズームしてみましょう。

## 2. Run Out of Memory

先ほども言ったように、`UIScrollView` は `subview` をラスタライズして表示しています。zoom しても解像度を担保する為には、view の bounds の width x height x contentScaleFactor のラスター画像をメモリに確保する必要があります。拡大率が大きくなった場合にメモリが確保できずにクラッシュに至ってしまう可能性は十分考えられます。

例えば、Viewのサイズが 1024 x 768 で retina x2 （約12MB）を 4倍にズームさせるだけでもラスタライズに単純計算で 8192 x 6144 でおよそ 48MB 必要となります。なるほど、これでは拡大を続けるとどこかでクラッシュするのは避けれれそうにありません。

# 対処法

これらの問題を解決する一つの方法を紹介したいと思います。

## View の構造

`UIScrollView` の `subview` に ダミーの `contentView` を配置します。ダミーの `view` は完全に透明にして表示はされないようにします。さらに `UIScrollView` の `backgroundColor` も透明にします。そして、もう一つ PDFなどのベクター表示をする `View` を `UIScrollView` と同じレベルで、`UIScrollView` の奥に配置します。このベクターを表示するViewは常に `UIScrollView` と同じサイズとして配置し、`UIScrollView` がズームしても、ベクターを表示するViewの座標はそのままとします。図で示すと以下のような図になります。

<img width="700" alt="スクリーンショット 2016-12-06 23.32.46.png" src="https://qiita-image-store.s3.amazonaws.com/0/65634/1a589c43-61e3-e381-c387-9e83166865a1.png">

ズームされていない等倍表示の時は次の図の左になります。そしてズームしたりスクロール操作をした状態を右とします。しかし実際には `dummyView` は表示されていないので、奥のベクター表示Viewが透けて見えます。そして、ベクター表示Viewは、`dummyView` の `bounds` をベクター表示 `View` の座標系に変換して、その座標系にめがけて描画を行います。ベクター表示Viewの `bounds` は実際には何も変わっていませんが、実際にズームやスクロールが発生すると、結果的に、ベクター表示Viewをズームしたりスクロールしているように見えるというわけです。

<img width="500" alt="Screen Shot 2016-12-06 23.37.33.png" src="https://qiita-image-store.s3.amazonaws.com/0/65634/cf02bd41-39cf-9eb8-fd67-67140d92954b.png">

そこで、スクロールやズームの都度、ベクター表示Viewは再描画される必要があるので、`UIScrollViewDelegate` でその都度必要な時に再描画を行います。

```.swift
class MyViewController: UIViewController, UIScrollViewDelegate {

	@IBOutlet weak var scrollView: UIScrollView!
	@IBOutlet weak var pageView: PageView!

	// snip

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

	func scrollViewDidZoom(_ scrollView: UIScrollView) {
		self.pageView.setNeedsDisplay()
	}

	func scrollViewDidScroll(_ scrollView: UIScrollView) {
		self.pageView.setNeedsDisplay()
	}
}
```

そして、ベクターを表示するView、この場合は `bounds` いっぱいの楕円を描く例をあげてみます。この場合 ダミーViewを `contentView` と名前をつけています。補足すると、可能な限り、`func drawRect(rect: CGRect)` より `func draw(_ layer: CALayer, in ctx: CGContext)` を使いましょう。~~main thread の負荷を避ける事ができます~~。そして、この場合、`setNeedsDisplay()` をオーバライドして、自身の super だけでなく、自身の layer の `setNeedsDisplay()` を呼んであげると、起動時など一部の状況で、layer の `setNeedsDisplay()` が呼ばれず、結果的に何も表示されなくて焦る事がなくなります。

<font color="orange">訂正：筆者は過去のヒューリスティックな体験により、これでユーザー体験が向上すると考えていますが、main thread で呼ばれないという訳ではないようです（iOS10 iPad Pro 12.9"で確認）。</font>

```.swift
class PageView: UIView {

	@IBOutlet weak var contentView: UIView!

	override func layoutSubviews() {
		super.layoutSubviews()
		self.layer.drawsAsynchronously = true
	}

	override func draw(_ layer: CALayer, in ctx: CGContext) {
		UIGraphicsPushContext(ctx)
		ctx.saveGState()

		let contentBounds = self.contentView.bounds
		UIColor.red.set()
		ctx.strokeEllipse(in: contentBounds)

		ctx.restoreGState()
		UIGraphicsPopContext()
	}

	override func setNeedsDisplay() {
		super.setNeedsDisplay()
		self.layer.setNeedsDisplay()
	}

}
```

完全なサンプルは GitHub から入手可能になっています。この例では、１ページのPDFを表示するだけですが、この記事で紹介した手法で実装され、かなりのズーム表示をしても、これが直接の原因でメモリ不足に陥る事はありません。このサンプルではダミーViewに`UIImageView` を使っていて、PDFの１ページを jpg で表示しています。そしてPDFの描画が終わると、`UIImageView` を非表示にして、表示を切り替えユーザー体験を向上させる事ができる例をサンプルとして実装しています。Resources フォルタ以下以外は MIT ライセンスとなっておりまので、興味のある方は是非お試しくだいませ。

https://github.com/codelynx/PDFPageViewDemo

```.log
Xcode Version 8.1 (8B62)
Apple Swift version 3.0.1 (swiftlang-800.0.58.6 clang-800.0.42.1)
```