UITouchEvent
, phase Began
, which calls the touchesBegan:withEvent:
method in controllerA
, which performs a segue from controllerA
to controllerB
.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.
To clarify, here's what's going on (according to @switz):
-touchesBegan:withEvent:
, a view controller is presented
modally via a segueThe 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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With