react-native-draggable-dynamic-flatlist
Version:
A react native component that lets you drag and drop dynamic items of a FlatList.
533 lines (480 loc) • 18.8 kB
JavaScript
import React, { Component } from 'react'
import {
YellowBox,
Animated,
FlatList,
View,
PanResponder,
UIManager,
StyleSheet,
} from 'react-native'
// Measure function triggers false positives
YellowBox.ignoreWarnings(['Warning: isMounted(...) is deprecated']);
UIManager.setLayoutAnimationEnabledExperimental && UIManager.setLayoutAnimationEnabledExperimental(true);
const initialState = {
hoverComponent: null,
extraData: null,
};
class DraggableFlatList extends Component {
_moveAnim = new Animated.Value(0);
_offset = new Animated.Value(0);
_hoverAnim = Animated.add(this._moveAnim, this._offset);
_spacerIndex = -1;
_previousIndex = -1;
_nextIndex = -1;
_tappedRow = -1;
_tappedRowSize = 0;
_scrollOffset = 0;
_containerSize;
_containerOffset;
_headerSize = 0;
_move = 0;
_hasMoved = false;
_additionalOffset = 0;
_releaseVal = 0;
_releaseAnim = null;
_currentEvent = null;
_container = null;
_spacerLayout = null;
_size = [];
_position = [];
_order = [];
constructor(props) {
super(props);
const { data } = this.props;
for (let i = 0; i < data.length; i++) {
this._order[i] = i;
}
this._panResponder = PanResponder.create({
onStartShouldSetPanResponderCapture: (evt, {numberActiveTouches}) => {
if (numberActiveTouches > 1) {
return true;
}
evt.persist();
this._currentEvent = evt;
return false
},
onMoveShouldSetPanResponder: (evt, gestureState) => {
const { horizontal } = this.props;
const { moveX, moveY, numberActiveTouches } = gestureState;
const move = horizontal ? moveX : moveY;
if (numberActiveTouches > 1) {
this.onRelease();
return false;
}
const shouldSet = this._tappedRow > -1;
if (shouldSet) {
this._moveAnim.setValue(move);
this.animate();
this._hasMoved = true
}
return shouldSet;
},
onPanResponderMove: (evt, gestureState) => {
if (gestureState.numberActiveTouches > 1) {
this.onRelease();
return;
}
Animated.event([null, { [props.horizontal ? 'moveX' : 'moveY']: this._moveAnim }], {
listener: (evt, gestureState) => {
const { moveX, moveY } = gestureState;
const { horizontal } = this.props;
this._move = horizontal ? moveX : moveY;
}
})(evt, gestureState)
},
onPanResponderTerminationRequest: ({ nativeEvent }, gestureState) => {
return false;
},
onPanResponderRelease: this.onRelease
});
this.state = initialState
}
componentDidUpdate = (prevProps, prevState) => {
if (prevProps.extraData !== this.props.extraData) {
this.setState({ extraData: this.props.extraData })
}
};
initPositions = () => {
let currentPos = this._containerOffset + this._headerSize;
for (let i = 0; i < this._order.length; i++) {
const index = this._order[i];
if (this._size[index] > 0) {
this._position[index] = currentPos;
currentPos += this._size[index];
if (index === this._tappedRow) {
this._position[index] = -1;
}
}
}
};
onRelease = () => {
if (this._currentEvent === null) return;
this._currentEvent = null;
if (this._tappedRow === -1) return;
const { horizontal } = this.props;
const { pageX, pageY, scrollOffset } = this._spacerLayout;
const position = horizontal ? pageX : pageY - this._scrollOffset + scrollOffset;
this._releaseVal = position - (this._containerOffset);
if (this._releaseAnim) this._releaseAnim.stop();
this._releaseAnim = Animated.parallel([
// after decay, in parallel:
Animated.spring(this._offset, {
toValue: 0,
stiffness: 5000,
damping: 500,
mass: 3,
useNativeDriver: true,
}),
Animated.spring(this._moveAnim, {
toValue: this._releaseVal,
stiffness: 5000,
damping: 500,
mass: 3,
useNativeDriver: true,
}),
]);
this._releaseAnim.start(this.onReleaseAnimationEnd)
};
move = (hoverComponent, index) => {
const { onMoveBegin, data } = this.props;
if (this._releaseAnim) {
this._releaseAnim.stop();
this.onReleaseAnimationEnd();
return
}
for (let i = 0; i < data.length; i++) {
this._order[i] = i;
}
this.initPositions();
this._tappedRow = index;
this._spacerIndex = index;
this._nextIndex = this._spacerIndex + 1;
this._previousIndex = this._spacerIndex - 1;
if (this._currentEvent && this._currentEvent.nativeEvent) {
const { pageX, pageY } = this._currentEvent.nativeEvent;
const { horizontal } = this.props;
this._tappedRowSize = this._size[this._tappedRow];
const position = this._position[this._tappedRow] - this._scrollOffset;
this._position[this._tappedRow] = -1;
if (this._tappedRow === -1) {
return false;
}
const tappedPixel = horizontal ? pageX : pageY;
this._moveAnim.setValue(tappedPixel);
this._move = tappedPixel;
this._additionalOffset = position - tappedPixel - (this._containerOffset);
this._offset.setValue(this._additionalOffset);
this.getSpacerIndex(tappedPixel);
this.setState({
hoverComponent,
}, () => onMoveBegin && onMoveBegin(index)
);
}
};
animate = () => {
const { scrollPercent, scrollSpeed } = this.props;
const scrollRatio = scrollPercent / 100;
if (this._tappedRow === -1) return;
const shouldScrollUp = this._move - this._containerOffset < (this._containerSize * scrollRatio);
const shouldScrollDown = this._move - this._containerOffset > (this._containerSize * (1 - scrollRatio));
if (shouldScrollUp) this.scroll(-scrollSpeed, this._spacerIndex);
else if (shouldScrollDown) this.scroll(scrollSpeed, this._spacerIndex);
this.getSpacerIndex(this._move);
requestAnimationFrame(this.animate)
};
scroll = (scrollAmt, spacerIndex) => {
if (spacerIndex === -1) return;
const newOffset = this._scrollOffset + scrollAmt;
const offset = Math.max(0, newOffset);
this._flatList.scrollToOffset({ offset, animated: false })
};
getSpacerIndex = (move) => {
const { data } = this.props;
const spacerIndex = this._order[this._spacerIndex];
const previousIndex = this._order[this._previousIndex];
const nextIndex = this._order[this._nextIndex];
const sizePrevious = this._size[previousIndex];
const positionPrevious = this._position[previousIndex];
const sizeNext = this._size[nextIndex];
const positionNext = this._position[nextIndex];
if (!sizePrevious || sizePrevious <= 0 || !positionPrevious || positionPrevious <= 0) {
this._previousIndex = this._previousIndex - 1;
if (this._previousIndex < 0) {
this._previousIndex = this._spacerIndex - 1;
if (!this._noPrevious) {
this._noPrevious = true;
this.forceUpdate();
}
}
} else {
if (positionPrevious >= 0) {
this._noPrevious = false;
if (move + this._scrollOffset < (positionPrevious + (sizePrevious / 2))) {
this._order[this._spacerIndex] = previousIndex;
this._order[this._previousIndex] = spacerIndex;
this._spacerIndex = this._previousIndex;
this._previousIndex = this._spacerIndex - 1;
this._nextIndex = this._previousIndex + 1;
let found = false;
for (let i = this._previousIndex; i >= 0; i--) {
const nextIndexTest = this._order[i];
const sizePreviousTest = this._size[nextIndexTest];
if (sizePreviousTest > 0 && nextIndexTest !== this._tappedRow) {
found = true;
}
}
this._noNext = false;
this._noPrevious = !found;
this.forceUpdate();
return;
}
}
}
if (!sizeNext || sizeNext <= 0 || !positionNext || positionNext <= 0) {
this._nextIndex = this._nextIndex + 1;
if (this._nextIndex >= data.length) {
this._nextIndex = this._spacerIndex + 1;
if (!this._noNext) {
this._noNext = true;
this.forceUpdate();
}
}
} else {
if (positionNext >= 0) {
this._noNext = false;
if (move + this._scrollOffset > (positionNext + (sizeNext / 2))) {
this._order[this._spacerIndex] = nextIndex;
this._order[this._nextIndex] = spacerIndex;
this._spacerIndex = this._nextIndex;
this._nextIndex = this._spacerIndex + 1;
this._previousIndex = this._nextIndex - 1;
let found = false;
for (let i = this._nextIndex; i < data.length; i++) {
const nextIndexTest = this._order[i];
const sizeNextTest = this._size[nextIndexTest];
if (sizeNextTest > 0 && nextIndexTest !== this._tappedRow) {
found = true;
}
}
this._noPrevious = false;
this._noNext = !found;
this.forceUpdate();
return;
}
}
}
};
onReleaseAnimationEnd = () => {
const { data, onMoveEnd } = this.props;
const tappedRowSave = this._tappedRow;
const from = this._tappedRow;
const to = this._spacerIndex;
const sortedData = this.arrayMove([...data], from, to);
this._size = this.arrayMove(this._size, from, to);
for (let i = 0; i < data.length; i++) {
this._order[i] = i;
}
this._moveAnim.setValue(this._releaseVal);
this._spacerIndex = -1;
this._nextIndex = -1;
this._previousIndex = -1;
this._tappedRow = -1;
this._hasMoved = false;
this._move = 0;
this._releaseAnim = null;
this.initPositions();
this.setState(initialState, () => {
onMoveEnd && onMoveEnd({
row: data[tappedRowSave],
from,
to,
data: sortedData,
})
})
};
arrayMove = (arr, old_index, new_index) => {
if (new_index >= arr.length) {
let k = new_index - arr.length + 1;
while (k--) {
arr.push(undefined);
}
}
arr.splice(new_index, 0, arr.splice(old_index, 1)[0]);
return arr;
};
moveEnd = () => {
if (!this._hasMoved) {
this._moveAnim.setValue(0);
this._spacerIndex = -1;
this._nextIndex = -1;
this._previousIndex = -1;
this._tappedRow = -1;
this._hasMoved = false;
this._move = 0;
this._releaseAnim = null;
this.setState(initialState);
}
};
renderItem = ({ item, index }) => {
const { renderItem, horizontal, data, spacerStyle } = this.props;
const _spacerIndex = this._tappedRow === data.length -1? this._spacerIndex-1: this._spacerIndex;
const isActiveRow = this._tappedRow === index;
const isSpacerRow = _spacerIndex === index;
const firstItem = this._noPrevious && this._tappedRow !== -1;
const spacer = (
<View
style={{
[horizontal ? 'width' : 'height']: this._tappedRowSize
}}
onLayout={e => {
this._spacerRef.measure((x, y, width, height, pageX, pageY) => {
this._spacerLayout = { x, y, width, height, pageX, pageY, scrollOffset: this._scrollOffset };
})
}}
ref={(ref) => { this._spacerRef = ref; }}>
<View style={spacerStyle} />
</View>
);
return (
<View
onLayout={e => {
if (index !== this._tappedRow) {
this._size[index] = e.nativeEvent.layout[horizontal ? 'width' : 'height'] - (((firstItem && index === 0) || (!firstItem && isSpacerRow))? this._tappedRowSize: 0);
this.initPositions();
}
}}
style={[styles.fullOpacity, { flexDirection: horizontal ? 'row' : 'column' }]} >
{
(firstItem && index === 0)? ( spacer ): null
}
<RowItem
horizontal={horizontal}
index={index}
isActiveRow={isActiveRow}
renderItem={renderItem}
item={item}
move={this.move}
moveEnd={this.moveEnd}
extraData={this.state.extraData}
/>
{
(!firstItem && isSpacerRow)? ( spacer ): null
}
</View>
)
};
renderHoverComponent = () => {
const { hoverComponent } = this.state;
const { horizontal, scaleSelectionFactor } = this.props;
return !!hoverComponent && (
<Animated.View style={[
horizontal ? styles.hoverComponentHorizontal : styles.hoverComponentVertical,
{
transform: [
horizontal ? { translateX: this._hoverAnim } : { translateY: this._hoverAnim },
{ scaleX: scaleSelectionFactor },
{ scaleY: scaleSelectionFactor }
]
}
]}>
{hoverComponent}
</Animated.View>
)
};
renderHeaderComponent = () => {
const { ListHeaderComponent, horizontal } = this.props;
return !!ListHeaderComponent && (
<View
onLayout={e => {
this._headerSize = e.nativeEvent.layout[horizontal ? 'width' : 'height'];
}}>
<ListHeaderComponent />
</View>
)
};
keyExtractor = (item, index) => `sortable-flatlist-item-${index}`;
render() {
const { horizontal, keyExtractor, removeClippedSubviews } = this.props;
return (
<View
onLayout={e => {
this._container.measure((x, y, width, height, pageX, pageY) => {
this._containerOffset = horizontal ? pageX : pageY;
this._containerSize = horizontal ? width : height;
this.initPositions();
})
}}
ref={(ref) => {
this._container = ref;
}}
collapsable={false}
{...this._panResponder.panHandlers}
style={styles.wrapper}>
<FlatList
{...this.props}
ListHeaderComponent={this.renderHeaderComponent}
removeClippedSubviews={removeClippedSubviews}
scrollEnabled={this._tappedRow === -1}
ref={ref => this._flatList = ref}
renderItem={this.renderItem}
extraData={this.state}
keyExtractor={keyExtractor || this.keyExtractor}
onScroll={({ nativeEvent }) => {
this._scrollOffset = nativeEvent.contentOffset[horizontal ? 'x' : 'y'];
} }
scrollEventThrottle={16}
/>
{this.renderHoverComponent()}
</View>
)
}
}
export default DraggableFlatList
DraggableFlatList.defaultProps = {
scrollPercent: 5,
scrollSpeed: 10,
scaleSelectionFactor: 0.95,
removeClippedSubviews: false
};
class RowItem extends React.PureComponent {
move = () => {
const { move, moveEnd, renderItem, item, index } = this.props;
const hoverComponent = renderItem({ isActive: true, item, index, move: () => null, moveEnd });
move(hoverComponent, index)
};
render() {
const { moveEnd, isActiveRow, horizontal, renderItem, item, index } = this.props;
const component = renderItem({
isActive: false,
item,
index,
move: this.move,
moveEnd,
});
let wrapperStyle = { opacity: 1 };
if (isActiveRow) wrapperStyle = { display: 'none' };
// Rendering the final row requires padding to be applied at the bottom
return (
<View collapsable={false} style={{ opacity: 1, flexDirection: horizontal ? 'row' : 'column' }}>
<View style={wrapperStyle}>
{component}
</View>
</View>
)
}
}
const styles = StyleSheet.create({
hoverComponentVertical: {
position: 'absolute',
left: 0,
right: 0,
},
hoverComponentHorizontal: {
position: 'absolute',
bottom: 0,
top: 0,
},
wrapper: { flex: 1, opacity: 1 },
fullOpacity: { opacity: 1 }
});