How to detect iPad trackpad touch-down (indirectPointer) to immediately stop coasting animation

I have a custom 3D object viewer on iOS that lets users spin the model using the touchscreen or a trackpad and supports coasting (momentum spinning). I need to stop the coasting animation as soon as the user touches down, but I can only immediately detect touches on the screen itself - on the trackpad I can't get an immediate notification of the touches.
So far I’ve tried:
State.began on my UIPanGestureRecognizer. It only fires after a small movement on both touchscreen and trackpad.
.possible on the pan gesture; this state never occurs during the gesture cycle.
UIApplicationSupportsIndirectInputEvents = YES in Info.plist; it didn’t make touchesBegan fire for indirectPointer touches.
Since UITableView (and other UIScrollView subclasses) clearly detect trackpad “touch-down” to cancel scrolling, there must be a way to receive that event. Does anyone know how to catch the initial trackpad contact—before any movement—on an indirect input device?
Below is a minimal code snippet demonstrating the issue. On the touchscreen you'll see a message the moment you touch the view, but the trackpad doesn't trigger any messages until your fingers move. Any advice would be greatly appreciated.
import UIKit
class ViewController: UIViewController {
private let debugView = DebugView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
// Fill the screen with our debug view
debugView.frame = view.bounds
debugView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
debugView.backgroundColor = UIColor(white: 0.95, alpha: 1)
view.addSubview(debugView)
// Attach a pan recognizer that logs its state
let panGR = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
panGR.allowedScrollTypesMask = .all
debugView.addGestureRecognizer(panGR)
}
@objc private func handlePan(_ gr: UIPanGestureRecognizer) {
switch gr.state {
case .possible:
print("Pan state: possible")
case .began:
print("Pan state: began")
case .changed:
print("Pan state: changed – translation = \(gr.translation(in: debugView))")
case .ended:
print("Pan state: ended – velocity = \(gr.velocity(in: debugView))")
case .cancelled:
print("Pan state: cancelled")
case .failed:
print("Pan state: failed")
@unknown default:
print("Pan state: unknown")
}
}
}
class DebugView: UIView {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
for t in touches {
let typeDesc: String
switch t.type {
case .direct: typeDesc = "direct (finger)"
case .indirectPointer: typeDesc = "indirectPointer (trackpad/mouse)"
case .indirect: typeDesc = "indirect (Apple TV remote)"
case .pencil: typeDesc = "pencil (Apple Pencil)"
@unknown default: typeDesc = "unknown"
}
print("touchesBegan on DebugView – touch type: \(typeDesc), location: \(t.location(in: self))")
}
}
}
Answer
I asked the question on Apple's developer forums and this is the answer from "Apple Frameworks Engineer", which did solve my problem:
There's a misconception here that is causing you problems. I'd recommend this video from WWDC20 starting here (but really just the whole video):
A pointer-based touch is only going to be
UITouch.Phase.began
->UITouch.Phase.ended
when you have clicked down on the pointing device (there will also be an associatedbuttonMask
). When your finger is on the touch surface of the trackpad or Magic Mouse it will not be in these phases, but a phase likeUITouch.Phase.regionEntered
orUITouch.Phase.regionMoved
.Gestures like
UIPanGestureRecognizer
,UITapGestureRecognizer
, andUILongPressGestureRecognizer
do not consume touches in these phases, so that's why they are not working for you. And to be clear, there is no way to tell these gestures to do so.The only gesture that consumes these type of hovering touches is
UIHoverGestureRecognizer
. I'd recommend adding aUIHoverGestureRecognizer
to one of your views. Do note that this is going to fire whenever the pointer is visible and within your view, so if this is a large container, that could be frequent. You may want to enable this gesture when the momentum spinning begins, but otherwise keep it disabled.Hope that helps!
I couldn't just activate the hover recognizer immediately after my pan recognizer ended because it would still fire off some .changed events and freeze the animation early. So my implementation was to start watching for the touches when my pan recognizer ended, but after a delay. I wrote this scheduleStartWatchingForTrackpadTouches() method for my view controller and had the .ended handler for the pan recognizer call it:
func scheduleStartWatchingForTrackpadTouches() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.startWatchingForTrackpadTouches()
}
}
Then I used this pair of methods to create and activate/deactivate the recognizer as needed:
func startWatchingForTrackpadTouches() {
// Create the recognizer the first time through
if hoverRecognizer == nil {
hoverRecognizer = UIHoverGestureRecognizer(target: self, action: #selector(handleHover(_:)))
}
// Add the recognizer to the view
if let recognizer = hoverRecognizer {
gestureView.addGestureRecognizer(recognizer)
}
}
func stopWatchingForTrackpadTouches() {
// Remove the recognizer from the view
if let recognizer = hoverRecognizer {
gestureView.removeGestureRecognizer(recognizer)
}
}
Finally, this is the event handler for the recognizer:
@objc private func handleHover(_ recognizer: UIHoverGestureRecognizer) {
if recognizer.state == .changed {
// Notify of touch begin
touchesBegan()
// Don’t watch until explicitly asked
stopWatchingForTrackpadTouches()
}
}
When the touch on the trackpad starts, the hover recognizer immediately starts sending .changed messages. The first .changed message calls my view controller's touchesBegan() method which freezes the animations, and then the recognizer is disabled until it's needed after the next pan gesture ends.
Enjoyed this article?
Check out more content on our blog or follow us on social media.
Browse more articles