React Native

How to handle user gestures in React Native with PanResponder

Today I'll tell you the story of how I developed a calendar handling pan gestures:

 

Bam tech React Native Calendar

 

It all started one day when our client asked us for a new feature: a calendar where you can set your availabilities by pressing on a day, or a pan gesture after a long press.

As a young React Native developer I had absolutely no idea how to do this. I started panicking: Was it even possible? How were we going to do that?

When I had calmed down, I spent some time to find different technical solutions and compare them to decide on the best one. I looked into open source solutions, but I didn't find what I was looking for, neither in calendar packages like ++code>react-native-calendar++/code> nor in interactions packages like ++code>react-native-interactable++/code>. And that's when I came across the ++code>PanResponder++/code>.

The PanResponder is a React Native API that provides listeners to handle all types of gestures : single press, long press, pan gestures, force touch (for devices supporting it), multi touches...

Using the PanResponder, it seemed I could do everything I needed for this calendar. And that's when the journey began.

 

 

The beginning

I first simply started to learn how PanResponder works. I used a View holding the PanResponder, with some Views inside to display 2 weeks.

++pre>++code><View {...this.panResponder.panHandlers}>
<View><Text>1</Text></View>
<View><Text>2</Text></View>
...
<View><Text>14</Text></View>
</View>
++/code>++/pre>

and the PanResponder was created as in React Native documentation with :

++pre>++code>onStartShouldSetPanResponder: (evt, gestureState) => true,
onStartShouldSetPanResponderCapture: (evt, gestureState) => true,
onMoveShouldSetPanResponder: (evt, gestureState) => true,
onMoveShouldSetPanResponderCapture: (evt, gestureState) => true,
++/code>++/pre>

Next step was playing with all the listeners ...

Alright, then I needed to find on which days the user is pressing. I used the ++code>onLayout++/code> property on the View containing the days list to get the ++code>height++/code> and ++code>width++/code> and the coordinates of the first day cell.

All I had to do was to use the ++code>onPanResponderGrant/Move/Release++/code> listener and in particular the ++code>gestureState++/code> to get the coordinates of the user gesture. With the gesture coordinates and the layout, it was easy to know which cell is being pressed or slid on.

And after a few tries, everything was working fine! I could detect simple and long presses, and with a bit of state management, I was able to know on which day the user's finger was.

It looked like this :

++pre>++code>onPanResponderGrant: (evt, gestureState) => {
 // Called when the gesture starts

 // After a timeout (long press), save in state the day on which the user pressed
},
onPanResponderMove: (evt, gestureState) => {
 // Called at each movement

 // Find the day on which the user's finger currently is
 // Select all days between the first selected day (saved in state) and the current day
},
onPanResponderRelease: (evt, gestureState) => {
 // Called when the gesture succeeds

 // Do something with the selected day(s) (API call)
},
++/code>++/pre>

Happy with myself, I just replaced the days with a FlatList, because I obviously needed to display more than 2 weeks :

++pre>++code><View {...this.panResponder.panHandlers}>
<FlatList data={...} renderItem={...} />
</View>
++/code>++/pre>

That's when the troubles started ...

The user couldn't scroll on the calendar, because the PanResponder was catching the event. Scroll was only working if I removed ++code>onStartShouldSetPanResponder++/code> and ++code>onStartShouldSetPanResponderCapture++/code> but then I couldn't detect single/long press on days.

My idea was to put the PanResponder View inside the FlatList. The first complication I encountered was that I couldn't insert a wrapper between the FlatList's ScrollView and the the FlatList's elements, so I had to render a single item in the FlatList containing all the days, it was then equivalent to using a simple ScrollView instead of the FlatList:

++pre>++code><ScrollView>
<View {...this.panResponder.panHandlers}>
<ElementsList />
</View>
</ScrollView>++/code>++/pre>

Then I was able to catch press and gesture events, but I couldn't scroll on the page. The reason is that when the gesture event reaches the ScrollView, it sends a ++code>TerminationRequest++/code> to the PanResponder. By default, the PanResponder returns false to the request and forbid the other components from taking the gesture. In order for the scroll to work, I had to use the ++code>onResponderTerminationRequest++/code> listener:

++pre>++code>onPanResponderTerminationRequest: () => !this.state.isSelecting
++/code>++/pre>

If the calendar is in selecting state, then the PanResponder blocks the scroll. But the scroll is enabled when the user isn't selecting days.

And it worked! I have even been able, with the ++code>gestureState++/code> and the ++code>scrollOffset++/code>, to detect when the gesture reached the top or bottom of the ScrollView to automatically scroll up or down.

When the end of the journey was near ...

On some old phones, the Calendar took 2 seconds to load. The cause was that we didn't use the FlatList component but a simple ScrollView, so we didn't benefit from its virtualization feature. The result was that when we tried to load the Calendar with a whole year, it took some time to render all the days cells, hence the 2 seconds loading time.

We added an ActivityIndicator during the loading, and we stayed on that solution for quite a time, but I wasn't proud of it.

Optimization

I needed to replace the ScrollView by a FlatList to benefit from the virtualization. But I couldn't put the PanResponder between the FlatList's ScrollView and the FlatList's elements. So I was back to the beginning, with the FlatList inside the PanResponder, but this time I had to be smarter.

I knew that as long as ++code>onStartShouldSetPanResponder++/code> or ++code>onStartShouldSetPanResponderCapture++/code> returned ++code>true++/code>, the scroll was blocked. But I needed these lines for the PanResponder to react to presses, and not only to movements.

I had to find a workaround and found no solutions using only PanResponder.

I ended up using TouchableWithoutFeedback component and the ++code>onPress++/code> and ++code>onLongPress++/code> properties to call the handlers I was previously calling in ++code>onPanResponderGrant++/code>.

Here is the final code:

++pre>++code><View {...this.panResponder.panHandlers}>
<FlatList
data={...}
renderItem={() => (
<TouchableOpacity onPress={this.selectSingleDay} onLongPress={this.startMultiSelection}>
...
</TouchableOpacity>
)}
/>
</View>++/code>++/pre>++pre>++code>
this.panResponder = PanResponder.create({
onMoveShouldSetPanResponder: () => {
// return true if the user is currently multi selecting days (after a long press)
return this.state.isMultiSelecting;
},
onPanResponderMove: evt => {
const { locationX, locationY } = evt.nativeEvent;
this.handleMultiSelection(locationX, locationY);
},
onPanResponderEnd: evt => this.handlePanResponderEnd(evt.nativeEvent),
});++/code>++/pre>

 

You can also check out the calendar full source code on Github:
https://github.com/AntoineDoubovetzky/calendar/blob/master/src/components/Calendar.js

Hope you learnt some things and have now a better understanding of React Native's PanResponder.
Feel free to comment and give me feedbacks to help me improve this article !

Développeur mobile ?

Rejoins nos équipes