UNPKG

react-native-sortable-list

Version:
686 lines (586 loc) 19.9 kB
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.oneOfType([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, isMounting: true, activeRowKey: null, activeRowIndex: null, releasedRowKey: null, sortingEnabled: this.props.sortingEnabled, scrollEnabled: this.props.scrollEnabled }; componentDidMount() { 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; }); } this._onUpdateLayouts(); this.setState({ isMounting: false }); } componentDidUpdate(prevProps, prevState) { const {data: currentData, order: currentOrder, scrollEnabled} = this.state; const {data: prevData} = prevState; let {data: nextData, order: nextOrder} = this.props; if (currentData && nextData && !shallowEqual(currentData, 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(currentData).length) { this.setState({ animated: false, data: nextData, containerLayout: null, rowsLayouts: null, order: nextOrder }); } else { this.setState({ data: nextData, order: nextOrder }); } } else if (currentOrder && nextOrder && !shallowEqual(currentOrder, nextOrder)) { this.setState({order: nextOrder}); } if (currentData && prevData && !shallowEqual(currentData, prevData)) { this._onUpdateLayouts(); } } 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() { if (this.state.isMounting ) return null; 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 container’s layout only after rows’s 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 row’s 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, }, });