[fa icon="calendar"] Publié le 30 October 2018 par Antoine DOUBOVETZKY


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

 

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 react-native-calendar nor in interactions packages like react-native-interactable. And that's when I came across the PanResponder.

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.

tumblr_mj9e0wwAhN1r6tfj7o1_500

 

 

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.

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

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

onStartShouldSetPanResponder: (evt, gestureState) => true,
onStartShouldSetPanResponderCapture: (evt, gestureState) => true,
onMoveShouldSetPanResponder: (evt, gestureState) => true,
onMoveShouldSetPanResponderCapture: (evt, gestureState) => true,

Next step was playing with all the listeners ...

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

All I had to do was to use the onPanResponderGrant/Move/Release listener and in particular the gestureState 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 :

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)
},

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

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

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 onStartShouldSetPanResponder and onStartShouldSetPanResponderCapture 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:

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

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 TerminationRequest 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 onResponderTerminationRequest listener:

onPanResponderTerminationRequest: () => !this.state.isSelecting

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 gestureState and the scrollOffset, 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 onStartShouldSetPanResponder or onStartShouldSetPanResponderCapture returned true, 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 onPress and onLongPress properties to call the handlers I was previously calling in onPanResponderGrant.

Here is the final code:

<View {...this.panResponder.panHandlers}>
	<FlatList
		data={...}
		renderItem={() => (
			<TouchableOpacity onPress={this.selectSingleDay} onLongPress={this.startMultiSelection}>
				...
			</TouchableOpacity>
		)}
	/>
</View>

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),
});

 

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 !


Liked this article? Interested in building an app with us?

Contact a React Native expert in Paris




Want to rate this article?
Submit Rating

Topics: React Native, PanResponder, Calendar, gestures