react-native-drag-sortable-view
Version:
A sortable view with drag support for React Native
516 lines (477 loc) • 16.4 kB
JavaScript
import React from 'react';
import {
NativeModules,
StyleSheet,
ScrollView,
View,
PanResponder,
LayoutAnimation,
Platform,
UIManager
} from 'react-native';
const PropTypes = require('prop-types')
const ANIM_DURATION = 300
// const { UIManager } = NativeModules;
if (Platform.OS === 'android') {
if (UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
}
export default class AnySizeDragSortableView extends React.PureComponent {
constructor(props) {
super(props)
this.layoutMap = new Map();
this.keyToIndexMap = new Map();
this._panResponder = PanResponder.create({
onStartShouldSetPanResponder: (evt, gestureState) => true,
onStartShouldSetPanResponderCapture: (evt, gestureState) => {
this.isMovePanResponder = false;
return false;
},
onMoveShouldSetPanResponder: (evt, gestureState) => this.isMovePanResponder,
onMoveShouldSetPanResponderCapture: (evt, gestureState) => this.isMovePanResponder,
onPanResponderGrant: (evt, gestureState) => {},
onPanResponderMove: (evt, gestureState) => this.moveTouch(evt, gestureState),
onPanResponderRelease: (evt, gestureState) => this.endTouch(evt),
onPanResponderTerminationRequest: (evt, gestureState) => false,
onShouldBlockNativeResponder: (evt, gestureState) => false,
});
this.state = {
selectedItem: null,
selectedKey: null,
selectedOriginLayout: null,
selectedPosition: null,
scrollEnabled: true
}
}
componentWillUnmount() {
if (this.isScaleRecovery) clearTimeout(this.isScaleRecovery)
this.clearAutoInterval()
}
componentDidMount() {
this.initTag()
this.autoMeasureHeight()
}
componentDidUpdate() {
this.autoMeasureHeight()
}
// Compatible with different systems and paging loading
autoMeasureHeight = () => {
if (!this.isHasMeasure) {
setTimeout(() => {
this.scrollTo(1, false)
this.scrollTo(0, false)
}, 30)
}
}
// Initialization tag
initTag = () => {
this.clearAutoInterval();
this.autoObj = {
curDy: 0,
scrollDx: 0,
scrollDy: 0,
hasScrollDy: null,
forceScrollStatus: 0, // 0: NONE 1: DOWN 2: ONLY_DOWN -1: UP -2: ONLY_UP
}
}
isStartupAuto = () => {
if (this.curScrollData == null) {
return false;
}
return true;
}
// Unified processing
dealtScrollStatus = () => {
const scrollData = this.curScrollData;
if (scrollData == null || scrollData.offsetY == null) return;
const {
totalHeight,
windowHeight,
offsetY
} = scrollData;
if (totalHeight <= windowHeight + offsetY) {
this.autoObj.forceScrollStatus = -2;
} else if (offsetY <= 0) {
this.autoObj.forceScrollStatus = 2;
}
}
// Handle automatic slide timer
clearAutoInterval = () => {
if (this.autoInterval) {
clearInterval(this.autoInterval);
this.autoInterval = null;
}
}
startAutoScroll = () => {
if (this.autoInterval != null) {
return;
}
// Start automatic swipe
this.autoInterval = setInterval(() => {
if (this.autoObj.forceScrollStatus === 0 ||
this.autoObj.forceScrollStatus === 2 ||
this.autoObj.forceScrollStatus === -2) {
this.clearAutoInterval();
return;
}
// Anti-shake 1.x1
if (!this.curScrollData.hasScroll) {
return;
}
if (this.autoObj.forceScrollStatus === 1) {
this.autoObj.scrollDy = this.autoObj.scrollDy + this.props.autoThrottle;
} else if (this.autoObj.forceScrollStatus === -1) {
this.autoObj.scrollDy = this.autoObj.scrollDy - this.props.autoThrottle;
}
this.scrollTo(this.autoObj.scrollDy, false);
this.dealtScrollStatus();
// Android slide time 30ms-50ms, iOS close to 0ms, optimize Android jitter
if (Platform.OS === 'android') {
setTimeout(() => {
if (this.isHasMove) this.moveTouch(null, {
...(this.preGestureState || {}),
dx: this.autoObj.scrollDx,
dy: this.autoObj.curDy + this.autoObj.scrollDy
})
}, 1)
} else {
this.moveTouch(null, {
...(this.preGestureState || {}),
dx: this.autoObj.scrollDx,
dy: this.autoObj.curDy + this.autoObj.scrollDy
})
}
}, this.props.autoThrottleDuration)
}
startTouch = (item, index) => {
const {keyExtractor, headerViewHeight} = this.props
this.isHasMove = false
this.isHasMeasure = true
this.preMoveKeyObj = null
// Initialization data
if (this.isStartupAuto()) {
this.autoObj.scrollDy = this.autoObj.hasScrollDy = this.curScrollData.offsetY;
}
const key = keyExtractor(item, index);
const curLayout = this.layoutMap.get(key)
const firstOffsetY = (this.curScrollData && this.curScrollData.offsetY) || 0
const initTop = parseInt(curLayout.y - firstOffsetY + headerViewHeight + 0.5)
this.setState({
scrollEnabled: false,
selectedItem: item,
selectedKey: key,
selectedOriginLayout: {...curLayout},
selectedPosition: {
left: parseInt(curLayout.x+0.5),
top: initTop,
initTop,
width: curLayout.width,
height: curLayout.height
}
}, () => {
this.isMovePanResponder = true
})
};
moveTouch = (nativeEvent, gestureState) => {
this.isHasMove = true
// record
if (nativeEvent) {
this.preGestureState = gestureState
}
const { selectedKey, selectedOriginLayout, selectedPosition } = this.state
const {
areaOverlapRatio, headerViewHeight,
childMarginTop, childMarginBottom, childMarginLeft, childMarginRight
} = this.props
const curLayout = this.layoutMap.get(selectedKey)
let {dx, dy, vy, moveY, y0} = gestureState;
if (!selectedOriginLayout) return
if (this.isStartupAuto()) {
const curDis = selectedOriginLayout.y + dy - this.autoObj.hasScrollDy;
if (nativeEvent != null) {
const tempStatus = this.autoObj.forceScrollStatus;
// Automatic sliding
const minDownDiss = curDis + selectedPosition.height + headerViewHeight;
const maxUpDiss = curDis + headerViewHeight;
if ((tempStatus === 0 || tempStatus === 2) && vy > 0.01 && minDownDiss > this.curScrollData.windowHeight) {
this.autoObj.curDy = dy;
this.autoObj.forceScrollStatus = 1;
this.startAutoScroll();
} else if ((tempStatus === 0 || tempStatus === -2) && -vy > 0.01 && maxUpDiss < 0) {
this.autoObj.curDy = dy;
this.autoObj.forceScrollStatus = -1;
this.startAutoScroll();
}
}
// Determine whether to change steering
if (vy != null) {
// Slide down 1、2
if (this.autoObj.forceScrollStatus >= 1 && -vy > 0.01) {
this.autoObj.forceScrollStatus = 0;
// Slide up -1、-2
} else if (this.autoObj.forceScrollStatus <= -1 && vy > 0.01) {
this.autoObj.forceScrollStatus = 0;
}
}
// Remember the X axis
this.autoObj.scrollDx = dx;
// Correction data 1
dy = dy - this.autoObj.hasScrollDy;
if (nativeEvent != null) {
// Correction data 2
dy = dy + this.autoObj.scrollDy;
// Prevent fingers from sliding when sliding automatically
if (this.autoObj.forceScrollStatus === 1 || this.autoObj.forceScrollStatus === -1) {
return;
}
}
}
if (!this.isUpdating) {
const moveX1 = selectedOriginLayout.x + dx + childMarginLeft
const moveX2 = moveX1 + selectedOriginLayout.width - childMarginRight
const moveY1 = selectedOriginLayout.y + dy + childMarginTop
const moveY2 = moveY1 + selectedOriginLayout.height - childMarginBottom
const nextLineY = curLayout.y + curLayout.height
const moveArea = selectedOriginLayout.width * selectedOriginLayout.height
const layouts = this.layoutMap.values()
let nextLineLastLayout = null
for (let layout of layouts) {
const tempX1 = layout.x + childMarginLeft
const tempX2 = tempX1 + layout.width - childMarginRight
const tempY1 = layout.y + childMarginTop
const tempY2 = tempY1 + layout.height - childMarginBottom
// record next line Layout start
if (nextLineY === layout.y && (!nextLineLastLayout || nextLineLastLayout.x < layout.x)) {
nextLineLastLayout = layout
}
if (layout.key === curLayout.key) continue;
const w = Math.min(moveX2, tempX2) - Math.max(moveX1, tempX1)
const h = Math.min(moveY2, tempY2) - Math.max(moveY1, tempY1)
if (w <= 0 || h <= 0) continue;
const overlapArea = w * h
const minArea = Math.min(layout.width * layout.height, moveArea)
if (overlapArea < minArea * areaOverlapRatio || !overlapArea) continue;
this.move(curLayout.key, layout.key, vy, curLayout.y !== layout.y)
break
}
// When sliding to the end, there is no assistance
if (!this.isUpdating && nextLineLastLayout &&
moveX1 >= nextLineLastLayout.x + nextLineLastLayout.width && (moveY2 + moveY1) / 2 > nextLineLastLayout.y
) {
this.move(curLayout.key, nextLineLastLayout.key, vy, curLayout.y !== nextLineLastLayout.y)
}
}
const preLeft = selectedPosition.left
const preTop = selectedPosition.top
const nextLeft = parseInt(selectedOriginLayout.x + dx + 0.5)
const nextTop = parseInt(selectedPosition.initTop + (moveY - y0) + 0.5)
if (preLeft !== nextLeft || preTop !== nextTop) {
this.setState({
selectedPosition: {
...selectedPosition,
left: nextLeft,
top: nextTop
}
})
}
};
move = (fromKey, toKey, vy, isDiffline) => {
this.isUpdating = true
const { onDataChange ,dataSource } = this.props
const length = dataSource.length
const fromIndex = this.keyToIndexMap.get(fromKey)
const toIndex = this.keyToIndexMap.get(toKey)
if (fromIndex < 0 || fromIndex >= length || toIndex < 0 || toIndex >= length) {
this.isUpdating = false
return
}
if (
this.preMoveKeyObj && this.preMoveKeyObj.fromKey === fromKey && this.preMoveKeyObj.toKey === toKey &&
isDiffline && ((toIndex - fromIndex > 0 && vy <= 0.01) || (toIndex - fromIndex < 0 && vy >= -0.01))
) {
this.isUpdating = false
return
}
this.preMoveKeyObj = {
fromKey, toKey
}
const newDataSource = [...dataSource];
const deleteItem = newDataSource.splice(fromIndex, 1);
LayoutAnimation.configureNext(
LayoutAnimation.create(
ANIM_DURATION,
LayoutAnimation.Types.linear,
LayoutAnimation.Properties.opacity
)
);
newDataSource.splice(toIndex, 0, deleteItem[0]);
onDataChange(newDataSource, () => {
setTimeout(() => {
this.isUpdating = false
}, ANIM_DURATION)
})
};
endTouch = (evt) => {
this.isHasMove = false;
this.initTag()
if (this.props.onDragEnd) this.props.onDragEnd()
this.setState({
selectedItem: null,
selectedKey: null,
selectedOriginLayout: null,
selectedPosition: null,
scrollEnabled: true
})
};
onPressOut() {
this.isScaleRecovery = setTimeout(() => {
if (this.isMovePanResponder && !this.isHasMove) {
this.endTouch()
}
}, 220)
}
_setLayoutData = (key, event) => {
this.layoutMap.set(key, {...event.nativeEvent.layout, key})
}
scrollTo = (height, animated = true) => {
// Prevent iOS from sliding when elastically sliding negative numbers
if (this.curScrollData) {
if (this.autoObj.forceScrollStatus < 0 && this.curScrollData.offsetY <= 0) {
this.autoObj.scrollDy = 0; // Correcting data system deviations
return;
} else if (this.autoObj.forceScrollStatus > 0 && this.curScrollData.windowHeight + this.curScrollData.offsetY >= this.curScrollData.totalHeight) {
this.autoObj.scrollDy = this.curScrollData.offsetY; //Correcting data system deviations
return;
}
//Barrel effect, the slowest is 1.x1
this.curScrollData.hasScroll = false;
}
this.scrollRef && this.scrollRef.scrollTo({x: 0, y: height, animated});
}
onScrollListener = (event) => {
const nativeEvent = event.nativeEvent
this.curScrollData = {
totalHeight: nativeEvent.contentSize.height,
windowHeight: nativeEvent.layoutMeasurement.height,
offsetY: nativeEvent.contentOffset.y,
hasScroll: true,
}
if (nativeEvent.contentOffset.y !== 0) this.isHasMeasure = true
if (this.props.onScrollListener) this.props.onScrollListener(event);
}
render() {
const {selectedItem, selectedPosition, scrollEnabled} = this.state
const {dataSource, keyExtractor, renderItem, movedWrapStyle} = this.props
return (
<View style={styles.box}>
{
selectedPosition && (
<View style={[movedWrapStyle, { left: selectedPosition.left, top: selectedPosition.top, position: 'absolute', zIndex: 999 }]}>
{renderItem(selectedItem, null, true)}
</View>
)
}
<ScrollView
bounces={false}
scrollEventThrottle={1}
scrollIndicatorInsets={this.props.scrollIndicatorInsets}
ref={(scrollRef)=> {
if (this.props.onScrollRef) this.props.onScrollRef(scrollRef)
this.scrollRef = scrollRef
return this.scrollRef
}}
scrollEnabled = {scrollEnabled}
onScroll={this.onScrollListener}
style={styles.scroll}>
{this.props.renderHeaderView ? this.props.renderHeaderView : null}
<View style={styles.container}>
{
dataSource.map((item, index) => {
const key = keyExtractor(item, index)
this.keyToIndexMap.set(key, index)
return (
<View
key={key}
{...this._panResponder.panHandlers}
onLayout={event => this._setLayoutData(key, event)}
>
{renderItem(item, index, false)}
</View>
)
})
}
</View>
{this.props.renderBottomView ? this.props.renderBottomView : null}
</ScrollView>
</View>
)
}
}
AnySizeDragSortableView.propTypes = {
dataSource: PropTypes.array.isRequired,
keyExtractor: PropTypes.func.isRequired,
renderItem: PropTypes.func.isRequired,
onDataChange: PropTypes.func,
headerViewHeight: PropTypes.number,
renderBottomView: PropTypes.element,
bottomViewHeight: PropTypes.number,
renderHeaderView: PropTypes.element,
autoThrottle: PropTypes.number,
onDragEnd: PropTypes.func,
autoThrottleDuration: PropTypes.number,
scrollIndicatorInsets: PropTypes.shape({
top: PropTypes.number,
left: PropTypes.number,
bottom: PropTypes.number,
right: PropTypes.number,
}),
onScrollListener: PropTypes.func,
onScrollRef: PropTypes.func,
areaOverlapRatio: PropTypes.number,
movedWrapStyle: PropTypes.object,
/**
* childMarginxx: Must be greater than > 0
* Optimize the calculation of the size of the rectangle where the two components
* intersect to prevent repeated switching caused by triggering the critical point .
*/
childMarginTop: PropTypes.number,
childMarginBottom: PropTypes.number,
childMarginLeft: PropTypes.number,
childMarginRight: PropTypes.number
}
AnySizeDragSortableView.defaultProps = {
areaOverlapRatio: 0.55, // Must be greater than 0.5
autoThrottle: 2,
autoThrottleDuration: 10,
scrollIndicatorInsets: {
top: 0,
left: 0,
bottom: 0,
right: 1,
},
headerViewHeight: 0,
bottomViewHeight: 0,
movedWrapStyle: {
backgroundColor: 'blue',
zIndex: 999,
},
childMarginTop: 10,
childMarginBottom: 10,
childMarginLeft: 10,
childMarginRight: 10
}
const styles = StyleSheet.create({
box: {
flex: 1,
position: 'relative'
},
scroll: {
flex: 1,
},
container: {
flex: 1,
flexDirection: 'row',
flexWrap: 'wrap'
}
});