Today I'll tell you the story of how I developed a calendar handling pan gestures:
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.
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>
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.
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.
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 !