iOS/macOS で Zoom/Scroll 可能な Core Graphics ベースの 2D アプリの開発を便利に

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` を呼び出してください。

```.swift
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)` でプラットフォームを切り分けます。

```.swift
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)` でプラットフォームを切り分けます。

```.swift
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` 内の座標を得る事ができます。

```.swift
	#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` ではありませんが、結果的に良しなにしてくれます。

```.swift
	self.setNeedsDisplay()
```

そして、`MyPanorama` をインスタンス化します。ズーム倍率に上限下限を儲けたい場合は値を設定します。macOS を対象にした場合も `maxMagnification` ではなく `maximumZoomScale` で一本化されています。

```.swift
let myPanorama = MyPanorama(bounds: CGRect(...))
myPanorama.maximumZoomScale = 4.0
myPanorama.minimumZoomScale = 1.0
```

そして、ViewController などで、`PanoramaView` にインスタンス化した`MyPanorama`をセットします。

```.swift
self.panoramaView.panorama = myPanorama
```

これだけで、iOS/macOSの面倒な違いを `PanoramaView` と `Panorama` が吸収してくれて、ズームしたりスクロールしたり、表示もiOSでもmacOSでも左上を原点にCoreGraphics で描画でき、`#if` でプラットフォームに切り分けは必要ですが、iOSのタッチやmacOSのマウスのイベントなどを拾う事が出来るようになります。

GestureRecognizer にはまだ対応していませんが、今後何かいい方法を考えて見たいと思います。

## Architecture

`Panorama` は前回の二つの記事をベースとしています。どちらも、扱いにくい UI/NSScrollView をどう扱うかをテーマにしています。

「Metal ＋ UIScrollView でズーム＆スクロール可能な２Dアプリに挑戦 TAKE 2」
http://qiita.com/codelynx/items/0434cdf453c8db6bc357#_reference-5f9ee5f88dd5fd1186f7

「UIScrollView内でPDFなどのベクター表示をうまく拡大表示する方法」
http://qiita.com/codelynx/items/a2a87b053f8225782a9c

よって `Panorama` の実際にコンテンツを表示するビューはこれらの記事同様に実際にズームされているわけではなく、Panorama のコンテンツの座標系から、透明なコンテントビューへ座標が変換され、結果として、`Panorama` は自分の座標系のみを意識して描画すればよい事になっています。

実際には、描画は `PanoramaBackView` より、タッチやマウスのイベントは`PanoramaContentView` を起点として `Panorama` を呼び出し、結果的には様々な面倒なプラットフォームの違いなどが吸収されている事になります。が、これらはほぼ意識しないでよいように設計されています。

<img width="829" alt="Screen Shot 2017-01-19 at 4.46.58.png" src="https://qiita-image-store.s3.amazonaws.com/0/65634/4c1060a7-7c2d-0019-1ee4-7ad9e87dbebe.png">

## Github

コードは Github で公開しています。
https://github.com/codelynx/Panorama

## 手書き

プロジェクトには Living Example として手書きアプリを実装しています。iOS と macOS で動作します。`Panorama` のサブクラス `MyPanorama` はタッチやマウスのイベントを処理して、画面に表示するだけの簡単なサンプルです。線の補正も保存も消去もできませんが、`Panorama` でこんな事が簡単にできそうだという事を感じていただければ幸いです。

![Screen Shot.png](https://qiita-image-store.s3.amazonaws.com/0/65634/64d86514-fb20-0f68-5c5d-f566d094110e.png)

## ロードマップ

考え中ですが、とりあえずテストは全然足りていないと思います。

## Xcode

```log:Versions
Xcode Version 8.2.1 (8C1002)
Apple Swift version 3.0.2 (swiftlang-800.0.63 clang-800.0.42.1)
```