Edited at

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

More than 3 years have passed since last update.

「集まれ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


コード

実際の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
}

さらに,ユーザが強く押し込んだ時は,上のコードで作成したWebViewControllerUINavigationControllerのルートに設定し,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を実現するために突っ込んだところをみていこう.


ぼかす領域

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

convert_sample.jpg

// 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