Core Graphics で 2D のコンテンツを UIScrollView や NSScrollView に入れてスクロールしたりズームしようとするとそれなりに骨が折れます。さらに一つのコードベースで iOS と macOS の両方をサポートしようとすると、iOS と macOS の似て非なるネーミングや挙動のおかげで結構苦労します。
でやっと動作するよういなっても、また別のアプリで同じような事をしようとすると再度細かい作り込みを強いられます。可能であれば、UI/NSScrollView の挙動やAPIの違い、さらに座標系の違いなどにあまりエネルギーを取られたくないと思うはずです。
今回はこの手の問題を解決する手段として Panorama
を公開したので紹介したいと思います。
使い方
iOS または macOS またその両方の Storyboard に PanoramaView
を配置します。同等の事をコードで行なっても構いません。
次に Panorama
クラスのサブクラスを用意します。そして、典型的には次の二つのメソッドをオーバーライドします。draw(in:)
メソッドでは self.bounds
内に Core Graphics でコンテンツを描画します。座標系は iOS と同じ左上が原点になります。macOS でもこの座標系でコードが書けます。didMove(to:)
メソッドは、実際の View の配下になった時に呼ばれ、リソースを準備したり何か準備をする際に適したオーバーライドポイントです。オーバーライドした際は必ず super
を呼び出してください。
class MyPanorama: Panorama {
override func draw(in context: CGContext) {
// draw within self.bounds
}
override func didMove(to panoramaView: PanoramaView?) {
super.didMove(to: panoramaView)
}
}
iOS でタッチの処理をしたい時は以下の任意のメソッドをオーバーライドします。macOS 側でエラーにならないように、#if os(iOS)
でプラットフォームを切り分けます。
func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)
func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?)
func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?)
func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?)
mouse の処理を行いたい時には、以下の任意のメソッドをオーバーライドします。iOS 側でエラーにならないように、#if os(iOS)
でプラットフォームを切り分けます。
func mouseDown(with event: NSEvent)
func mouseDragged(with event: NSEvent)
func mouseUp(with event: NSEvent)
func mouseMoved(with event: NSEvent)
func rightMouseDown(with event: NSEvent)
func rightMouseDragged(with event: NSEvent)
func rightMouseUp(with event: NSEvent)
func otherMouseDown(with event: NSEvent)
func otherMouseDragged(with event: NSEvent)
func otherMouseUp(with event: NSEvent)
func mouseExited(with event: NSEvent)
func mouseEntered(with event: NSEvent)
NSEvent
にも UITouch
にも location(in: Panorama)
メソッドが extension で用意されているので、ズームやスクロールされている状態でも、Panorama
内の座標を得る事ができます。
#if os(iOS)
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
for touch in touches {
if let location = touch.location(in: self) {
// location in Panorama
}
}
}
#endif
そして、何らかの理由で画面の更新が必要になった時は、setNeedsDisplay()
を呼びます。決して、draw()
を自分では呼び出さないでください。Panorama
は実際には UIView
でも NSView
ではありませんが、結果的に良しなにしてくれます。
self.setNeedsDisplay()
そして、MyPanorama
をインスタンス化します。ズーム倍率に上限下限を儲けたい場合は値を設定します。macOS を対象にした場合も maxMagnification
ではなく maximumZoomScale
で一本化されています。
let myPanorama = MyPanorama(bounds: CGRect(...))
myPanorama.maximumZoomScale = 4.0
myPanorama.minimumZoomScale = 1.0
そして、ViewController などで、PanoramaView
にインスタンス化したMyPanorama
をセットします。
self.panoramaView.panorama = myPanorama
これだけで、iOS/macOSの面倒な違いを PanoramaView
と Panorama
が吸収してくれて、ズームしたりスクロールしたり、表示もiOSでもmacOSでも左上を原点にCoreGraphics で描画でき、#if
でプラットフォームに切り分けは必要ですが、iOSのタッチやmacOSのマウスのイベントなどを拾う事が出来るようになります。
GestureRecognizer にはまだ対応していませんが、今後何かいい方法を考えて見たいと思います。
Architecture
Panorama
は前回の二つの記事をベースとしています。どちらも、扱いにくい UI/NSScrollView をどう扱うかをテーマにしています。
「Metal + UIScrollView でズーム&スクロール可能な2Dアプリに挑戦 TAKE 2」
http://qiita.com/codelynx/items/0434cdf453c8db6bc357#_reference-5f9ee5f88dd5fd1186f7
「UIScrollView内でPDFなどのベクター表示をうまく拡大表示する方法」
http://qiita.com/codelynx/items/a2a87b053f8225782a9c
よって Panorama
の実際にコンテンツを表示するビューはこれらの記事同様に実際にズームされているわけではなく、Panorama のコンテンツの座標系から、透明なコンテントビューへ座標が変換され、結果として、Panorama
は自分の座標系のみを意識して描画すればよい事になっています。
実際には、描画は PanoramaBackView
より、タッチやマウスのイベントはPanoramaContentView
を起点として Panorama
を呼び出し、結果的には様々な面倒なプラットフォームの違いなどが吸収されている事になります。が、これらはほぼ意識しないでよいように設計されています。
Github
コードは Github で公開しています。
https://github.com/codelynx/Panorama
手書き
プロジェクトには Living Example として手書きアプリを実装しています。iOS と macOS で動作します。Panorama
のサブクラス MyPanorama
はタッチやマウスのイベントを処理して、画面に表示するだけの簡単なサンプルです。線の補正も保存も消去もできませんが、Panorama
でこんな事が簡単にできそうだという事を感じていただければ幸いです。
ロードマップ
考え中ですが、とりあえずテストは全然足りていないと思います。
Xcode
Xcode Version 8.2.1 (8C1002)
Apple Swift version 3.0.2 (swiftlang-800.0.63 clang-800.0.42.1)