Last active
December 8, 2019 17:15
-
-
Save baughmann/82923c1c70390ab87ebfd7900354a2f3 to your computer and use it in GitHub Desktop.
react-native Drag-and-Drop Sortable Horizontal List
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @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