A Touching Story About JavaScript Gesture Responders in React Native

Recently, I had to dive deep on the native hooks for setting JavaScript touch responders in React Native, and I wanted to share what I’ve learned.

ScrollView Fail

We had a few issues filed on react-native-windows related to PanResponders not working when they are applied to a view that has a ScrollView ancestor. A common example of this is a ListView where the rows respond to a swipe gesture, e.g., SwipeableListView.


An Underappreciated UIManager API

It turns out, when we were building react-native-windows, we had a few specific apps we were testing, and gestures inside a ScrollView was not a scenario in any of them. In fact, there was no SwipeableListView component available in core React Native when we first started active development. So, when we were scratching our heads trying to figure out what the UIManager setJSResponder API did, and not implementing it had no effect on our target apps, we punted on the issue and figured it was something we could implement later if we needed it. After all, we had lots of other features to implement, and we were trying to keep pace with the mach speed evolution of React Native.

It turns out that this API is quite important for the touch handling implementations in iOS and Android.  On Android, there is an API called onInterceptTouchEvent, which allows ViewGroups to preview and capture touch events before they are dispatched to their children.  When setJSResponder is called, the React Native bridge on Android sets the given responder tag inside the JSResponderHandler, which is reachable from all ViewGroups. During the chain of onInterceptTouchEvent calls down the view hierarchy, each ViewGroup evaluates if it is the designated responder in the JSResponderHandler, and if so, captures the touch event. When the event is captured, it prevents system responders like scrolling and drawer layout operations from responding to the gesture.

On iOS, the use of the setJSResponder API has a much more limited scope. When setJSResponder is called, the responder tag is set to a public property on the UIManager. Inside the native ScrollView implementation, the panGestureRecognizer on the UIScrollView is set to a function that checks if the current JavaScript responder is a descendant of the UIScrollView, and if so, cancels the pan on the UIScrollView so the events are only processed by the specified JS responder.

UWP is a Whole Different Story

So because we were just ignoring the setJSResponder call, we were doing nothing to ensure that the native ScrollView in UWP did not just intercept all the gestures before the inner views had a chance to capture the pointer.

UWP does not have an analogy to the onInterceptTouchEvent for parents to capture the input before a child view has a chance to, so we were not able to implement a similar architecture to Android.

UWP does, however, have an API called CancelDirectManipulations on UIElement that looked like a promising way to effectively replicate the pan-canceling behavior of setJSResponder on iOS. But before we go into why that turned out to be inadequate, lets go back to the sequence of events that leads to setJSResponder being called on the UIManager in the first place.

When a gesture is started in React Native, the native touch implementation sends a “topTouchStart” event on the RCTEventEmitter JavaScript API, receiveTouches. The JavaScript event implementation uses a bubble/capture algorithm to find the designated event responder for the touch.  The way the bubble/capture algorithm works is a two phase traversal of the view hierarchy, starting from the root and ending at the touch target (as determined by the native touch handing behavior). For each node along the path from root to the touch target, the “capture” handler is executed (in the case of “topTouchStart”, the capture handler is “onStartShouldSetResponderCapture”). If any of these handlers return true, the owner of that event handler becomes the responder. If none of the capture handlers return true, the bubble handlers are evaluated from touch target back to the root. Once one of these bubble or capture handlers returns true, the element is notified that it is now the responder via a call to “onResponderGrant” and, assuming the responder has changed, the native setJSResponder API is called on the UIManager. Other events can trigger this responder change, including move events and scroll events, and any change in the responder instance will result in a native notification via the UIManager setJSResponder API. For example, the JavaScript PanResponder module uses touch move events to check if an element should take over as a responder.

So, now that we know how the setJSResponder API is called, we can discuss why the scroll cancellation approach in iOS won’t work for UWP. We can use the example of SwipeableRow. On iOS, when the user starts a swipe gesture, the native touch event handler emits a touch start event (i.e., “topTouchStart”) and a series of touch move events (i.e., “topTouchMove”). At some point, the swipe gesture exceeds the horizontal threshold for a valid swipe, and the SwipeableRow takes over as the touch event responder. Once the SwipeableRow takes over as the responder, the UIManager setJSResponder API is called, and the UIScrollView cancels any active pans, ensuring all subsequent touches are sent to the RCTEventEmitter and the SwipeableRow can continue to respond.

On UWP, however, when the ScrollViewer recognizes pan gestures, it immediately cancels any active pointer event sequences related to the pan gesture. When this cancellation occurs, a “topTouchCancel” event is dispatched. This “topTouchCancel” event is needed for very valid scenarios, such as touch points moving off screen or any other native component taking control. However, in this case, the touch cancellation occurs before the PanResponder is able to hit the horizontal threshold and take control as the responder, even if the gesture is not on the primary scrolling axis of the ScrollViewer. As soon as the “topTouchCancel” event is dispatched to JavaScript, the responder is cleared and the touch history is released, so further processing of the gesture would not occur.

Ultimately, it is the asynchronous nature of the communication between the UI thread and the JavaScript thread in the React Native architecture that prevents the pan cancellation approach from working on UWP. The ScrollViewer recognizes the pan and captures the pointer within a few frames, and the JavaScript thread has no chance of processing the initial touch event and batching the setJSResponder call before this capture occurs.

As a workaround to the async behavior, I considered deferring the touch cancel event to check if setJSResponder would be called in the next batch of native method calls. If the setJSResponder API was called, the CancelDirectManipulations() API would be called to release the ScrollViewer’s capture of the pointer. However, in most cases the ScrollViewer should be allowed to take over as the responder, so we don’t want to introduce a delay in that behavior. For example, Touchable descendants of the ScrollViewer should defer to the ScrollViewer if a pan gesture is started, so we don’t want to cancel any pan gestures in that case.

The Final Solution?

The recommended way to prevent a ScrollViewer from capturing the pan gesture on UWP is to set the ManipulationMode property on the child view that should respond to the gesture instead. There’s actually a fairly similar example to this that conditionally disables scrolling if the input device is a pen, achieved using the ManipulationMode property. However, the main difference between the pen example and what we are trying to do here is, again, the asynchronous disconnect between the UI thread and JavaScript thread.

So, at this point, I’m fairly certain that the setJSResponder API is useless for UWP, at least for the use case of unblocking gestures behind ScrollViewers. The approach I took instead was to declaratively set the ManipulationMode property of the view by requiring the user to set a prop on the React element. A pull request was created and merged, and the manipulationModes prop is available in react-native-windows@0.47.* and higher. We have an example of how to use the prop in the react-native-windows fork of SwipeableRow.

  style={{transform: [{translateX: this.state.currentLeft}]}}>

This is something we will continue to discuss as time goes on, as this declarative requirement is something specific to UWP. We’d love to find an alternative that does not require any additional React props specific to Windows or knowledge of how manipulation modes work.

A Touching Story About JavaScript Gesture Responders in React Native