Skip to content

Instantly share code, notes, and snippets.

@baughmann
Last active December 8, 2019 17:15
Show Gist options
  • Save baughmann/82923c1c70390ab87ebfd7900354a2f3 to your computer and use it in GitHub Desktop.
Save baughmann/82923c1c70390ab87ebfd7900354a2f3 to your computer and use it in GitHub Desktop.
react-native Drag-and-Drop Sortable Horizontal List
/**
* @author Nick Baughman
* @description SortableList is a drag-and-drop horizonal list component that allows for animated re-ordering of a list of elements.
* @todo This component *WILL* require customization! You cannot just copy and paste! See the "TODOs"...
*/
import React, {useState, useEffect} from 'react';
import {
LayoutRectangle,
StyleSheet,
View,
ViewStyle,
LayoutAnimation,
Animated,
} from 'react-native';
import * as Helpers from './Helpers';
import * as Constants from './Constants';
import {
PanGestureHandler,
PanGestureHandlerGestureEvent,
State,
} from 'react-native-gesture-handler';
export default ({
data,
onReorder,
renderItem,
}: {
// the array of data to render
data: Array<any>;
// the function that will update the data array in the parent's state upon re-ordering
onReorder: (newData: Array<any>) => void;
// function used to render an item from the array
renderItem: ({item, index}: {item: any; index: number}) => any;
}) => {
// boolean that tells us if we're dragging something or not (used for rendering hover component, etc)
const [isMoving, setIsMoving] = useState<boolean>(false);
// the current coordinates of the drag
const [point, _setPoint] = useState<{x: number; y: number}>({x: -1, y: -1});
// the index of the element being moved
const [dragIndex, setDragIndex] = useState<number>(-1);
// the index of the element that is currently being hovered over
const [hoverIndex, setHoverIndex] = useState<number>(-1);
// the list container view
const [containerView, setContainerView] = useState<LayoutRectangle>();
// the width and margin of every list item
const [itemStyle, setItemStyle] = useState<ViewStyle>({
width: 100,
height: '100%',
backgroundColor: '#ccc',
margin: 5,
});
/**
* @description The total width plus margin of a singular item in the list. Used for calculating XY positions
*/
const WidthOfOneItem =
(itemStyle.width as number) + (itemStyle.margin as number) * 2;
// calculate the list item size based on the size of the total list
useEffect(() => {
const margin = 5;
const totalMargin = margin * 2 * data.length;
const width =
containerView && containerView.width
? Math.floor((containerView.width - totalMargin) / data.length)
: 100;
setItemStyle({
width,
height: width,
margin,
backgroundColor: '#ccc',
});
}, [containerView]);
/**
* @description Tell react to animate the next layout change (such as a list reordering, or moving something to a new XY)
*/
const animate = () => LayoutAnimation.configureNext(Constants.animConfig);
/**
* @description Updates the in-state position of the drag event, and thus the position of the hoverComponent
* @param x The absolute X-coordinate to move to
* @param y The absolute Y-coordinate to move to
*/
const setPoint = (x: number, y: number) => _setPoint({x, y});
/**
* @description Re-orders a list with a from and to index of the elements
* @param from The starting index of the element to move
* @param to The index to move the element to
*/
const reorder = (from: number, to: number) => {
const next = Helpers.reorder(data, from, to);
// tell the parent to update the array
onReorder(next);
};
// listen to changes to the hover index and then re-order the array
useEffect(() => {
if (hoverIndex !== -1) {
animate();
reorder(dragIndex, hoverIndex);
// set the drag index to the new index of the value, taht way we're displaying the
// item at the NEW position, and not pulling the item from the OLD position
setDragIndex(hoverIndex);
}
}, [hoverIndex]);
/**
* @description Gets the index of a component in the list based on an X-coordinate
* @param x The current absolute X position on the screen
* @returns {number} The numerical index of the X-coordinate
*/
const getIndex = (x: number) =>
Helpers.getIndexAtX(
x,
containerView.x,
data,
WidthOfOneItem,
containerView.width,
);
// what happens when a list item moves
const onMove = (event: PanGestureHandlerGestureEvent) => {
const {absoluteX, absoluteY} = event.nativeEvent;
// get the index that the touch event is currently at
const i = getIndex(absoluteX);
// update the state so we know which index we're at
setHoverIndex(i);
// tell the hover component where to move to
setPoint(
absoluteX - (itemStyle.width as number) / 2,
absoluteY - (itemStyle.height as number) / 2,
);
};
/**
* @description Resets the values that are altered when dragging is started
*/
const resetTouchables = () => {
setDragIndex(-1);
setHoverIndex(-1);
setPoint(-1, -1);
setIsMoving(false);
};
// what happens when drag state changes
const onState = (event: PanGestureHandlerGestureEvent) => {
const {state, absoluteX, absoluteY} = event.nativeEvent;
switch (state) {
case State.BEGAN:
setPoint(absoluteX, absoluteY);
// get the index that the touch event started at (i.e. the element that will be moved)
const index = getIndex(absoluteX);
setDragIndex(index);
setHoverIndex(index);
setIsMoving(true);
break;
case State.ACTIVE:
break;
case State.CANCELLED:
case State.FAILED:
// reset on failure
resetTouchables();
console.log('failed or cancelled');
break;
case State.END:
// animate the next change (the hoverComponent moving to the coordinates of the new index)
animate();
// this is meant to drag the hover component to new position
setPoint(
containerView.x + WidthOfOneItem * hoverIndex,
containerView.y,
);
// wait for the animation to complete and then reset
setTimeout(() => resetTouchables(), 300);
break;
default:
break;
}
};
/**
* @description Wraps the `renderItem` props (i.e. the element to be rendered) inside an animated View that can be used to manipulate it
* @param info Object containing the item itself as well as it's index
*/
const _renderItem = ({item, index}) => {
const isBeingDragged = isMoving && index === dragIndex;
const style: ViewStyle = {
width: itemStyle.width,
height: itemStyle.width,
opacity: isBeingDragged ? 0 : 1,
};
return (
/**
* @todo `item.name` in this case is a unique value per item. It should probably be a generated UUID instead, but can NOT be index
*/
<Animated.View key={`view-${item.name}`} style={{...itemStyle, ...style}}>
{renderItem({item, index})}
</Animated.View>
);
};
/**
* @description Renders a floating copy of what is being dragged
*/
const renderHoverComponent = () => {
return (
<View
style={[
styles.hoverComponent,
itemStyle,
{
top: point.y,
left: point.x,
},
]}>
{/* Use the paren't `renderItem` prop to copy the element that we're dragging */}
{renderItem({item: data[dragIndex], index: dragIndex})}
</View>
);
};
return (
<View style={styles.container}>
<PanGestureHandler onGestureEvent={onMove} onHandlerStateChange={onState}>
<View
onLayout={({nativeEvent: {layout}}) => setContainerView(layout)}
style={styles.listContainer}>
{data.map((item, index) => _renderItem({item, index}))}
</View>
</PanGestureHandler>
{dragIndex !== -1 && renderHoverComponent()}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
paddingTop: 100,
},
listContainer: {
flex: 1,
width: '100%',
flexDirection: 'row',
},
hoverComponent: {
position: 'absolute',
bottom: 0,
top: 0,
},
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment