├── .watchmanconfig ├── .gitignore ├── app.json ├── demo.gif ├── demo.png ├── .babelrc ├── App.test.js ├── README.md ├── package.json ├── src ├── types.js ├── components │ ├── NewTagModal.js │ ├── TagsArea.js │ ├── Tag.js │ ├── InputField.js │ └── Tags.js └── helpers.js ├── .flowconfig └── App.js /.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules/ 3 | .expo/ 4 | npm-debug.* 5 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "sdkVersion": "21.0.0" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rationalappdev/react-native-drag-and-drop-tags-tutorial/HEAD/demo.gif -------------------------------------------------------------------------------- /demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rationalappdev/react-native-drag-and-drop-tags-tutorial/HEAD/demo.png -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["babel-preset-expo"], 3 | "env": { 4 | "development": { 5 | "plugins": ["transform-react-jsx-source"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import App from './App'; 3 | 4 | import renderer from 'react-test-renderer'; 5 | 6 | it('renders without crashing', () => { 7 | const rendered = renderer.create().toJSON(); 8 | expect(rendered).toBeTruthy(); 9 | }); 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Drag and Drop Tags in React Native 2 | 3 | Learn how to reorder tags, using animated drag and drop, remove them and add new tags using a modal with a text input. 4 | 5 | ## Blog Tutorial Series 6 | 7 | - [Part 1 of 2: The Basics and Removing the Tags](http://rationalappdev.com/drag-and-drop-tags-in-react-native-part-1-of-2) 8 | - [Part 2 of 2: Reordering and Adding New Tags](http://rationalappdev.com/drag-and-drop-tags-in-react-native-part-2-of-2) 9 | 10 | ## Demo 11 | 12 | Demo -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-drag-and-drop-tags-tutorial", 3 | "version": "0.1.0", 4 | "private": true, 5 | "devDependencies": { 6 | "flow-bin": "^0.49.1", 7 | "jest-expo": "^21.0.2", 8 | "react-native-scripts": "1.5.0", 9 | "react-test-renderer": "16.0.0-alpha.12" 10 | }, 11 | "main": "./node_modules/react-native-scripts/build/bin/crna-entry.js", 12 | "scripts": { 13 | "start": "react-native-scripts start", 14 | "eject": "react-native-scripts eject", 15 | "android": "react-native-scripts android", 16 | "ios": "react-native-scripts ios", 17 | "test": "node node_modules/jest/bin/jest.js --watch", 18 | "flow": "flow" 19 | }, 20 | "jest": { 21 | "preset": "jest-expo" 22 | }, 23 | "dependencies": { 24 | "expo": "^21.0.0", 25 | "react": "16.0.0-alpha.12", 26 | "react-native": "^0.48.4", 27 | "react-native-vector-icons": "^4.4.2" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | // Tag object type 2 | export type TagObject = { 3 | title: string, // tag title 4 | tlX?: number, // top left x coordinate 5 | tlY?: number, // top left y coordinate 6 | brX?: number, // bottom right x coordinate 7 | brY?: number, // bottom right y coordinate 8 | isBeingDragged?: boolean, // whether the tag is currently being dragged or not 9 | }; 10 | 11 | // PanResponder's gesture state type 12 | export type GestureState = { 13 | dx: number, // accumulated distance of the gesture since the touch started 14 | dy: number, // accumulated distance of the gesture since the touch started 15 | moveX: number, // the latest screen coordinates of the recently-moved touch 16 | moveY: number, // the latest screen coordinates of the recently-moved touch 17 | numberActiveTouches: number, // Number of touches currently on screen 18 | stateID: number, // ID of the gestureState- persisted as long as there at least one touch on screen 19 | vx: number, // current velocity of the gesture 20 | vy: number, // current velocity of the gesture 21 | x0: number, // the screen coordinates of the responder grant 22 | y0: number, // the screen coordinates of the responder grant 23 | }; 24 | -------------------------------------------------------------------------------- /src/components/NewTagModal.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { PureComponent } from 'react'; 4 | import { 5 | Modal, 6 | StyleSheet, 7 | TouchableWithoutFeedback, 8 | View 9 | } from 'react-native'; 10 | import InputField from './InputField'; 11 | 12 | type Props = { 13 | visible: boolean, 14 | onClose: () => void, 15 | onSubmit: (tag: string) => void, 16 | }; 17 | 18 | export default class NewTagModal extends PureComponent { 19 | 20 | props: Props; 21 | 22 | onSubmit = (tag: string): void => { 23 | this.props.onClose(); 24 | this.props.onSubmit(tag); 25 | }; 26 | 27 | render() { 28 | const { visible, onClose } = this.props; 29 | return ( 30 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | ); 43 | } 44 | 45 | } 46 | 47 | const styles = StyleSheet.create({ 48 | container: { 49 | flex: 1, // take up the whole screen 50 | justifyContent: 'flex-end', // position input at the bottom 51 | backgroundColor: 'rgba(0,0,0,0.33)', // semi transparent background 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | // Calculates whether a given point is within a given area 4 | export const isPointWithinArea = (pointX: number, // x coordinate 5 | pointY: number, // y coordinate 6 | areaTlX: number, // top left x coordinate 7 | areaTlY: number, // top left y coordinate 8 | areaBrX: number, // bottom right x coordinate 9 | areaBrY: number // bottom right y coordinate 10 | ): boolean => { 11 | return areaTlX <= pointX && pointX <= areaBrX // is within horizontal axis 12 | && areaTlY <= pointY && pointY <= areaBrY; // is within vertical axis 13 | }; 14 | 15 | // Moves an object within a given array from one position to another 16 | export const moveArrayElement = (array: {}[], // array of objects 17 | from: number, // element to move index 18 | to: number, // index where to move 19 | mergeProps?: {} = {} // merge additional props into the object 20 | ): {}[] => { 21 | 22 | if (to > array.length) 23 | return array; 24 | 25 | // Remove the element we need to move 26 | const arr = [ 27 | ...array.slice(0, from), 28 | ...array.slice(from + 1), 29 | ]; 30 | 31 | // And add it back at a new position 32 | return [ 33 | ...arr.slice(0, to), 34 | { 35 | ...array[from], 36 | ...mergeProps, // merge passed props if any or nothing (empty object) by default 37 | }, 38 | ...arr.slice(to), 39 | ]; 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/TagsArea.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { PureComponent } from 'react'; 4 | import { 5 | Text, 6 | View 7 | } from 'react-native'; 8 | import Tag from './Tag'; 9 | import type { TagObject } from '../types'; 10 | 11 | type Props = { 12 | tags: TagObject[], 13 | // Called when user taps 'Add new' button 14 | onPressAddNew: () => void, 15 | // Passes these two callbacks down to Tag component 16 | onPress: (tag: TagObject) => void, 17 | onRenderTag: (tag: TagObject, screenX: number, screenY: number, width: number, height: number) => void, 18 | }; 19 | 20 | export default class TagsArea extends PureComponent { 21 | 22 | props: Props; 23 | 24 | render() { 25 | const { 26 | tags, 27 | onPress, 28 | onPressAddNew, 29 | onRenderTag, 30 | } = this.props; 31 | 32 | return ( 33 | 34 | 35 | {tags.map(tag => 36 | 42 | )} 43 | 44 | 48 | Add new 49 | 50 | 51 | 53 | ); 54 | } 55 | 56 | } 57 | 58 | const styles = { 59 | container: { 60 | flexDirection: 'row', 61 | flexWrap: 'wrap', 62 | borderColor: 'rgba(255,255,255,0.5)', 63 | borderRadius: 5, 64 | borderWidth: 2, 65 | paddingBottom: 10, 66 | paddingHorizontal: 15, 67 | paddingTop: 15, 68 | }, 69 | add: { 70 | backgroundColor: 'transparent', 71 | color: '#FFFFFF', 72 | paddingHorizontal: 5, 73 | paddingVertical: 5, 74 | textDecorationLine: 'underline', 75 | }, 76 | }; 77 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | ; We fork some components by platform 3 | .*/*[.]android.js 4 | 5 | ; Ignore "BUCK" generated dirs 6 | /\.buckd/ 7 | 8 | ; Ignore unexpected extra "@providesModule" 9 | .*/node_modules/.*/node_modules/fbjs/.* 10 | 11 | ; Ignore duplicate module providers 12 | ; For RN Apps installed via npm, "Libraries" folder is inside 13 | ; "node_modules/react-native" but in the source repo it is in the root 14 | .*/Libraries/react-native/React.js 15 | .*/Libraries/react-native/ReactNative.js 16 | 17 | ; Additional create-react-native-app ignores 18 | 19 | ; Ignore duplicate module providers 20 | .*/node_modules/fbemitter/lib/* 21 | 22 | ; Ignore misbehaving dev-dependencies 23 | .*/node_modules/xdl/build/* 24 | .*/node_modules/reqwest/tests/* 25 | 26 | ; Ignore missing expo-sdk dependencies (temporarily) 27 | ; https://github.com/expo/expo/issues/162 28 | .*/node_modules/expo/src/* 29 | 30 | ; Ignore react-native-fbads dependency of the expo sdk 31 | .*/node_modules/react-native-fbads/* 32 | 33 | [include] 34 | 35 | [libs] 36 | node_modules/react-native/Libraries/react-native/react-native-interface.js 37 | node_modules/react-native/flow 38 | flow/ 39 | 40 | [options] 41 | module.system=haste 42 | 43 | emoji=true 44 | 45 | experimental.strict_type_args=true 46 | 47 | munge_underscores=true 48 | 49 | module.name_mapper='^[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\)$' -> 'RelativeImageStub' 50 | 51 | suppress_type=$FlowIssue 52 | suppress_type=$FlowFixMe 53 | suppress_type=$FixMe 54 | 55 | suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe\\($\\|[^(]\\|(\\(>=0\\.\\(4[0-9]\\|[1-3][0-9]\\|[0-9]\\).[0-9]\\)? *\\(site=[a-z,_]*react_native[a-z,_]*\\)?)\\) 56 | suppress_comment=\\(.\\|\n\\)*\\$FlowIssue\\((\\(>=0\\.\\(4[0-9]\\|[1-3][0-9]\\|[0-9]\\).[0-9]\\)? *\\(site=[a-z,_]*react_native[a-z,_]*\\)?)\\)?:? #[0-9]+ 57 | suppress_comment=\\(.\\|\n\\)*\\$FlowFixedInNextDeploy 58 | suppress_comment=\\(.\\|\n\\)*\\$FlowExpectedError 59 | 60 | unsafe.enable_getters_and_setters=true 61 | 62 | [version] 63 | ^0.49.1 64 | -------------------------------------------------------------------------------- /App.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { PureComponent } from 'react'; 4 | import { 5 | StatusBar, 6 | StyleSheet, 7 | Text, 8 | View, 9 | } from 'react-native'; 10 | import Tags from './src/components/Tags'; 11 | import NewTagModal from './src/components/NewTagModal'; 12 | 13 | const TAGS = [ 14 | '#love', 15 | '#instagood', 16 | '#photooftheday', 17 | '#beautiful', 18 | '#fashion', 19 | '#happy', 20 | '#tbt', 21 | '#cute', 22 | '#followme', 23 | '#like4like', 24 | '#follow', 25 | '#followme', 26 | '#picoftheday', 27 | '#me', 28 | '#selfie', 29 | '#summer', 30 | '#instadaily', 31 | '#photooftheday', 32 | '#friends', 33 | '#girl', 34 | '#fun', 35 | '#style', 36 | '#instalike', 37 | '#food', 38 | '#family', 39 | '#tagsforlikes', 40 | '#igers', 41 | ]; 42 | 43 | type State = { 44 | modalVisible: boolean, 45 | }; 46 | 47 | export default class Main extends PureComponent { 48 | 49 | state: State = { 50 | modalVisible: false, 51 | }; 52 | 53 | // Reference Tags component 54 | _tagsComponent: ?Tags; 55 | 56 | openModal = (): void => { 57 | this.setState({ modalVisible: true }); 58 | }; 59 | 60 | closeModal = (): void => { 61 | this.setState({ modalVisible: false }); 62 | }; 63 | 64 | onSubmitNewTag = (tag: string) => { 65 | this._tagsComponent && this._tagsComponent.onSubmitNewTag(tag); 66 | }; 67 | 68 | render() { 69 | const { modalVisible } = this.state; 70 | return ( 71 | 72 | 96 | ); 97 | } 98 | 99 | } 100 | 101 | const styles = StyleSheet.create({ 102 | container: { 103 | flex: 1, 104 | backgroundColor: '#2196F3', 105 | }, 106 | header: { 107 | marginHorizontal: 20, 108 | marginVertical: 50, 109 | }, 110 | title: { 111 | fontSize: 22, 112 | fontWeight: 'bold', 113 | marginBottom: 10, 114 | }, 115 | text: { 116 | color: '#FFFFFF', 117 | fontSize: 16, 118 | textAlign: 'center', 119 | }, 120 | }); 121 | -------------------------------------------------------------------------------- /src/components/Tag.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { PureComponent } from 'react'; 4 | import { 5 | Text, 6 | TouchableOpacity, 7 | View, 8 | } from 'react-native'; 9 | import Icon from 'react-native-vector-icons/Ionicons'; 10 | import type { NativeMethodsMixinType } from 'react-native/Libraries/Renderer/shims/ReactNativeTypes'; 11 | import type { TagObject } from '../types'; 12 | 13 | type Props = { 14 | tag: TagObject, 15 | // Called when user taps on a tag 16 | onPress: (tag: TagObject) => void, 17 | // Called after a tag is rendered 18 | onRender: (tag: TagObject, screenX: number, screenY: number, width: number, height: number) => void, 19 | }; 20 | 21 | export default class Tag extends PureComponent { 22 | 23 | props: Props; 24 | 25 | container: ?NativeMethodsMixinType; 26 | 27 | // Append styles.tagBeingDragged style if tag is being dragged 28 | getTagStyle = (): {} => ({ 29 | ...styles.tag, 30 | ...(this.props.tag.isBeingDragged ? styles.tagBeingDragged : {}), 31 | }); 32 | 33 | // Call view container's measure function to measure tag position on the screen 34 | onLayout = (): void => { 35 | this.container && this.container.measure(this.onMeasure); 36 | }; 37 | 38 | // Pass tag coordinates up to the parent component 39 | onMeasure = (x: number, 40 | y: number, 41 | width: number, 42 | height: number, 43 | screenX: number, 44 | screenY: number): void => { 45 | this.props.onRender(this.props.tag, screenX, screenY, width, height); 46 | }; 47 | 48 | // Handle tag taps 49 | onPress = (): void => { 50 | this.props.onPress(this.props.tag); 51 | }; 52 | 53 | render() { 54 | const { tag: { title } } = this.props; 55 | return ( 56 | this.container = el} 58 | style={styles.container} 59 | onLayout={this.onLayout} 60 | > 61 | 65 | 66 | {' '} 67 | {title} 68 | 69 | 70 | ); 71 | } 72 | 73 | } 74 | 75 | const styles = { 76 | container: { 77 | marginBottom: 8, 78 | marginRight: 6, 79 | }, 80 | tag: { 81 | flexDirection: 'row', 82 | alignItems: 'center', 83 | backgroundColor: 'rgba(255, 255, 255, .33)', 84 | borderColor: 'rgba(255, 255, 255, .25)', 85 | borderRadius: 20, 86 | borderWidth: 1, 87 | paddingHorizontal: 10, 88 | paddingVertical: 3, 89 | }, 90 | tagBeingDragged: { 91 | backgroundColor: 'rgba(255, 255, 255, .01)', 92 | borderStyle: 'dashed', 93 | }, 94 | title: { 95 | color: '#FFFFFF', 96 | fontSize: 15, 97 | fontWeight: 'normal', 98 | }, 99 | }; 100 | -------------------------------------------------------------------------------- /src/components/InputField.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { PureComponent } from 'react'; 4 | import { 5 | KeyboardAvoidingView, 6 | TextInput, 7 | Text, 8 | TouchableOpacity, 9 | View, 10 | } from 'react-native'; 11 | 12 | type Props = { 13 | onSubmit: (text: string) => void, 14 | }; 15 | 16 | type State = { 17 | text: ?string, 18 | }; 19 | 20 | export default class InputField extends PureComponent { 21 | 22 | props: Props; 23 | 24 | state: State = { 25 | text: undefined, // user's input 26 | }; 27 | 28 | getButtonTextStyles = (): {} => ({ 29 | ...styles.text, 30 | ...(!this.state.text ? styles.inactive : {}), 31 | }); 32 | 33 | // Call this.props.onSubmit handler and pass the input 34 | submit = (): void => { 35 | const { text } = this.state; 36 | if (text) { 37 | this.setState({ text: undefined }, () => this.props.onSubmit(text)); 38 | } else { 39 | alert('Please enter new tag first'); 40 | } 41 | }; 42 | 43 | // Update state when input changes 44 | onChangeText = (text: string): void => this.setState({ text }); 45 | 46 | // Handle return press on the keyboard 47 | onSubmitEditing = (event: { nativeEvent: { text: ?string } }): void => { 48 | const { nativeEvent: { text } } = event; 49 | this.setState({ text }, this.submit); 50 | }; 51 | 52 | render() { 53 | return ( 54 | // This component moves children view with the text input above the keyboard 55 | // when the text input gets the focus and the keyboard appears 56 | 57 | 58 | 59 | 67 | 68 | 72 | Add 73 | 74 | 75 | 76 | 77 | ); 78 | } 79 | 80 | } 81 | 82 | const styles = { 83 | container: { 84 | flexDirection: 'row', 85 | alignItems: 'center', 86 | backgroundColor: '#FFF', 87 | borderColor: '#EEE', 88 | borderTopWidth: 1, 89 | paddingLeft: 15, 90 | }, 91 | input: { 92 | flex: 1, 93 | fontSize: 15, 94 | height: 40, 95 | }, 96 | button: { 97 | alignItems: 'center', 98 | justifyContent: 'center', 99 | height: 40, 100 | paddingHorizontal: 20, 101 | }, 102 | inactive: { 103 | color: '#CCC', 104 | }, 105 | text: { 106 | color: '#3F51B5', 107 | fontSize: 15, 108 | fontWeight: 'bold', 109 | textAlign: 'center', 110 | }, 111 | }; 112 | -------------------------------------------------------------------------------- /src/components/Tags.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React, { PureComponent } from 'react'; 4 | import { 5 | LayoutAnimation, 6 | PanResponder, 7 | StyleSheet, 8 | View 9 | } from 'react-native'; 10 | import { isPointWithinArea, moveArrayElement } from '../helpers'; 11 | import TagsArea from './TagsArea'; 12 | import type { TagObject, GestureState } from '../types'; 13 | 14 | type Props = { 15 | // Array of tag titles 16 | tags: string[], 17 | // Tag swapping animation duration in ms 18 | animationDuration: number, 19 | // Passes onPressAddNewTag callback down to TagsArea component 20 | onPressAddNewTag: () => void, 21 | }; 22 | 23 | type State = { 24 | tags: TagObject[], 25 | // Used to temporarily disable tag swapping while moving tag to the new position 26 | // to avoid unwanted tag swaps while the animation is happening 27 | dndEnabled: boolean, 28 | }; 29 | 30 | export default class Tags extends PureComponent { 31 | 32 | props: Props; 33 | 34 | static defaultProps = { 35 | animationDuration: 250 36 | }; 37 | 38 | state: State = { 39 | // Convert passed array of tag titles to array of objects of TagObject type, 40 | // so ['tag', 'another'] becomes [{ title: 'tag' }, { title: 'another' }] 41 | tags: [...new Set(this.props.tags)] // remove duplicates 42 | .map((title: string) => ({ title })), // convert to objects 43 | dndEnabled: true, // drag and drop enabled 44 | }; 45 | 46 | // PanResponder to handle drag and drop gesture 47 | panResponder: PanResponder; 48 | 49 | // Tag that is currently being dragged 50 | tagBeingDragged: ?TagObject; 51 | 52 | // Initialize PanResponder 53 | componentWillMount() { 54 | this.panResponder = this.createPanResponder(); 55 | } 56 | 57 | // Animate layout changes when dragging or removing a tag 58 | componentWillUpdate() { 59 | LayoutAnimation.configureNext({ 60 | ...LayoutAnimation.Presets.easeInEaseOut, 61 | duration: this.props.animationDuration 62 | }); 63 | } 64 | 65 | // Create PanResponder 66 | createPanResponder = (): PanResponder => PanResponder.create({ 67 | // Handle drag gesture 68 | onMoveShouldSetPanResponder: (_, gestureState: GestureState) => this.onMoveShouldSetPanResponder(gestureState), 69 | onPanResponderGrant: (_, gestureState: GestureState) => this.onPanResponderGrant(), 70 | onPanResponderMove: (_, gestureState: GestureState) => this.onPanResponderMove(gestureState), 71 | // Handle drop gesture 72 | onPanResponderRelease: (_, gestureState: GestureState) => this.onPanResponderEnd(), 73 | onPanResponderTerminate: (_, gestureState: GestureState) => this.onPanResponderEnd(), 74 | }); 75 | 76 | // Find out if we need to start handling tag dragging gesture 77 | onMoveShouldSetPanResponder = (gestureState: GestureState): boolean => { 78 | const { dx, dy, moveX, moveY, numberActiveTouches } = gestureState; 79 | 80 | // Do not set pan responder if a multi touch gesture is occurring 81 | if (numberActiveTouches !== 1) { 82 | return false; 83 | } 84 | 85 | // or if there was no movement since the gesture started 86 | if (dx === 0 && dy === 0) { 87 | return false; 88 | } 89 | 90 | // Find the tag below user's finger at given coordinates 91 | const tag = this.findTagAtCoordinates(moveX, moveY); 92 | if (tag) { 93 | // assign it to `this.tagBeingDragged` while dragging 94 | this.tagBeingDragged = tag; 95 | // and tell PanResponder to start handling the gesture by calling `onPanResponderMove` 96 | return true; 97 | } 98 | 99 | return false; 100 | }; 101 | 102 | // Called when gesture is granted 103 | onPanResponderGrant = (): void => { 104 | this.updateTagState(this.tagBeingDragged, { isBeingDragged: true }); 105 | }; 106 | 107 | // Handle drag gesture 108 | onPanResponderMove = (gestureState: GestureState): void => { 109 | const { moveX, moveY } = gestureState; 110 | // Do nothing if dnd is disabled 111 | if (!this.state.dndEnabled) { 112 | return; 113 | } 114 | // Find the tag we're dragging the current tag over 115 | const draggedOverTag = this.findTagAtCoordinates(moveX, moveY, this.tagBeingDragged); 116 | if (draggedOverTag) { 117 | this.swapTags(this.tagBeingDragged, draggedOverTag); 118 | } 119 | }; 120 | 121 | // Called after gesture ends 122 | onPanResponderEnd = (): void => { 123 | this.updateTagState(this.tagBeingDragged, { isBeingDragged: false }); 124 | this.tagBeingDragged = undefined; 125 | }; 126 | 127 | // Enable dnd back after the animation is over 128 | enableDndAfterAnimating = (): void => { 129 | setTimeout(this.enableDnd, this.props.animationDuration) 130 | }; 131 | 132 | enableDnd = (): void => { 133 | this.setState({ dndEnabled: true }); 134 | }; 135 | 136 | // Find the tag at given coordinates 137 | findTagAtCoordinates = (x: number, y: number, exceptTag?: TagObject): ?TagObject => { 138 | return this.state.tags.find((tag) => 139 | tag.tlX && tag.tlY && tag.brX && tag.brY 140 | && isPointWithinArea(x, y, tag.tlX, tag.tlY, tag.brX, tag.brY) 141 | && (!exceptTag || exceptTag.title !== tag.title) 142 | ); 143 | }; 144 | 145 | // Remove tag 146 | removeTag = (tag: TagObject): void => { 147 | this.setState((state: State) => { 148 | const index = state.tags.findIndex(({ title }) => title === tag.title); 149 | return { 150 | tags: [ 151 | // Remove the tag 152 | ...state.tags.slice(0, index), 153 | ...state.tags.slice(index + 1), 154 | ] 155 | } 156 | }); 157 | }; 158 | 159 | // Swap two tags 160 | swapTags = (draggedTag: TagObject, anotherTag: TagObject): void => { 161 | this.setState((state: State) => { 162 | const draggedTagIndex = state.tags.findIndex(({ title }) => title === draggedTag.title); 163 | const anotherTagIndex = state.tags.findIndex(({ title }) => title === anotherTag.title); 164 | return { 165 | tags: moveArrayElement( 166 | state.tags, 167 | draggedTagIndex, 168 | anotherTagIndex, 169 | ), 170 | dndEnabled: false, 171 | } 172 | }, this.enableDndAfterAnimating); 173 | }; 174 | 175 | // Update the tag in the state with given props 176 | updateTagState = (tag: TagObject, props: Object): void => { 177 | this.setState((state: State) => { 178 | const index = state.tags.findIndex(({ title }) => title === tag.title); 179 | return { 180 | tags: [ 181 | ...state.tags.slice(0, index), 182 | { 183 | ...state.tags[index], 184 | ...props, 185 | }, 186 | ...state.tags.slice(index + 1), 187 | ], 188 | } 189 | }); 190 | }; 191 | 192 | // Update tag coordinates in the state 193 | onRenderTag = (tag: TagObject, 194 | screenX: number, 195 | screenY: number, 196 | width: number, 197 | height: number): void => { 198 | this.updateTagState(tag, { 199 | tlX: screenX, 200 | tlY: screenY, 201 | brX: screenX + width, 202 | brY: screenY + height, 203 | }); 204 | }; 205 | 206 | // Add new tag to the state 207 | onSubmitNewTag = (title: string): void => { 208 | // Remove tag if it already exists to re-add it to the bottom of the list 209 | const existingTag = this.state.tags.find((tag: TagObject) => tag.title === title); 210 | if (existingTag) { 211 | this.removeTag(existingTag); 212 | } 213 | // Add new tag to the state 214 | this.setState((state: State) => { 215 | return { 216 | tags: [ 217 | ...state.tags, 218 | { title }, 219 | ], 220 | } 221 | }); 222 | }; 223 | 224 | render() { 225 | const { tags } = this.state; 226 | return ( 227 | 231 | 232 | 238 | 239 | 240 | ); 241 | } 242 | 243 | } 244 | 245 | const styles = StyleSheet.create({ 246 | container: { 247 | flex: 1, 248 | paddingHorizontal: 15, 249 | }, 250 | }); 251 | --------------------------------------------------------------------------------