UIViewControllerに用意された3D Touchを利用する

「集まれSwift好き！Swift愛好会 vol12」http://love-swift.connpass.com/event/41463/
「集まれSwift好き！Swift愛好会 vol11」http://love-swift.connpass.com/event/39087/
上の二つの会で話した内容のまとめ．

説明に使うサンプルコード．
https://github.com/sonsongithub/UZTextView/tree/master/Sample3DTouch
※Sample3DTouchプロジェクトが以下の説明のアプリケーションのソースコードです．選択したり，リンクをタップできるビューUZTextViewのリポジトリに入れてあります．

# 3D Touchの実装方法

3D Touchは，大きく分けて三つ利用する方法がある．

1. ホーム画面でアプリのアイコンを強く押すときのメニューを実装する
2. ```UIViewController```に用意されたAPIを使う
3. ```UITouch```から値をダイレクトに取得して自分ですべて実装する

特に(1)は特段難しいことはない(気がする)．一方で，(3)は気軽に実装すると，未来にAPIの仕様がかわって泣く，あるいは，実装しているうちに何を作ってるのかわからなくなるパターンなので手を出さない（ことにする）．となると，```UIViewController```に実装された(2)を利用するのがよさそうだ．
というわけで，今回は(2)を説明してみる．

# 処理の流れ

```UIViewController```で3D Touchを使いたい場合は，まず，反応するビューを以下の```UIViewController```のメソッドで登録する．

```
func registerForPreviewing(with delegate: UIViewControllerPreviewingDelegate, 
                sourceView: UIView) -> UIViewControllerPreviewing
```

このメソッドは，```viewDidLoad```のイベントで実装するのが良さそうである．

```
override func viewDidLoad() {
    super.viewDidLoad()
    // other process
    self.registerForPreviewing(with: self, sourceView: self.view)
}
```

これでview controllerの```view```が3D Touchに反応するようになる．ただし，このdelegate先となるオブジェクトは，下に示す二つの```UIViewControllerPreviewingDelegate```プロトコルを実装する必要がある．

```
// 表示するview controllerを作成して，ランタイムに返す
func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController?
// 表示されているview controllerが強くおされて，peekが終了するときに呼ばれる．
func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController)
```

そして，処理の流れは，下のようになる．

1. 3D Touchイベントが発生すると，```previewingContext(_. location)```が呼ばれる．
2. このcallbackの中で，```UIViewController```の配下のビューに，3D Touchで表示させるデータとそのビューコントローラがあるかを問い合わせる．適当なデータがない場合（変な場所を3D Touchした場合も）には，```previewingContext(_. location)```はnilを返せば，3D Touchによって何も表示されないようになる．この時．表示されるビューをpeek viewと呼ぶ．また，この強く押して，プレビューすることをpeekと呼ぶみたいだ(peekは「覗く」の意)．
3. 次にユーザが3D Touchで開いたビューをさらに強く押し込んだ場合，```previewingContext(_, commit:)```が呼ばれる．この```commit```には，```previewingContext(_. location)```で生成したview controllerが入っている．もし，表示されたビューが```UINavigationController```で管理されるdrill downのビューであれば，```UINavigationController```のpushを実行したり，あるいあ表示されたビューはmodalで表示されるべきであれば，presentViewControllerをつかって，modal view controllerを表示すればよい．

これが大まかな処理の流れとなる．よくデザインされているように見える．

![flow.001.jpeg](https://qiita-image-store.s3.amazonaws.com/0/3341/7a899b2d-c00f-43a6-00fd-da8ba57544f2.jpeg)

# コード
実際の```UIViewControllerPreviewingDelegate```プロトコルで実装するコードを見てみる．
まず，3D Touchが押された場所で表示する情報があるかを判定する．
サンプルコードでは，`getInfo`というメソッドは，その場所に含まれるURLとその領域を返し，その戻り値が```nil```でないとき，そのURLを`WebViewController`に設定し，ランタイムに返すようになっている．

```
func previewingContext(_ previewingContext: UIViewControllerPreviewing,
    viewControllerForLocation location: CGPoint) -> UIViewController? {
    
    let locationInTextView = self.view.convert(location, to: textView)

    if let (url, rect) = getInfo(locationInTextView: locationInTextView) {
        previewingContext.sourceRect = self.view.convert(rect, from: textView)
        let controller = WebViewController(nibName: nil, bundle: nil)
        controller.url = url
        return controller
    }
    
    return nil
}
```

さらに，ユーザが強く押し込んだ時は，上のコードで作成した`WebViewController`を`UINavigationController`のルートに設定し，modalで表示するようにしている．
このようにして，peek状態の時と，最終的にちゃんと開いた時のview controllerの状態を分けてコーディングすることができる．
これは，なかなかよくできている．

```
func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController) {
    let nav = UINavigationController(rootViewController: viewControllerToCommit)
    self.present(nav, animated: true, completion: nil)
}
```

以上で基本的な3D touchの実装は終わりだが，よりかっこいいUIを実現するために突っ込んだところをみていこう．

# ぼかす領域
<img src="http://i.giphy.com/l0HlwqSqCfa9gsOSA.gif"> <img src="http://i.giphy.com/3oriNJsUeF8I4Rrg3e.gif">

上の動画の例でわかるように，3D touchは押し込んだ時に反応した部分だけをぼかさないようにして，次のpeek状態へアニメーションさせることができる．上の例では，強く押し込んだ"Let's Enctypt"のリンクのところだけがぼかされず，拡大されるようアニメーションすることがわかる．この効果を得るためには，```previewingContext(_. location)```の中で，```UIViewControllerPreviewing```の```sourceRect```に正しい領域を設定する必要がある．このとき，`UIView`のメソッドである`convert`を使って，ぼかす領域をセットする．`UIViewControllerPreviewing.sourceRect`は，`registerForPreviewing`でセットしたviewの座標系で表さなければならない．`convert`メソッドの動作イメージは，下図を例にすると`b.frame`が`(200, 50, 50, 60)`の値であるとき，`convert`を使って，`b.frame`をビュー`a`からみた時の値に`convert`を使って変換することができる．

<img width="509" alt="convert_sample.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/3341/d837f0f4-9eb2-563d-d253-beee74d9b2c7.jpeg">

```
// fromの場合
let f_from = a.convert(b.frame, from: c)
print(f_from) => (400, 70 50, 60)

// toの場合
let f_to = c.convert(b.frame, to: a)
print(f_to) => (400, 70 50, 60)
```

この`convert`メソッドは，アニメショーンさせる時に役立つ．
今回の例では，
```
previewingContext.sourceRect = self.view.convert(rect, from: textView)
```
の部分では，ぼかす領域を`textView`の座標から，`self.view`の座標に変換している．
# サブメニュー

3D touchをした後に表示させる`UIViewController`に，メニューアイテムを実装しておくと，強く押して表示されるpeek viewを上にドラッグした時にメニューを表示させることができる．```UIViewController```は，3D touch用に`previewActionItems`というプロパティが追加されており，このプロパティが，`UIPreviewActionItem`の配列を返すと，このサブメニューが表示されるようになる．`UIPreviewActionItem`は，階層構造を作ることもできる，実際のコードを見てみる．

```
override var previewActionItems : [UIPreviewActionItem] {
    get {
        func previewActionForTitle(_ title: String, style: UIPreviewActionStyle = .default) -> UIPreviewAction {
            return UIPreviewAction(title: title, style: style) { previewAction, viewController in
                print(title)
            }
        }
        
        let action1 = previewActionForTitle("Action1")
        let action2 = previewActionForTitle("Destructive Action", style: .destructive)
        
        let subAction1 = previewActionForTitle("Sub1")
        let subAction2 = previewActionForTitle("Sub2")
        let groupedActions = UIPreviewActionGroup(title: "Sub Actions", style: .default, actions: [subAction1, subAction2] )
        
        return [action1, action2, groupedActions]
    }
}
```

各アイテムを選んだ時のアクションは，ブロックで実装する．

# まとめ
3D touchの肝は，
1. 準備
2. 強く押した時のpeek表示，ぼかさない領域の設定
3. さらに押し込んだ時の対応
4. メニューを用意する時は，`previewActionItems`を使う
となる．

タップされた場所に対応するコンテンツを用意に用意できる（例えばUIImageViewとか，リンクとか）ならば，サポートするのは簡単なので，実装することをオススメする．

## テスト環境
テストするためには，やはり3D touchをサポートするiPhone6s以降のiPhoneの実機を使うことが望ましいが，感圧タッチトラックパッドを備えるMacBook，あるいあMagicTrackPad2があれば，それをつかってシミュレータでテストすることもできる．設定はメニューから，Hardware→Touch pressure→Use Trackpad Forceにチェックをつければ良い．

![simulator.jpg](https://qiita-image-store.s3.amazonaws.com/0/3341/d005144d-28a2-ad6d-d940-2169ceb86e3b.jpeg)

<script async class="speakerdeck-embed" data-id="13184e203cb945688fb7d03d72f9bd8b" data-ratio="1.33333333333333" src="//speakerdeck.com/assets/embed.js"></script>

<script async class="speakerdeck-embed" data-id="f2dbf2ce170741e4a26984b11c52d2d1" data-ratio="1.77777777777778" src="//speakerdeck.com/assets/embed.js"></script>