Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

iOS: Detecting touch down, segue, touch up

  1. User puts her finger on the screen. This triggers a UITouchEvent, phase Began, which calls the touchesBegan:withEvent: method in controllerA, which performs a segue from controllerA to controllerB.
  2. User lifts her finger off the screen. This triggers a UITouchEvent, phase Ended, which calls some callback method.

Question: What and where is this callback method? It's not in controllerA, and it's not in controllerB. From what I can tell, it's not in any view. But it exists.

like image 622
Tsubaki Avatar asked Oct 21 '22 12:10

Tsubaki


1 Answers

To clarify, here's what's going on (according to @switz):

  • In response to -touchesBegan:withEvent:, a view controller is presented modally via a segue
  • When the user lifts up their finger, the view controller should be dismissed.

The question is how to react to the finger being lifted, since -touchesEnded:withEvent: is not invoked.

The short answer is the presented view controller needs to use the "Over Full Screen" modalPresentationStyle instead of the default "Full Screen" style (this can either be specified as the presentation style of the segue, or if that's "Default" then the presentation style of the presented view controller).

The long answer requires a brief overview of how touch handling works. This explanation ignores gesture recognizers:

When a touch begins, it's delivered to the "topmost" view that contains the touch point. From there it gets passed along the responder chain until some object decides to handle the touch (which is signified by implementing -touchesBegan:withEvent: and not calling super).

Subsequent changes to the touch (e.g. moved, ended, canceled) are delivered back to the same view that accepted the touch. The view will continue to receive the touch events until the touch finishes or cancels.

A touch is canceled either when the application moves to the background (because e.g. a phone call came in), or when a UIKit class like UIScrollView decides that it needs to take over touch handling (because the finger moved far enough that it looks like the user wants to scroll). There's also some funny stuff here with UIScrollView.delaysContentTouches, but that can be ignored.

But there's a wrinkle, something that isn't documented: touch delivery only happens so long as the view remains associated with the window. If the view that is considered "topmost" (the view that is associated with the UITouch) is removed from the window, then the touch is considered to have vanished and, importantly, no events for that touch are delivered again, to anyone. This is true even if the view in question is not the object handling touches.

And that final wrinkle is the cause for this problem. Because the default "Full Screen" presentation style actually removes the old view controller's view from the window, the touch handling immediately stops. However, the "Over Full Screen" presentation style does not remove it, it merely covers up the old view with the one. "Over Full Screen" is typically used when the presented view controller is not fully opaque, but in this case we're using it so touch handling isn't interrupted.


But that's not all. There's another problem here, which is when the view that's being touched lives inside a UIScrollView (one that either is scrollable or always bounces). In that case, even with "Over Full Screen", you'll find that, while the touch events continue to be delivered, moving your finger around a bit suddenly causes the touch to be canceled. This is because the UIScrollView doesn't know it's covered up and has decided that the user is actually trying to scroll. This causes it to cancel the touch.

There is a solution to this, though. It's kind of ugly, but the solution is to immediately cancel any scrolling on any enclosing scroll view when performing the segue. This can be done with code like the following:

class ViewController: UIViewController {
    // this is called from -touchesBegan:withEvent: from a child view
    // the child view is `sender`
    func touchDown(sender: UIView) {
        var view = sender.superview
        while view != nil {
            if let scrollView = view as? UIScrollView {
                // toggle the panGestureRecognizer enabled state to immediately
                // cause it to fail.
                let enabled = scrollView.panGestureRecognizer.enabled
                scrollView.panGestureRecognizer.enabled = true
                scrollView.panGestureRecognizer.enabled = enabled
            }
            view = view?.superview
        }
        performSegueWithIdentifier(identifier, sender: self)
    }
    // ...
}

Of course, no discussion of touch handling would be complete without gesture recognizers. Gesture recognizers change pretty much everything about touch handling. They get first dibs on any touches, and they can interrupt view touch handling at any time. For example, the UIScrollView's UIPanGestureRecognizer is what is used for scrolling, and when it moves into the "began" state (because the user has moved their finger enough), that's what causes the touch to be canceled.

So given this, really the best solution here is to not implement -touchesBegan:withEvent: at all, but to use a gesture recognizer. The easiest solution here is to use a UILongPressGestureRecognizer with minimumPressDuration set to 0 and allowableMovement set to some ridiculously high value (since you don't want movement to cancel the touch). I'm recommending this because UILongPressGestureRecognizer is a continuous recognizer, meaning it will send events for Began, Moved, and Ended, and with the recommended settings, it will send them in response to the touch beginning, moving, and ending. What's more, once your recognizer starts handling the touch, this automatically prevents any other recognizers (such as the scroll view's pan recognizer) from "taking over" and canceling the touch.


Note that if you're attaching your gesture recognizer to the scrollView itself (e.g. a UITableView) but only want to respond to touches in certain locations (such as on a row), then you'll need to restrict the recognizer. You can use the delegate method gestureRecognizer(_:shouldReceiveTouch:) to do this, something like this:

func gestureRecognizer(recognizer: UIGestureRecognizer, shouldReceiveTouch touch: UITouch) -> Bool {
    // if you might be the delegate of multiple recognizers, check for that
    // here. This code will assume `recognizer` is the correct recognizer.
    // We're also assuming, for the purposes of this code, that we're a
    // UITableViewController and want to only capture touches on rows in the
    // first section.
    let touchLocation = touch.locationInView(self.tableView)
    if let indexPath = self.tableView.indexPathForRowAtPoint(touchLocation) {
        if indexPath.section == 0 {
            // we're on one of the special rows
            return true
        }
    }
    return false
}

This way the recognizer won't prevent the tableView's panGestureRecognizer from scrolling when the user touches elsewhere on the table.

like image 160
Lily Ballard Avatar answered Nov 01 '22 18:11

Lily Ballard