問題点の整理
PDFのような解像度に依存しないようなベクターコンテンツを UIScrollView で扱うには、問題がある場合があります。もっとも、UIScollViewの最大拡大倍率が2x程度であれば、さほど心配する事はありません。ただし、拡大倍率がそれより大きな 4x や 8x になると、いろいろな問題が出てきます。
1. 表示がぼやける
UIScrollView は特定の解像度で subview をラスタライズしてそれを拡大表示します。単純に実装すると、zoom の拡大率を大きくするほど、輪郭のはっきりしない画像が拡大表示されます。以下の画像にその例をあげます。左の赤い枠の部分を拡大表示しても、文字がぼやけて表示され、くっきり表示がされる訳ではありません。
そっかと思い、subview の contentMode を .redraw にセットしてみます。しかし、やはり解像度が上がるわけではない事に気がつきます。ズームした時の解像度をあげるには PDFなどベクターグラフィックを表示している UIScrollView の subview に contentScaleFactor を設定してあげます。実際には、Retina ディスプレイの場合もあるので、UIScrollView の zoomScale と UIScreen の scale の積を設定してあげます。
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の座標はそのままとします。図で示すと以下のような図になります。
ズームされていない等倍表示の時は次の図の左になります。そしてズームしたりスクロール操作をした状態を右とします。しかし実際には dummyView は表示されていないので、奥のベクター表示Viewが透けて見えます。そして、ベクター表示Viewは、dummyView の bounds をベクター表示 View の座標系に変換して、その座標系にめがけて描画を行います。ベクター表示Viewの bounds は実際には何も変わっていませんが、実際にズームやスクロールが発生すると、結果的に、ベクター表示Viewをズームしたりスクロールしているように見えるというわけです。
そこで、スクロールやズームの都度、ベクター表示Viewは再描画される必要があるので、UIScrollViewDelegate でその都度必要な時に再描画を行います。
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() が呼ばれず、結果的に何も表示されなくて焦る事がなくなります。
訂正:筆者は過去のヒューリスティックな体験により、これでユーザー体験が向上すると考えていますが、main thread で呼ばれないという訳ではないようです(iOS10 iPad Pro 12.9"で確認)。
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 から入手可能になっています。この例では、1ページのPDFを表示するだけですが、この記事で紹介した手法で実装され、かなりのズーム表示をしても、これが直接の原因でメモリ不足に陥る事はありません。このサンプルではダミーViewにUIImageView を使っていて、PDFの1ページを jpg で表示しています。そしてPDFの描画が終わると、UIImageView を非表示にして、表示を切り替えユーザー体験を向上させる事ができる例をサンプルとして実装しています。Resources フォルタ以下以外は MIT ライセンスとなっておりまので、興味のある方は是非お試しくだいませ。
Xcode Version 8.1 (8B62)
Apple Swift version 3.0.1 (swiftlang-800.0.58.6 clang-800.0.42.1)