react-native-sortable-list/src/SortableList.js
2019-11-26 21:47:02 +03:00

690 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {ScrollView, View, StyleSheet, Platform, RefreshControl, ViewPropTypes} from 'react-native';
import {shallowEqual, swapArrayElements} from './utils';
import Row from './Row';
const AUTOSCROLL_INTERVAL = 100;
const ZINDEX = Platform.OS === 'ios' ? 'zIndex' : 'elevation';
function uniqueRowKey(key) {
return `${key}${uniqueRowKey.id}`
}
uniqueRowKey.id = 0
export default class SortableList extends Component {
static propTypes = {
data: PropTypes.oneOfType([PropTypes.array, PropTypes.object]).isRequired,
order: PropTypes.arrayOf(PropTypes.any),
style: ViewPropTypes.style,
contentContainerStyle: ViewPropTypes.style,
innerContainerStyle: ViewPropTypes.style,
sortingEnabled: PropTypes.bool,
scrollEnabled: PropTypes.bool,
horizontal: PropTypes.bool,
showsVerticalScrollIndicator: PropTypes.bool,
showsHorizontalScrollIndicator: PropTypes.bool,
refreshControl: PropTypes.element,
autoscrollAreaSize: PropTypes.number,
snapToAlignment: PropTypes.string,
rowActivationTime: PropTypes.number,
manuallyActivateRows: PropTypes.bool,
keyboardShouldPersistTaps: PropTypes.oneOf(['never', 'always', 'handled']),
scrollEventThrottle: PropTypes.number,
decelerationRate: PropTypes.oneOf([PropTypes.string, PropTypes.number]),
pagingEnabled: PropTypes.bool,
nestedScrollEnabled: PropTypes.bool,
disableIntervalMomentum: PropTypes.bool,
renderRow: PropTypes.func.isRequired,
renderHeader: PropTypes.func,
renderFooter: PropTypes.func,
onChangeOrder: PropTypes.func,
onActivateRow: PropTypes.func,
onReleaseRow: PropTypes.func,
onScroll: PropTypes.func,
};
static defaultProps = {
sortingEnabled: true,
scrollEnabled: true,
keyboardShouldPersistTaps: 'never',
autoscrollAreaSize: 60,
snapToAlignment: 'start',
manuallyActivateRows: false,
showsVerticalScrollIndicator: true,
showsHorizontalScrollIndicator: true,
scrollEventThrottle: 2,
decelerationRate: 'normal',
pagingEnabled: false,
onScroll: () => {}
}
/**
* Stores refs to rows components by keys.
*/
_rows = {};
/**
* Stores promises of rows layouts.
*/
_rowsLayouts = {};
_resolveRowLayout = {};
_contentOffset = {x: 0, y: 0};
state = {
animated: false,
order: this.props.order || Object.keys(this.props.data),
rowsLayouts: null,
containerLayout: null,
data: this.props.data,
activeRowKey: null,
activeRowIndex: null,
releasedRowKey: null,
sortingEnabled: this.props.sortingEnabled,
scrollEnabled: this.props.scrollEnabled
};
componentWillMount() {
this.state.order.forEach((key) => {
this._rowsLayouts[key] = new Promise((resolve) => {
this._resolveRowLayout[key] = resolve;
});
});
if (this.props.renderHeader && !this.props.horizontal) {
this._headerLayout = new Promise((resolve) => {
this._resolveHeaderLayout = resolve;
});
}
if (this.props.renderFooter && !this.props.horizontal) {
this._footerLayout = new Promise((resolve) => {
this._resolveFooterLayout = resolve;
});
}
}
componentDidMount() {
this._onUpdateLayouts();
}
componentWillReceiveProps(nextProps) {
const {data, order} = this.state;
let {data: nextData, order: nextOrder} = nextProps;
if (data && nextData && !shallowEqual(data, nextData)) {
nextOrder = nextOrder || Object.keys(nextData)
uniqueRowKey.id++;
this._rowsLayouts = {};
nextOrder.forEach((key) => {
this._rowsLayouts[key] = new Promise((resolve) => {
this._resolveRowLayout[key] = resolve;
});
});
if (Object.keys(nextData).length > Object.keys(data).length) {
this.setState({
animated: false,
data: nextData,
containerLayout: null,
rowsLayouts: null,
order: nextOrder
});
} else {
this.setState({
data: nextData,
order: nextOrder
});
}
} else if (order && nextOrder && !shallowEqual(order, nextOrder)) {
this.setState({order: nextOrder});
}
}
componentDidUpdate(prevProps, prevState) {
const {data, scrollEnabled} = this.state;
const {data: prevData} = prevState;
if (data && prevData && !shallowEqual(data, prevData)) {
this._onUpdateLayouts();
}
if (prevProps.scrollEnabled !== scrollEnabled) {
this.setState({scrollEnabled: prevProps.scrollEnabled})
}
}
scrollBy({dx = 0, dy = 0, animated = false}) {
if (this.props.horizontal) {
this._contentOffset.x += dx;
} else {
this._contentOffset.y += dy;
}
this._scroll(animated);
}
scrollTo({x = 0, y = 0, animated = false}) {
if (this.props.horizontal) {
this._contentOffset.x = x;
} else {
this._contentOffset.y = y;
}
this._scroll(animated);
}
scrollToRowKey({key, animated = false}) {
const {order, containerLayout, rowsLayouts} = this.state;
let keyX = 0;
let keyY = 0;
for (const rowKey of order) {
if (rowKey === key) {
break;
}
keyX += rowsLayouts[rowKey].width;
keyY += rowsLayouts[rowKey].height;
}
// Scroll if the row is not visible.
if (
this.props.horizontal
? (keyX < this._contentOffset.x || keyX > this._contentOffset.x + containerLayout.width)
: (keyY < this._contentOffset.y || keyY > this._contentOffset.y + containerLayout.height)
) {
if (this.props.horizontal) {
this._contentOffset.x = keyX;
} else {
this._contentOffset.y = keyY;
}
this._scroll(animated);
}
}
render() {
let {
contentContainerStyle,
innerContainerStyle,
horizontal,
style,
showsVerticalScrollIndicator,
showsHorizontalScrollIndicator,
snapToAlignment,
scrollEventThrottle,
decelerationRate,
pagingEnabled,
nestedScrollEnabled,
disableIntervalMomentum,
keyboardShouldPersistTaps,
} = this.props;
const {animated, contentHeight, contentWidth, scrollEnabled} = this.state;
const containerStyle = StyleSheet.flatten([style, {opacity: Number(animated)}])
innerContainerStyle = [
styles.rowsContainer,
horizontal ? {width: contentWidth} : {height: contentHeight},
innerContainerStyle
];
let {refreshControl} = this.props;
if (refreshControl && refreshControl.type === RefreshControl) {
refreshControl = React.cloneElement(this.props.refreshControl, {
enabled: scrollEnabled, // fix for Android
});
}
return (
<View style={containerStyle} ref={this._onRefContainer}>
<ScrollView
nestedScrollEnabled={nestedScrollEnabled}
disableIntervalMomentum={disableIntervalMomentum}
refreshControl={refreshControl}
ref={this._onRefScrollView}
horizontal={horizontal}
contentContainerStyle={contentContainerStyle}
scrollEventThrottle={scrollEventThrottle}
pagingEnabled={pagingEnabled}
decelerationRate={decelerationRate}
scrollEnabled={scrollEnabled}
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
showsHorizontalScrollIndicator={showsHorizontalScrollIndicator}
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
snapToAlignment={snapToAlignment}
onScroll={this._onScroll}
>
{this._renderHeader()}
<View style={innerContainerStyle}>
{this._renderRows()}
</View>
{this._renderFooter()}
</ScrollView>
</View>
);
}
_renderRows() {
const {horizontal, rowActivationTime, sortingEnabled, renderRow} = this.props;
const {animated, order, data, activeRowKey, releasedRowKey, rowsLayouts} = this.state;
let nextX = 0;
let nextY = 0;
return order.map((key, index) => {
const style = {[ZINDEX]: 0};
const location = {x: 0, y: 0};
if (rowsLayouts) {
if (horizontal) {
location.x = nextX;
nextX += rowsLayouts[key] ? rowsLayouts[key].width : 0;
} else {
location.y = nextY;
nextY += rowsLayouts[key] ? rowsLayouts[key].height : 0;
}
}
const active = activeRowKey === key;
const released = releasedRowKey === key;
if (active || released) {
style[ZINDEX] = 100;
}
return (
<Row
key={uniqueRowKey(key)}
ref={this._onRefRow.bind(this, key)}
horizontal={horizontal}
activationTime={rowActivationTime}
animated={animated && !active}
disabled={!sortingEnabled}
style={style}
location={location}
onLayout={!rowsLayouts ? this._onLayoutRow.bind(this, key) : null}
onActivate={this._onActivateRow.bind(this, key, index)}
onPress={this._onPressRow.bind(this, key)}
onRelease={this._onReleaseRow.bind(this, key)}
onMove={this._onMoveRow}
manuallyActivateRows={this.props.manuallyActivateRows}>
{renderRow({
key,
data: data[key],
disabled: !sortingEnabled,
active,
index,
})}
</Row>
);
});
}
_renderHeader() {
if (!this.props.renderHeader || this.props.horizontal) {
return null;
}
const {headerLayout} = this.state;
return (
<View onLayout={!headerLayout ? this._onLayoutHeader : null}>
{this.props.renderHeader()}
</View>
);
}
_renderFooter() {
if (!this.props.renderFooter || this.props.horizontal) {
return null;
}
const {footerLayout} = this.state;
return (
<View onLayout={!footerLayout ? this._onLayoutFooter : null}>
{this.props.renderFooter()}
</View>
);
}
_onUpdateLayouts() {
Promise.all([this._headerLayout, this._footerLayout, ...Object.values(this._rowsLayouts)])
.then(([headerLayout, footerLayout, ...rowsLayouts]) => {
// Can get correct containers layout only after rowss layouts.
this._container.measure((x, y, width, height, pageX, pageY) => {
const rowsLayoutsByKey = {};
let contentHeight = 0;
let contentWidth = 0;
rowsLayouts.forEach(({rowKey, layout}) => {
rowsLayoutsByKey[rowKey] = layout;
contentHeight += layout.height;
contentWidth += layout.width;
});
this.setState({
containerLayout: {x, y, width, height, pageX, pageY},
rowsLayouts: rowsLayoutsByKey,
headerLayout,
footerLayout,
contentHeight,
contentWidth,
}, () => {
this.setState({animated: true});
});
});
});
}
_scroll(animated) {
this._scrollView.scrollTo({...this._contentOffset, animated});
}
/**
* Finds a row under the moving row, if they are neighbours,
* swaps them, else shifts rows.
*/
_setOrderOnMove() {
const {activeRowKey, activeRowIndex, order} = this.state;
if (activeRowKey === null || this._autoScrollInterval) {
return;
}
let {
rowKey: rowUnderActiveKey,
rowIndex: rowUnderActiveIndex,
} = this._findRowUnderActiveRow();
if (this._movingDirectionChanged) {
this._prevSwapedRowKey = null;
}
// Swap rows if necessary.
if (rowUnderActiveKey !== activeRowKey && rowUnderActiveKey !== this._prevSwapedRowKey) {
const isNeighbours = Math.abs(rowUnderActiveIndex - activeRowIndex) === 1;
let nextOrder;
// If they are neighbours, swap elements, else shift.
if (isNeighbours) {
this._prevSwapedRowKey = rowUnderActiveKey;
nextOrder = swapArrayElements(order, activeRowIndex, rowUnderActiveIndex);
} else {
nextOrder = order.slice();
nextOrder.splice(activeRowIndex, 1);
nextOrder.splice(rowUnderActiveIndex, 0, activeRowKey);
}
this.setState({
order: nextOrder,
activeRowIndex: rowUnderActiveIndex,
}, () => {
if (this.props.onChangeOrder) {
this.props.onChangeOrder(nextOrder);
}
});
}
}
/**
* Finds a row, which was covered with the moving rows half.
*/
_findRowUnderActiveRow() {
const {horizontal} = this.props;
const {rowsLayouts, activeRowKey, activeRowIndex, order} = this.state;
const movingRowLayout = rowsLayouts[activeRowKey];
const rowLeftX = this._activeRowLocation.x
const rowRightX = rowLeftX + movingRowLayout.width;
const rowTopY = this._activeRowLocation.y;
const rowBottomY = rowTopY + movingRowLayout.height;
for (
let currentRowIndex = 0, x = 0, y = 0, rowsCount = order.length;
currentRowIndex < rowsCount - 1;
currentRowIndex++
) {
const currentRowKey = order[currentRowIndex];
const currentRowLayout = rowsLayouts[currentRowKey];
const nextRowIndex = currentRowIndex + 1;
const nextRowLayout = rowsLayouts[order[nextRowIndex]];
x += currentRowLayout.width;
y += currentRowLayout.height;
if (currentRowKey !== activeRowKey && (
horizontal
? ((x - currentRowLayout.width <= rowLeftX || currentRowIndex === 0) && rowLeftX <= x - currentRowLayout.width / 3)
: ((y - currentRowLayout.height <= rowTopY || currentRowIndex === 0) && rowTopY <= y - currentRowLayout.height / 3)
)) {
return {
rowKey: order[currentRowIndex],
rowIndex: currentRowIndex,
};
}
if (horizontal
? (x + nextRowLayout.width / 3 <= rowRightX && (rowRightX <= x + nextRowLayout.width || nextRowIndex === rowsCount - 1))
: (y + nextRowLayout.height / 3 <= rowBottomY && (rowBottomY <= y + nextRowLayout.height || nextRowIndex === rowsCount - 1))
) {
return {
rowKey: order[nextRowIndex],
rowIndex: nextRowIndex,
};
}
}
return {rowKey: activeRowKey, rowIndex: activeRowIndex};
}
_scrollOnMove(e) {
const {pageX, pageY} = e.nativeEvent;
const {horizontal} = this.props;
const {containerLayout} = this.state;
let inAutoScrollBeginArea = false;
let inAutoScrollEndArea = false;
if (horizontal) {
inAutoScrollBeginArea = pageX < containerLayout.pageX + this.props.autoscrollAreaSize;
inAutoScrollEndArea = pageX > containerLayout.pageX + containerLayout.width - this.props.autoscrollAreaSize;
} else {
inAutoScrollBeginArea = pageY < containerLayout.pageY + this.props.autoscrollAreaSize;
inAutoScrollEndArea = pageY > containerLayout.pageY + containerLayout.height - this.props.autoscrollAreaSize;
}
if (!inAutoScrollBeginArea &&
!inAutoScrollEndArea &&
this._autoScrollInterval !== null
) {
this._stopAutoScroll();
}
// It should scroll and scrolling is processing.
if (this._autoScrollInterval !== null) {
return;
}
if (inAutoScrollBeginArea) {
this._startAutoScroll({
direction: -1,
shouldScroll: () => this._contentOffset[horizontal ? 'x' : 'y'] > 0,
getScrollStep: (stepIndex) => {
const nextStep = this._getScrollStep(stepIndex);
const contentOffset = this._contentOffset[horizontal ? 'x' : 'y'];
return contentOffset - nextStep < 0 ? contentOffset : nextStep;
},
});
} else if (inAutoScrollEndArea) {
this._startAutoScroll({
direction: 1,
shouldScroll: () => {
const {
contentHeight,
contentWidth,
containerLayout,
footerLayout = {height: 0},
} = this.state;
if (horizontal) {
return this._contentOffset.x < contentWidth - containerLayout.width
} else {
return this._contentOffset.y < contentHeight + footerLayout.height - containerLayout.height;
}
},
getScrollStep: (stepIndex) => {
const nextStep = this._getScrollStep(stepIndex);
const {
contentHeight,
contentWidth,
containerLayout,
footerLayout = {height: 0},
} = this.state;
if (horizontal) {
return this._contentOffset.x + nextStep > contentWidth - containerLayout.width
? contentWidth - containerLayout.width - this._contentOffset.x
: nextStep;
} else {
const scrollHeight = contentHeight + footerLayout.height - containerLayout.height;
return this._contentOffset.y + nextStep > scrollHeight
? scrollHeight - this._contentOffset.y
: nextStep;
}
},
});
}
}
_getScrollStep(stepIndex) {
return stepIndex > 3 ? 60 : 30;
}
_startAutoScroll({direction, shouldScroll, getScrollStep}) {
if (!shouldScroll()) {
return;
}
const {activeRowKey} = this.state;
const {horizontal} = this.props;
let counter = 0;
this._autoScrollInterval = setInterval(() => {
if (shouldScroll()) {
const movement = {
[horizontal ? 'dx' : 'dy']: direction * getScrollStep(counter++),
};
this.scrollBy(movement);
this._rows[activeRowKey].moveBy(movement);
} else {
this._stopAutoScroll();
}
}, AUTOSCROLL_INTERVAL);
}
_stopAutoScroll() {
clearInterval(this._autoScrollInterval);
this._autoScrollInterval = null;
}
_onLayoutRow(rowKey, {nativeEvent: {layout}}) {
this._resolveRowLayout[rowKey]({rowKey, layout});
}
_onLayoutHeader = ({nativeEvent: {layout}}) => {
this._resolveHeaderLayout(layout);
};
_onLayoutFooter = ({nativeEvent: {layout}}) => {
this._resolveFooterLayout(layout);
};
_onActivateRow = (rowKey, index, e, gestureState, location) => {
this._activeRowLocation = location;
this.setState({
activeRowKey: rowKey,
activeRowIndex: index,
releasedRowKey: null,
scrollEnabled: false,
});
if (this.props.onActivateRow) {
this.props.onActivateRow(rowKey);
}
};
_onPressRow = (rowKey) => {
if (this.props.onPressRow) {
this.props.onPressRow(rowKey);
}
};
_onReleaseRow = (rowKey) => {
this._stopAutoScroll();
this.setState(({activeRowKey}) => ({
activeRowKey: null,
activeRowIndex: null,
releasedRowKey: activeRowKey,
scrollEnabled: this.props.scrollEnabled,
}));
if (this.props.onReleaseRow) {
this.props.onReleaseRow(rowKey, this.state.order);
}
};
_onMoveRow = (e, gestureState, location) => {
const prevMovingRowX = this._activeRowLocation.x;
const prevMovingRowY = this._activeRowLocation.y;
const prevMovingDirection = this._movingDirection;
this._activeRowLocation = location;
this._movingDirection = this.props.horizontal
? prevMovingRowX < this._activeRowLocation.x
: prevMovingRowY < this._activeRowLocation.y;
this._movingDirectionChanged = prevMovingDirection !== this._movingDirection;
this._setOrderOnMove();
if (this.props.scrollEnabled) {
this._scrollOnMove(e);
}
};
_onScroll = (e) => {
this._contentOffset = e.nativeEvent.contentOffset;
this.props.onScroll(e)
};
_onRefContainer = (component) => {
this._container = component;
};
_onRefScrollView = (component) => {
this._scrollView = component;
};
_onRefRow = (rowKey, component) => {
this._rows[rowKey] = component;
};
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
rowsContainer: {
flex: 1,
zIndex: 1,
},
});