If you missed the first post in this series, make sure to go back and read Part 1 before you read this one.
Table of contents
What we will be building
During this series we’re building the app that has a list of tags and allows adding, removing and reordering tags by dragging and dropping them.
In the previous part, we set up the presentational components for rendering a list of tags and added the ability to remove tags from the list by tapping on them.
In this part, we’ll implement drag and drop for reordering the tags, and add the ability to add new tags by using a text input within a modal.
For your reference, the final code for the app we’re building can be found in this GitHub repo.
Previous part code
In this part We’ll continue building upon the code, we wrote in the previous part. If you haven’t been following up and you don’t have the code you can check it out from GitHub. Open your Terminal app and execute these commands:
git clone --branch part-1 https://github.com/rationalappdev/react-native-drag-and-drop-tags-tutorial.git;
cd react-native-drag-and-drop-tags-tutorial;
npm install;
Now, let’s make those tags draggable and droppable so we could reorder them by tapping on a tag and dragging it elsewhere.
- Open
Tags.js
file withinsrc/components
folder to make a couple of changes.
Step 1. Setting up PanResponder
We’re going to use React Native’s PanResponder
component that allows us to handle touch gestures. First, we’ll do a basic setup that outputs some information to the console when handling gestures so we can see how it all works together before diving deeper.
There are a few steps we need to do to set it up.
- Import
PanResponder
andGestureState
type.
import React, { PureComponent } from 'react';
import {
LayoutAnimation,
PanResponder,
StyleSheet,
View
} from 'react-native';
import TagsArea from './TagsArea';
import type { TagObject, GestureState } from '../types';
- Add
createPanResponder
that initializesPanResponder
and a few functions that handle variousPanResponder
events.
componentWillUpdate() {...}
// Create PanResponder
createPanResponder = (): PanResponder => PanResponder.create({
// Handle drag gesture
onMoveShouldSetPanResponder: (_, gestureState: GestureState) => this.onMoveShouldSetPanResponder(gestureState),
onPanResponderGrant: (_, gestureState: GestureState) => this.onPanResponderGrant(),
onPanResponderMove: (_, gestureState: GestureState) => this.onPanResponderMove(gestureState),
// Handle drop gesture
onPanResponderRelease: (_, gestureState: GestureState) => this.onPanResponderEnd(),
onPanResponderTerminate: (_, gestureState: GestureState) => this.onPanResponderEnd(),
});
// Find out if we need to start handling tag dragging gesture
onMoveShouldSetPanResponder = (gestureState: GestureState): boolean => {
console.log('setting PanResponder');
return true;
};
// Called when gesture is granted
onPanResponderGrant = (): void => {
console.log('granted');
};
// Handle drag gesture
onPanResponderMove = (gestureState: GestureState): void => {
const { moveX, moveY } = gestureState;
console.log('onPanResponderMove', moveX, moveY);
};
// Called after gesture ends
onPanResponderEnd = (): void => {
console.log('ended');
};
// Remove tag
removeTag = (tag: TagObject): void => {...}
- Initialize
PanResponder
when the component is about to get mounted.
state: State = {...};
// PanResponder to handle drag and drop gesture
panResponder: PanResponder;
// Initialize PanResponder
componentWillMount() {
this.panResponder = this.createPanResponder();
}
// Animate layout changes when dragging or removing a tag
componentWillUpdate() {...}
- Pass
PanResponder
handlers to the container view.
render() {
const { tags } = this.state;
return (
<View
style={styles.container}
{...this.panResponder.panHandlers}
>
<TagsArea
tags={tags}
onPress={this.removeTag}
onRenderTag={() => {}} // do nothing for now
onPressAddNew={this.props.onPressAddNewTag}
/>
</View>
);
}
Now, let’s enable remote debugging and try tapping and holding anywhere inside the white border, and then dragging while holding. PanResponder
is going to handle gestures started only within borders of Tags
component view because it’s the component we applied it to since we only want tags to be draggable and not anything on the screen. So if you try dragging and dropping the header on the top, it’s not going to work.
To enable remote debugging either press ⌘D if you’re running the app on iOS simulator or press ⌘M if running on Android simulator or shake your phone if running on a physical device to bring up the developer menu, and select Debug Remote JS.
After you’ve done some dragging, you should see something similar to the screenshot above in your Chrome’s console. Let’s see what exactly is happening and in what order.
- When you tap anywhere within
Tags
component view,PanResponder
callsonMoveShouldSetPanResponder
and since it returnstrue
it proceeds further and starts handling the gesture. If it returnedfalse
, the gesture wouldn’t be handled. We’re going to use that, later on, to filter out taps made outside of the actual tags. PanResponder
callsonPanResponderGrant
once after the gesture was started.PanResponder
callsonPanResponderMove
every N milliseconds and passesgestureState
to it. Which we pullmoveX
andmoveY
coordinates out of, and output those into the console.
moveX
and moveY
are the coordinates of your mouse pointer on the screen or of your finger if you did the gesture on your phone. moveX
is a coordinate on horizontal x
axis and moveY
is a coordinate on vertical y
axis. The top left corner of the screen has zero x
and y
coordinates. So the bottom right corner has maximum possible coordinate values.
Step 2. Storing tag coordinates in the state
Next, we need to store each tag coordinates in the state so we could use those to find tags using coordinates when handling touch gestures.
- Add
updateTagState
function which updates a tag in the state with given props. You can either update existing tag props, add new ones, or both sinceprops
object is being merged with the original tag object from the state using spread operator...props
.
removeTag = (tag: TagObject): void => {...}
// Update the tag in the state with given props
updateTagState = (tag: TagObject, props: Object): void => {
this.setState((state: State) => {
const index = state.tags.findIndex(({ title }) => title === tag.title);
return {
tags: [
...state.tags.slice(0, index),
{
...state.tags[index],
...props,
},
...state.tags.slice(index + 1),
],
}
});
};
render() {...}
- Add
onRenderTag
function which receives tag coordinates and updates a tag with the new coordinates in the state usingthis.updateTagState()
we just added before.
updateTagState = (tag: TagObject, props: Object): void => {...};
// Update tag coordinates in the state
onRenderTag = (tag: TagObject,
screenX: number,
screenY: number,
width: number,
height: number): void => {
this.updateTagState(tag, {
tlX: screenX,
tlY: screenY,
brX: screenX + width,
brY: screenY + height,
});
};
render() {...}
- Pass
this.onRenderTag
toonRenderTag
prop ofTagsArea
.
render() {
const { tags } = this.state;
return (
<View
style={styles.container}
{...this.panResponder.panHandlers}
>
<TagsArea
tags={tags}
onPress={this.removeTag}
onRenderTag={this.onRenderTag}
onPressAddNew={this.props.onPressAddNewTag}
/>
</View>
);
}
Now, let’s output this.state
into the console to make sure the coordinates are being stored.
It looks like everything works as expected. And now we have the coordinates of the top left and the bottom right corners stored in the state for each tag.
Step 3. Adding helper functions
Let’s create a file with two helper functions. First one, isPointWithinArea
will help us finding tags by their coordinates and another one, moveArrayElement
will help with rearranging tag objects within an array stored in the state.
- Create a new file called
helpers.js
withinsrc
folder.
// @flow
// Calculates whether a given point is within a given area
export const isPointWithinArea = (pointX: number, // x coordinate
pointY: number, // y coordinate
areaTlX: number, // top left x coordinate
areaTlY: number, // top left y coordinate
areaBrX: number, // bottom right x coordinate
areaBrY: number // bottom right y coordinate
): boolean => {
return areaTlX <= pointX && pointX <= areaBrX // is within horizontal axis
&& areaTlY <= pointY && pointY <= areaBrY; // is within vertical axis
};
// Moves an object within a given array from one position to another
export const moveArrayElement = (array: {}[], // array of objects
from: number, // element to move index
to: number, // index where to move
mergeProps?: {} = {} // merge additional props into the object
): {}[] => {
if (to > array.length)
return array;
// Remove the element we need to move
const arr = [
...array.slice(0, from),
...array.slice(from + 1),
];
// And add it back at a new position
return [
...arr.slice(0, to),
{
...array[from],
...mergeProps, // merge passed props if any or nothing (empty object) by default
},
...arr.slice(to),
];
};
Next, we want PanResponder
to handle only those gestures that drag tags. So if a user taps outside of any tags on an empty area of Tags
container view, we don’t want PanResponder
to handle those gestures.
To achieve that we’re going to change the code so that onMoveShouldSetPanResponder
handler finds the tag at the point where the gesture starts and proceeds only if it finds the tag.
- Import
isPointWithinArea
helper function.
import React, { PureComponent } from 'react';
import {
LayoutAnimation,
PanResponder,
StyleSheet,
View
} from 'react-native';
import { isPointWithinArea } from '../helpers';
import TagsArea from './TagsArea';
import type { TagObject, GestureState } from '../types';
- Add
tagBeingDragged
class variable for storing the tag that is being dragged.
panResponder: PanResponder;
// Tag that is currently being dragged
tagBeingDragged: ?TagObject;
// Initialize PanResponder
componentWillMount() {...}
- Add
findTagAtCoordinates
class function which searches through all tags in the state to find the one that matches given coordinates.
onPanResponderEnd = (): void => {...};
// Find the tag at given coordinates
findTagAtCoordinates = (x: number, y: number, exceptTag?: TagObject): ?TagObject => {
return this.state.tags.find((tag) =>
tag.tlX && tag.tlY && tag.brX && tag.brY
&& isPointWithinArea(x, y, tag.tlX, tag.tlY, tag.brX, tag.brY)
&& (!exceptTag || exceptTag.title !== tag.title)
);
};
// Remove tag
removeTag = (tag: TagObject): void => {...}
- Update
onMoveShouldSetPanResponder
class function to do a couple of things. First of all, we want to ignore multi touch-gestures and regular taps with no dragging which should be handled bythis.removeTag()
class function that we added earlier. And then we want to find the tag that matches given coordinates, assign it tothis.tagBeingDragged
and returntrue
, soPanResponder
starts handling the gesture. Or returnfalse
to ignore the gesture if no tag was found.
// Find out if we need to start handling tag dragging gesture
onMoveShouldSetPanResponder = (gestureState: GestureState): boolean => {
const { dx, dy, moveX, moveY, numberActiveTouches } = gestureState;
// Do not set pan responder if a multi touch gesture is occurring
if (numberActiveTouches !== 1) {
return false;
}
// or if there was no movement since the gesture started
if (dx === 0 && dy === 0) {
return false;
}
// Find the tag below user's finger at given coordinates
const tag = this.findTagAtCoordinates(moveX, moveY);
if (tag) {
// assign it to `this.tagBeingDragged` while dragging
this.tagBeingDragged = tag;
// and tell PanResponder to start handling the gesture by calling `onPanResponderMove`
return true;
}
return false;
};
- Update
onPanResponderGrant
class function to setisBeingDragged
prop of the tag being dragged totrue
usingthis.updateTagState()
. WhenisBeingDragged
prop is set totrue
theTag
component uses a different background and border style to make it visually stand out, so there’s feedback for users when they start dragging a tag.
// Called when gesture is granted
onPanResponderGrant = (): void => {
this.updateTagState(this.tagBeingDragged, { isBeingDragged: true });
};
- Update
onPanResponderEnd
class function to setisBeingDragged
prop of the dropped tag back tofalse
and clearthis.tagBeingDragged
after the gesture ends.
// Called after gesture ends
onPanResponderEnd = (): void => {
this.updateTagState(this.tagBeingDragged, { isBeingDragged: false });
this.tagBeingDragged = undefined;
};
Now, let’s take a look at how it works. Try tapping and holding a tag, and then dragging. You can see a bunch of onPanResponderMove
in the console. That means the gesture is being handled by PanResponder
. Now, try doing the same, but somewhere in the empty area outside the tags. You can see that nothing is happening. That’s exactly the behavior we wanted.
This is the final step. We’re going to make the tag that is being dragged swap with the tag it’s being dragged over. And that will be nicely animated thank to LayoutAnimation
we set up before.
- Import
moveArrayElement
helper function.
import React, { PureComponent } from 'react';
import {
LayoutAnimation,
PanResponder,
StyleSheet,
View
} from 'react-native';
import { isPointWithinArea, moveArrayElement } from '../helpers';
import TagsArea from './TagsArea';
import type { TagObject, GestureState } from '../types';
- Add
dndEnabled
variable to the state. We’ll be using it to temporarily disable drag and drop when two tags are being swapped. Because if we didn’t do that then during the animation, it’d be possible for another tag that’s not involved in the swapping but is rather just crossing the point of gesture, to start swapping with the tag that is being dragged.
type State = {
tags: TagObject[],
// Used to temporarily disable tag swapping while moving tag to the new position
// to avoid unwanted tag swaps while the animation is happening
dndEnabled: boolean,
};
export default class Tags extends PureComponent {
props: Props;
static defaultProps = {
animationDuration: 250
};
state: State = {
// Convert passed array of tag titles to array of objects of TagObject type,
// so ['tag', 'another'] becomes [{ title: 'tag' }, { title: 'another' }]
tags: [...new Set(this.props.tags)] // remove duplicates
.map((title: string) => ({ title })), // convert to objects
dndEnabled: true, // drag and drop enabled
};
- Add
enableDndAfterAnimating
class function that callsenableDnd
function after the animation is over to enable drag and drop back.
onPanResponderEnd = (): void => {...};
// Enable dnd back after the animation is over
enableDndAfterAnimating = (): void => {
setTimeout(this.enableDnd, this.props.animationDuration)
};
enableDnd = (): void => {
this.setState({ dndEnabled: true });
};
// Find the tag at given coordinates
findTagAtCoordinates = (x: number, y: number, exceptTag?: TagObject): ?TagObject => {...};
- Add
swapTags
class function that swaps two tags.
removeTag = (tag: TagObject): void => {...};
// Swap two tags
swapTags = (draggedTag: TagObject, anotherTag: TagObject): void => {
this.setState((state: State) => {
const draggedTagIndex = state.tags.findIndex(({ title }) => title === draggedTag.title);
const anotherTagIndex = state.tags.findIndex(({ title }) => title === anotherTag.title);
return {
tags: moveArrayElement(
state.tags,
draggedTagIndex,
anotherTagIndex,
),
dndEnabled: false,
}
}, this.enableDndAfterAnimating);
};
// Update the tag in the state with given props
updateTagState = (tag: TagObject, props: Object): void => {...};
- Update
onPanResponderMove
class function to find a tag that we’re dragging another tag over and callthis.swapTags()
to swap them.
onPanResponderGrant = (): void => {...};
// Handle drag gesture
onPanResponderMove = (gestureState: GestureState): void => {
const { moveX, moveY } = gestureState;
// Do nothing if dnd is disabled
if (!this.state.dndEnabled) {
return;
}
// Find the tag we're dragging the current tag over
const draggedOverTag = this.findTagAtCoordinates(moveX, moveY, this.tagBeingDragged);
if (draggedOverTag) {
this.swapTags(this.tagBeingDragged, draggedOverTag);
}
};
// Called after gesture ends
onPanResponderEnd = (): void => {...};
And that’s it in terms of reordering tags. Let’s try dragging and dropping some tags to see how it works. Let’s also verify that tapping on tags to remove them still works.
And finally, let’s make Add new button work.
First of all, let’s make Tags
component handle adding new tags to the state.
- Open
Tags.js
file withinsrc/components
folder and add new class function calledonSubmitNewTag
.
onRenderTag = (...): void => {...};
// Add new tag to the state
onSubmitNewTag = (title: string): void => {
// Remove tag if it already exists to re-add it to the bottom of the list
const existingTag = this.state.tags.find((tag: TagObject) => tag.title === title);
if (existingTag) {
this.removeTag(existingTag);
}
// Add new tag to the state
this.setState((state: State) => {
return {
tags: [
...state.tags,
{ title },
],
}
});
};
render() {...}
Step 2. Creating text input component
Let’s create a component that shows a text input field with a submit button that allows users to enter the text for new tags.
- Create a new file called
InputField.js
withinsrc/components
folder.
// @flow
import React, { PureComponent } from 'react';
import {
KeyboardAvoidingView,
TextInput,
Text,
TouchableOpacity,
View,
} from 'react-native';
type Props = {
onSubmit: (text: string) => void,
};
type State = {
text: ?string,
};
export default class InputField extends PureComponent {
props: Props;
state: State = {
text: undefined, // user's input
};
getButtonTextStyles = (): {} => ({
...styles.text,
...(!this.state.text ? styles.inactive : {}),
});
// Call this.props.onSubmit handler and pass the input
submit = (): void => {
const { text } = this.state;
if (text) {
this.setState({ text: undefined }, () => this.props.onSubmit(text));
} else {
alert('Please enter new tag first');
}
};
// Update state when input changes
onChangeText = (text: string): void => this.setState({ text });
// Handle return press on the keyboard
onSubmitEditing = (event: { nativeEvent: { text: ?string } }): void => {
const { nativeEvent: { text } } = event;
this.setState({ text }, this.submit);
};
render() {
return (
// This component moves children view with the text input above the keyboard
// when the text input gets the focus and the keyboard appears
<KeyboardAvoidingView behavior='position'>
<View style={styles.container}>
<TextInput
autoFocus={true} // focus and show the keyboard
keyboardType="twitter" // keyboard with no return button
placeholder="Add a tag..." // visible before they started typing
style={styles.input}
onChangeText={this.onChangeText} // handle input changes
onSubmitEditing={this.onSubmitEditing} // handle submit event
/>
<TouchableOpacity
style={styles.button}
onPress={this.submit}
>
<Text style={this.getButtonTextStyles()}>Add</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
}
const styles = {
container: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFF',
borderColor: '#EEE',
borderTopWidth: 1,
paddingLeft: 15,
},
input: {
flex: 1,
fontSize: 15,
height: 40,
},
button: {
alignItems: 'center',
justifyContent: 'center',
height: 40,
paddingHorizontal: 20,
},
inactive: {
color: '#CCC',
},
text: {
color: '#3F51B5',
fontFamily: 'Avenir',
fontSize: 15,
fontWeight: 'bold',
textAlign: 'center',
},
};
Step 3. Creating new tag modal component
Next, let’s put that InputField
component from the previous step inside a modal with slightly dimmed background and position the input field at the bottom. Let’s also wrap everything in TouchableWithoutFeedback
that closes the modal on touches, so users could tap anywhere outside of the input field or keyboard to exit out of the modal.
- Create a new file called
NewTagModal.js
withinsrc/components
folder.
// @flow
import React, { PureComponent } from 'react';
import {
Modal,
StyleSheet,
TouchableWithoutFeedback,
View
} from 'react-native';
import InputField from './InputField';
type Props = {
visible: boolean,
onClose: () => void,
onSubmit: (tag: string) => void,
};
export default class NewTagModal extends PureComponent {
props: Props;
onSubmit = (tag: string): void => {
this.props.onClose();
this.props.onSubmit(tag);
};
render() {
const { visible, onClose } = this.props;
return (
<Modal
animationType="slide"
transparent={true}
visible={visible}
onRequestClose={onClose}
>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.container}>
<InputField onSubmit={this.onSubmit} />
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1, // take up the whole screen
justifyContent: 'flex-end', // position input at the bottom
backgroundColor: 'rgba(0,0,0,0.33)', // semi transparent background
},
});
Step 4. Modifying App.js to enable the modal
And finally, let’s update App.js
to enable the modal for adding new tags that we just created.
- Open
App.js
file within the project root folder to make a few changes. - Import
NewTagModal
component that we created earlier.
import React, { PureComponent } from 'react';
import {
StatusBar,
StyleSheet,
Text,
View,
} from 'react-native';
import Tags from './src/components/Tags';
import NewTagModal from './src/components/NewTagModal';
- Add
modalVisible
state variable to make the modal either visible or not.
const TAGS = [...];
type State = {
modalVisible: boolean,
};
export default class Main extends PureComponent {
state: State = {
modalVisible: false,
};
render() {...}
- Add
_tagsComponent
class variable to referenceTags
component so we could callonSubmitNewTag
function ofTags
component whenonSubmit
callback fromNewTagModal
component is called.
state: State = {...};
// Reference Tags component
_tagsComponent: ?Tags;
render() {...}
- Add a few class function to open or close modal, and
onSubmitNewTag
callback that adds new tags.
_tagsComponent: ?Tags;
openModal = (): void => {
this.setState({ modalVisible: true });
};
closeModal = (): void => {
this.setState({ modalVisible: false });
};
onSubmitNewTag = (tag: string) => {
this._tagsComponent && this._tagsComponent.onSubmitNewTag(tag);
};
render() {...}
- Add
NewTagModal
component to therender()
, addref
prop toTags
component to assign the reference to the class variable and passthis.openModal
function toonPressAddNewTag
prop.
render() {
const { modalVisible } = this.state;
return (
<View style={styles.container}>
<StatusBar hidden={true} />
<NewTagModal
visible={modalVisible}
onSubmit={this.onSubmitNewTag}
onClose={this.closeModal}
/>
<View style={styles.header}>
<Text style={[styles.text, styles.title]}>
Let's drag and drop some tags!
</Text>
<Text style={styles.text}>
Drag and drop tags to reorder, tap to remove or press Add New to add new tags.
</Text>
</View>
<Tags
ref={component => this._tagsComponent = component }
tags={TAGS}
onPressAddNewTag={this.openModal}
/>
</View>
);
}
Ok, let’s see if that works. It seems like it does. And now we can add new tags, remove, and reorder them.
Wrapping up
You did a great job and learned a lot! I’m proud of you! Leave a comment if you have any questions, ideas for new tutorials or just want to share an amazing app that you’ve built. I can’t wait to see those!
And subscribe below to not miss new tutorials.