by

If you missed the first post in this series, make sure to go back and read Part 1 before you read this one.

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;

Reordering tags

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 within src/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 and GestureState 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 initializes PanResponder and a few functions that handle various PanResponder 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.

  1. When you tap anywhere within Tags component view, PanResponder calls onMoveShouldSetPanResponder and since it returns true it proceeds further and starts handling the gesture. If it returned false, the gesture wouldn’t be handled. We’re going to use that, later on, to filter out taps made outside of the actual tags.
  2. PanResponder calls onPanResponderGrant once after the gesture was started.
  3. PanResponder calls onPanResponderMove every N milliseconds and passes gestureState to it. Which we pull moveX and moveY 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 since props 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 using this.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 to onRenderTag prop of TagsArea.
  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 within src 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),
  ];
};

Step 4. Handling only gestures that drag tags

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 by this.removeTag() class function that we added earlier. And then we want to find the tag that matches given coordinates, assign it to this.tagBeingDragged and return true, so PanResponder starts handling the gesture. Or return false 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 set isBeingDragged prop of the tag being dragged to true using this.updateTagState(). When isBeingDragged prop is set to true the Tag 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 set isBeingDragged prop of the dropped tag back to false and clear this.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.

Step 5. Swapping tags while dragging

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 calls enableDnd 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 call this.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.

Adding new tags

And finally, let’s make Add new button work.

Step 1. Adding new tags to the state

First of all, let’s make Tags component handle adding new tags to the state.

  • Open Tags.js file within src/components folder and add new class function called onSubmitNewTag.
  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 within src/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 within src/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 reference Tags component so we could call onSubmitNewTag function of Tags component when onSubmit callback from NewTagModal 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 the render(), add ref prop to Tags component to assign the reference to the class variable and pass this.openModal function to onPressAddNewTag 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.

Spread the Word