Draggable chat-heads in React Native
How we’ve used panResponder to create draggable chat-heads which could be paired by overlapping one over another.
Most of us are familiar with Facebook’s floating heads that are screaming for your attention on top of all other apps. At the time, it was a novel concept, somewhat annoying, but still, something new.
Recently, we’ve had a client that requested similar behavior, just in-app, which would show draggable profile photos which could be paired by overlapping one over another.
Since you’re probably skimming this part to see if a solution you’re looking forward is here, let get straight to the point.
We’ve used panResponder and wrapped each person in one.
constructor(props: Props) {
super(props);
this.pan.addListener((value) => this.position = value);
this.panResponder = PanResponder.create({
onMoveShouldSetPanResponder: (evt, gestureState) => true,
onPanResponderGrant: () => {
this.pan.setOffset({
x: this.position.x,
y: this.position.y
});
this.pan.setValue({ x: 0, y: 0 });
},
// Move object while onPress is active. Snapping is handled later.
onPanResponderMove: Animated.event([
null, { dx: this.pan.x, dy: this.pan.y }
]),
// Handle swiper behaviour after object is released.
onPanResponderRelease: (e, { dx, dy, vx }) => {
// Fix jumping when moving the object second time.
this.pan.flattenOffset();
// Send ending position to parent component.
this.props.calculateOverlapping(dx, dy, this.props.person.id);
// Animate springy tuff.
Animated.spring(this.pan, { toValue: { x: 0, y: 0 } }).start();
}
});
}
See the whole panResponder code on https://gist.github.com/sebastijandumancic/4ff2184aac4dd88770f6bb66f5be998f
Register initial people position
Each person is wrapped in an Animated.View component which means it’s draggable. Animated.View, just as normal View, has an onLayout event which is invoked on mount and layout changes.
Once that event is triggered, we can register this person initial position. They are positioned absolutely, but when reporting position it will use XY coordinates based on the parent they are on (0,0 will be top left corner of the parent element).
const newPersonPosition = new PersonPosition(Math.trunc(event.nativeEvent.layout.x + 30), Math.trunc(event.nativeEvent.layout.y + 30), userId);
The position is truncated since we don’t need extreme precision that horizontal and vertical displacements report (dx and dy in onPanResponderRelease).
PersonPosition here is just a constructor that creates an object with its horizontal and vertical position, together with userId which we can use later on to trigger events on that specific user.
Also, I’ve added 30, a magic number, which is half of the width and height of a component. Reported location (event.nativeEvent.layout.x) is a position in the top left corner of the component. If you want to be scientific about this, the proper way would be to check for a component's width and height and add half of it, but I know mine is 60, so I just added half manually. Now we save this since it’s a center of a component, and we need that for overlap calculation.
Position for each person is then pushed into an array which is saved to state:
peoplePosition.push(newPersonPosition);
this.setState({
peoplePosition
});
This is to have an easier way of comparing future dropped components to all of the others (using array’s find method).
Checking for overlapping
Main part is to check for overlapping after the user releases the person. We can get the drop coordinates like this:
onst droppedX = Math.trunc(draggedPerson.startingPointX + dx);
const droppedY = Math.trunc(draggedPerson.startingPointY + dy);
Where we take dragged person’s horizontal starting point and add the horizontal displacement and repeat for the vertical axis. The result is once again truncated to remove unneeded decimals.
Then, that ending position of the person is checked against the positions of all people that were not dragged:
const matchedPerson = notDraggedPeople.find((personPosition: PersonPosition) => Math.abs(personPosition.startingPointX - droppedX) < 30 && Math.abs(personPosition.startingPointY - droppedY) < 30);
If the dropped person is anywhere inside a set distance from any of the people, we have a match! Here the radius is hardcoded to 30px, but you can set it to whatever you want.
Maybe the best is half the width of an element + some buffer to make it easier to overlap successfully. You definitely want to avoid making it larger than the total width of the elements you’re overlapping to avoid false positives.
The distance of 0 means that the two components are perfectly overlapped (their centers match). Distance of 30 (in our case) means that they are touched by the edges. Tweak this number to determine how precise you have to be in order to get a sucesfull match.
If a match is successful, just push the person to the matchedPeople array and save it to the state:
let matchedPeople = [];
if (matchedPerson) {
matchedPeople.push(matchedPerson);
this.setState({
matchedPeople
});
}
Trigger action after overlapping
Finally, you probably want to do something after the user overlaps two heads successfully.
In our case, we just listened to state change for matchedPeople in ComponentWillUpdate:
componentWillUpdate(nextProps: Props, nextState: State) {
if (nextState.matchedPeople.length) {
// Navigate away from the screen
}
}
You should check for changes here to avoid excessive triggering for each component updates, but since we navigated away from this screen once a successful overlaps occur (matchedPeople array is populated), it’s a simple logic to check for.
Provided you’re experienced with panResponder, this code should be easy to replicate. In case you need a refresher on panResponder, I’ve written another article which tackles rotateable circle to select items here:
Did I mess up somewhere? Have better ideas? Drop us an email at hello@prototyp.digital or visit us at https://prototyp.digital. Cheers!