1. marty-suzuki

    Posted

    marty-suzuki
Changes in title
+3DTouchをUINavigationControllerに入れて新しいUXを作ってみた
Changes in tags
Changes in body
Source | HTML | Preview
@@ -0,0 +1,150 @@
+# まず始めに
+
+iPhone6s、iPhone6s plusから3DTouchが使えるようになりPeek and PopやQuick Actionsなどありますが、それ以外の部分で具体的にどういった部分に導入すれば良いかというものは決まっていない印象があります。
+いろいろと導入箇所を検討してみたところ、UINavigationControllerの履歴と紐付けたら面白いのではないかと考えました。
+そこで、元々リリースしていた[SAHistoryNavigationViewController](https://github.com/szk-atmosphere/SAHistoryNavigationViewController)というUINavigationControllerの履歴を表示し任意のViewControllerまで戻れるライブラリに3DTouchを追加してみました。
+追加してみた結果のGIFアニメーションが以下になります。
+![Sample](http://uploda.cc/img/img562f6cb5c6ae5.gif)![Samples](https://github.com/szk-atmosphere/SAHistoryNavigationViewController/blob/master/SampleImage/3dtouch.gif?raw=true)
+
+# 実装のポイント
+
+- `UINavigationBar`のバックボタンの部分に3DTouchを追加する
+- `UINavigationBar`のバックボタンのアクションと3DTouchのアクションが衝突しないようにする
+- ViewControllerの遷移と3DTouchの深度を紐付かせるために、カスタムアニメーションを実装し[UIPercentDrivenInteractiveTransition](https://developer.apple.com/library/prerelease/tvos/documentation/UIKit/Reference/UIPercentDrivenInteractiveTransition_class/index.html)でインタラクティブな動きにする
+
+# 3DTouchを扱うクラスをつくる
+```swift
+import UIKit.UIGestureRecognizerSubclass
+import AudioToolbox.AudioServices
+
+@available(iOS 9, *)
+/*
+ * UINavigationBarのバックボタンのアクションと3DTouchのアクションが衝突しないようにするために
+ * UILongPressGestureRecognizerを継承してminimumPressDurationをしたいからです。
+ */
+class SAThirdDimensionalTouchRecognizer: UILongPressGestureRecognizer {
+ // 3DTouchの深度をパーセンテージにするために使います
+ private(set) var percentage: CGFloat = 0
+ // 3DTouchのアクションが完了する割合を設定するために使います
+ var threshold: CGFloat = 1
+
+ init(target: AnyObject?, action: Selector, threshold: CGFloat) {
+ self.threshold = threshold
+ super.init(target: target, action: action)
+ }
+
+ override func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent) {
+ super.touchesMoved(touches, withEvent: event)
+
+ guard let touch = touches.first else {
+ return
+ }
+ percentage = max(0, min(1, touch.force / touch.maximumPossibleForce))
+ /*
+ * minimumPressDurationで設定した秒数後にstateが.Begin -> .Changedになる可能性があるので
+ * stateが.Changedだった場合を条件文に含んでいます
+ */
+ if percentage > threshold && state == .Changed {
+ state = .Ended
+ // 3DTouchのバイブレーションがなかったので、システムのバイブレーションを使います。
+ AudioServicesPlayAlertSound(kSystemSoundID_Vibrate)
+ }
+ }
+
+ override func touchesEnded(touches: Set<UITouch>, withEvent event: UIEvent) {
+ super.touchesEnded(touches, withEvent: event)
+ // touchesMovedでstateを.Endにするので、ここに入った場合は失敗となります
+ state = .Failed
+ }
+
+ override func reset() {
+ super.reset()
+ percentage = 0
+ }
+}
+```
+
+# 3DTouchを追加する
+
+バックボタンに直接アクションを追加することができないので、まず`UINavigationBar`に3DTouchが有効になるようにします。
+バックボタンのアクションと3DTouchが衝突しないようにするために、minimumPressDurationも設定します。
+
+```swift
+let gestureRecognizer = SAThirdDimensionalTouchRecognizer(target: self, action: "handleThirdDimensionalTouch:", threshold: 0.75)
+gestureRecognizer.minimumPressDuration = 0.2
+gestureRecognizer.delegate = self
+navigationBar.addGestureRecognizer(gestureRecognizer)
+```
+
+`UINavigationBar`のバックボタンあたりでだけ3DTouchが有効になるようにするために、UIGestureRecognizerDelegateに以下のような実装をします。
+
+```swift
+extension SAHistoryNavigationViewController: UIGestureRecognizerDelegate {
+ public func gestureRecognizerShouldBegin(gestureRecognizer: UIGestureRecognizer) -> Bool {
+ if let _ = visibleViewController?.navigationController?.navigationBar.backItem, view = gestureRecognizer.view as? UINavigationBar {
+ var height = 64.0
+ if visibleViewController?.navigationController?.navigationBarHidden == true {
+ height = 44.0
+ }
+ let backButtonFrame = CGRect(x: 0.0, y :0.0, width: 100.0, height: height)
+ let touchPoint = gestureRecognizer.locationInView(view)
+ if CGRectContainsPoint(backButtonFrame, touchPoint) {
+ return true
+ }
+ }
+ return false
+ }
+}
+```
+
+# ViewControllerの遷移と紐付ける
+
+`UIViewControllerTransitioningDelegate`で任意の`UIViewControllerAnimatedTransitioning`を実装したクラスのインスタンスを返して、ViewControllerが表示されるときのみインタラクティブな遷移をするように実装します。
+
+```swift
+extension SAHistoryNavigationViewController : UIViewControllerTransitioningDelegate {
+ public func animationControllerForPresentedController(presented: UIViewController, presentingController presenting: UIViewController, sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
+ return SAHistoryViewAnimatedTransitioning(isPresenting: true)
+ }
+
+ public func animationControllerForDismissedController(dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
+ return SAHistoryViewAnimatedTransitioning(isPresenting: false)
+ }
+
+ public func interactionControllerForPresentation(animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
+ // UIPercentDrivenInteractiveTransitionのproperty
+ return interactiveTransition
+ }
+}
+```
+
+3DTouchを検知した際に以下のようにViewControllerを生成してpresentViewControllerした後に、gestureのstateに合わせて処理をしていきます。
+
+```swift
+@available(iOS 9, *)
+func handleThirdDimensionalTouch(gesture: SAThirdDimensionalTouchRecognizer) {
+ switch gesture.state {
+ case .Began:
+ let viewController = ViewController()
+ viewController.transitioningDelegate = self
+ presentViewController(viewController, animated: true, completion: nil)
+
+ case .Changed:
+ // インタラクティブな遷移をさせるために、3DTouchの深度を元にしたパーセンテージを渡しています。
+ interactiveTransition?.updateInteractiveTransition(min(gesture.threshold, max(0, gesture.percentage)))
+
+ case .Ended:
+ if gesture.percentage >= gesture.threshold {
+ interactiveTransition?.finishInteractiveTransition()
+ } else {
+ interactiveTransition?.cancelInteractiveTransition()
+ }
+
+ case .Cancelled, .Failed, .Possible:
+ break
+ }
+ }
+```
+
+# 最後に
+UINavigationControllerのバックボタンに3DTouchを追加してみたところ思っていたよりも不自然さがないので、UINavigationControllerにpushされ続けるとなかなかトップに戻れない問題の新しいユーザー体験の1つになるのではないかと思っております。