Add vertical two finger swipe gesture to UIScrollView

Viewed 1315

According to How to add a vertical swipe gesture to iPhone app for all screens, I add a two finger swipe down gesture to window, which works fine on normal pages throughout the whole app. But it fails on the page has UIScrollView (like UITableViewController). When I swipe down with two fingers on UIScrollView, it just scrolls it as normal. If I swipe from UINavigationBar that above UIScrollView, it works fine again.

The ideal result is that I can scroll tableview by one finger normally, and call some method by swiping page with two fingers without scrolling tableview. This is used in TweetBot to switch dark mode, works perfectly.

According to Apple's document: Using Responders and the Responder Chain to Handle Events, I think I understand how the Responder Chain works, so I want to ask UIScrollView to ignore the two fingers swipe gesture so that it can pass this event to UIWindow. But I can't figure out how to:

I tried to implement UIGestureRecognizerDelegate's func gestureRecognizer(_:, shouldRequireFailureOf otherGestureRecognizer:) from Apple's document, or override gestureRecognizerShouldBegin(_) by inherit UITableView. But all didn't work.

Any solutions or advice is welcomed.

Update - Final Solution

I simplified joern's solution and here is.

In AppDelegate

// It's not necessary to keep a reference to gesture unless you want to do something further.
lazy var gesture: UIPanGestureRecognizer = {
    let gesture = UIPanGestureRecognizer(target: self, action: #selector(twoFingerDidSwipe(recognizer:)))
    gesture.minimumNumberOfTouches = 2
    gesture.maximumNumberOfTouches = 2
    return gesture
}()

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    gesture.delegate = AppDelegate
    window.addGestureRecognizer(gesture)
    return true
}

@objc func twoFingerDidSwipe(recognizer: UIPanGestureRecognizer) {
    let swipeThreshold: CGFloat = 50

    if recognizer.state == .changed { // 1
      switch recognizer.translation(in: window).y { // 2
      case ...(-swipeThreshold):
        print("Swipe Up")
        recognizer.state = .cancelled // 3
      case swipeThreshold...:
        print("Swipe Down")
        recognizer.state = .cancelled
      default:
        break
      }
    }
}

And

extension AppDelegate: UIGestureRecognizerDelegate {
  func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
    return true
  }
}

Some explanations about differences from joern's solution:

  1. Only recognizer.state == .change is concerned.
  2. recognizer.translation(in:)'s parameter should be window so that I can do nothing about particular VCs.
  3. After triggering some methods by this recognizer, it should be cancelled to prevent from triggering them continually.

And It's unnecessary to do anything about any particular VC.

With this, two-finger swipe gesture works fine on both normal VC and scroll VC.

1 Answers

When you add the UISwipeGestureRecognizer to your window, keep a reference to it, so you can access it later via the AppDelegate:

class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    var twoFingerSwipeDownRecognizer: UISwipeGestureRecognizer?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        let twoFingerSwipeDownRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(didRecognizeTwoFingerSwipeDown))
        twoFingerSwipeDownRecognizer.numberOfTouchesRequired = 2
        twoFingerSwipeDownRecognizer.direction = .down
        window?.addGestureRecognizer(twoFingerSwipeDownRecognizer)
        self.twoFingerSwipeDownRecognizer = twoFingerSwipeDownRecognizer

        return true
    }

    @objc func didRecognizeTwoFingerSwipeDown(recognizer: UISwipeGestureRecognizer) {
        print("SWIPE DOWN")
    }
}

Then, in the UIViewController that contains the UITableView (or UIScrollView) you have to call require(toFail:) on the UITableView's pan gesture recognizer:

func enableTwoFingerSlideDown() {
    guard
        let appDelegate = UIApplication.shared.delegate as? AppDelegate,
        let twoFingerGestureRecognizer = appDelegate.twoFingerSwipeDownRecognizer
        else {
            return
        }
    tableView.panGestureRecognizer.require(toFail: twoFingerGestureRecognizer)
}

Now the two finger down gesture works over a UITableView.

UPDATE

Because a swipe is a discrete gesture the solution above might not be the perfect solution. When you scroll down slowly with one finger you will notice that the UITableView will not scroll down instantly. There is a short delay because the UITableView's UIPanGestureDelagate has to wait until the (Two Finger) SwipeDelegate has failed. And that takes some time.

A better solution might be to use a UIPanGestureRecognizer to recognize a two finger pan and then disable the scrolling on the UITableView while the user is panning using two fingers.

That could be achieved like this:

In your AppDelegate:

class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    var twoFingerPanRecognizer: UIPanGestureRecognizer?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        let twoFingerSwipeDownRecognizer = UIPanGestureRecognizer(target: self, action: #selector(didRecognizeTwoFingerPan))
        twoFingerSwipeDownRecognizer.minimumNumberOfTouches = 2
        twoFingerSwipeDownRecognizer.maximumNumberOfTouches = 2
        twoFingerPanRecognizer?.delegate = self
        window?.addGestureRecognizer(twoFingerSwipeDownRecognizer)
        self.twoFingerPanRecognizer = twoFingerSwipeDownRecognizer

        return true
    }

    @objc func didRecognizeTwoFingerPan(recognizer: UIPanGestureRecognizer) {
        let tableView = recognizer.view as? UITableView
        switch recognizer.state {
        case .began:
            tableView?.isScrollEnabled = false
        case .changed:
            let swipeThreshold: CGFloat = 50
            switch recognizer.translation(in: nil).y {
            case ...(-swipeThreshold):
                print("Swipe UP")
                recognizer.isEnabled = false
            case swipeThreshold...:
                print("Swipe DOWN")
                recognizer.isEnabled = false
            default:
                break
            }
        case .cancelled, .ended, .failed, .possible:
            tableView?.isScrollEnabled = true
            recognizer.isEnabled = true
        }
    }
}

extension AppDelegate: UIGestureRecognizerDelegate {
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        return true
    }
}

In your ViewController:

func enableTwoFingerSlideDown() {
        guard
            let appDelegate = UIApplication.shared.delegate as? AppDelegate,
            let twoFingerGestureRecognizer = appDelegate.twoFingerPanRecognizer
            else {
                return
            }
        tableView.addGestureRecognizer(twoFingerGestureRecognizer)
    }

You have to take the UIPanGestureRecognizer from the AppDelegate and add it to the UITableView. Otherwise this won't work. Just remember to add it back to the UIWindow before this UIViewController is dismissed.

With this solution the normal scrolling behavior of the UITableView remains unchanged.

Related