react-native-sortable-grid
Version:
Drag-and-drop sortable grid view for React Native.
590 lines (516 loc) • 19.5 kB
JavaScript
import React, { Component } from 'react'
import {
StyleSheet,
Animated,
TouchableWithoutFeedback,
PanResponder,
Image,
View
} from 'react-native'
import _ from 'lodash'
// Default values
const ITEMS_PER_ROW = 4
const DRAG_ACTIVATION_TRESHOLD = 200 // Milliseconds
const BLOCK_TRANSITION_DURATION = 300 // Milliseconds
const ACTIVE_BLOCK_CENTERING_DURATION = 200 // Milliseconds
const DOUBLETAP_TRESHOLD = 150 // Milliseconds
const NULL_FN = () => {}
class Block extends Component {
render = () =>
<Animated.View
style = { this.props.style }
onLayout = { this.props.onLayout }
{...this.props.panHandlers}
>
<TouchableWithoutFeedback
style = {{ flex: 1 }}
delayLongPress = { this.props.delayLongPress }
onLongPress = { this.props.onLongPress }
onPress = { this.props.onPress }>
<View style={styles.itemImageContainer}>
<View style={ this.props.itemWrapperStyle }>
{this.props.children}
</View>
{ this.props.deletionView }
</View>
</TouchableWithoutFeedback>
</Animated.View>
}
class SortableGrid extends Component {
render = () =>
<Animated.View
style={ this._getGridStyle() }
onLayout={ this.assessGridSize }
>
{ this.state.gridLayout &&
this.items.map( (item, key) =>
<Block
key = { key }
style = { this._getBlockStyle(key) }
onLayout = { this.saveBlockPositions(key) }
panHandlers = { this._panResponder.panHandlers }
delayLongPress = { this.dragActivationTreshold }
onLongPress = { this.activateDrag(key) }
onPress = { this.handleTap(item.props) }
itemWrapperStyle = { this._getItemWrapperStyle(key) }
deletionView = { this._getDeletionView(key) }
>
{item}
</Block>
)}
</Animated.View>
constructor() {
super()
this.blockTransitionDuration = BLOCK_TRANSITION_DURATION
this.activeBlockCenteringDuration = ACTIVE_BLOCK_CENTERING_DURATION
this.itemsPerRow = ITEMS_PER_ROW
this.dragActivationTreshold = DRAG_ACTIVATION_TRESHOLD
this.doubleTapTreshold = DOUBLETAP_TRESHOLD
this.onDragRelease = NULL_FN
this.onDragStart = NULL_FN
this.onDeleteItem = NULL_FN
this.dragStartAnimation = null
this.rows = null
this.dragPosition = null
this.activeBlockOffset = null
this.blockWidth = null
this.gridHeightTarget = null
this.ghostBlocks = []
this.itemOrder = []
this.panCapture = false
this.items = []
this.initialLayoutDone = false
this.initialDragDone = false
this.tapTimer = null
this.tapIgnore = false
this.doubleTapWait = false
this.state = {
gridLayout: null,
blockPositions: [],
startDragWiggle: new Animated.Value(0),
activeBlock: null,
blockWidth: null,
gridHeight: new Animated.Value(0),
blockPositionsSetCount: 0,
deleteModeOn: false,
deletionSwipePercent: 0,
deleteBlock: null,
deleteBlockOpacity: new Animated.Value(1),
deletedItems: []
}
}
toggleDeleteMode = () => {
let deleteModeOn = !this.state.deleteModeOn
this.setState({ deleteModeOn })
return { deleteModeOn }
}
componentWillMount = () => this.createTouchHandlers()
componentDidMount = () => this.handleNewProps(this.props)
componentWillUnmount = () => { if (this.tapTimer) clearTimeout(this.tapTimer) }
componentWillReceiveProps = (properties) => this.handleNewProps(properties)
handleNewProps = (properties) => {
this._assignReceivedPropertiesIntoThis(properties)
this._saveItemOrder(properties.children)
this._removeDisappearedChildren(properties.children)
}
onStartDrag = (evt, gestureState) => {
if (this.state.activeBlock != null) {
let activeBlockPosition = this._getActiveBlock().origin
let x = activeBlockPosition.x - gestureState.x0
let y = activeBlockPosition.y - gestureState.y0
this.activeBlockOffset = { x, y }
this._getActiveBlock().currentPosition.setOffset({ x, y })
this._getActiveBlock().currentPosition.setValue({ x: gestureState.moveX, y: gestureState.moveY })
}
}
onMoveBlock = (evt, {moveX, moveY, dx, dy}) => {
if (this.state.activeBlock != null && this._blockPositionsSet()) {
if (this.state.deleteModeOn) return this.deleteModeMove({ x: moveX, y: moveY })
if (dx != 0 || dy != 0) this.initialDragDone = true
let yChokeAmount = Math.max(0, (this.activeBlockOffset.y + moveY) - (this.state.gridLayout.height - this.blockWidth))
let xChokeAmount = Math.max(0, (this.activeBlockOffset.x + moveX) - (this.state.gridLayout.width - this.blockWidth))
let yMinChokeAmount = Math.min(0, this.activeBlockOffset.y + moveY)
let xMinChokeAmount = Math.min(0, this.activeBlockOffset.x + moveX)
let dragPosition = { x: moveX - xChokeAmount - xMinChokeAmount, y: moveY - yChokeAmount - yMinChokeAmount }
this.dragPosition = dragPosition
let originalPosition = this._getActiveBlock().origin
let distanceToOrigin = this._getDistanceTo(originalPosition)
this._getActiveBlock().currentPosition.setValue(dragPosition)
let closest = this.state.activeBlock
let closestDistance = distanceToOrigin
this.state.blockPositions.forEach( (block, index) => {
if (index !== this.state.activeBlock && block.origin) {
let blockPosition = block.origin
let distance = this._getDistanceTo(blockPosition)
if (distance < closestDistance && distance < this.state.blockWidth) {
closest = index
closestDistance = distance
}
}
})
this.ghostBlocks.forEach( ghostBlockPosition => {
let distance = this._getDistanceTo(ghostBlockPosition)
if (distance < closestDistance) {
closest = this.state.activeBlock
closestDistance = distance
}
})
if (closest !== this.state.activeBlock) {
Animated.timing(
this._getBlock(closest).currentPosition,
{
toValue: this._getActiveBlock().origin,
duration: this.blockTransitionDuration
}
).start()
let blockPositions = this.state.blockPositions
this._getActiveBlock().origin = blockPositions[closest].origin
blockPositions[closest].origin = originalPosition
this.setState({ blockPositions })
var tempOrderIndex = this.itemOrder[this.state.activeBlock].order
this.itemOrder[this.state.activeBlock].order = this.itemOrder[closest].order
this.itemOrder[closest].order = tempOrderIndex
}
}
}
onReleaseBlock = (evt, gestureState) => {
this.returnBlockToOriginalPosition()
if (this.state.deleteModeOn && this.state.deletionSwipePercent == 100)
this.deleteBlock()
else
this.afterDragRelease()
}
deleteBlock = () => {
this.setState({ deleteBlock: this.state.activeBlock })
this.blockAnimateFadeOut()
.then( () => {
let activeBlock = this.state.activeBlock
this.setState({ activeBlock: null, deleteBlock: null }, () => {
this.onDeleteItem({ item: this.itemOrder[ activeBlock ] })
this.deleteBlocks([ activeBlock ])
this.afterDragRelease()
})
})
}
blockAnimateFadeOut = () => {
this.state.deleteBlockOpacity.setValue(1)
return new Promise( (resolve, reject) => {
Animated.timing(
this.state.deleteBlockOpacity,
{ toValue: 0, duration: 2 * this.activeBlockCenteringDuration }
).start(resolve)
})
}
animateBlockMove = (blockIndex, position) => {
Animated.timing(
this._getBlock(blockIndex).currentPosition,
{
toValue: position,
duration: this.blockTransitionDuration
}
).start()
}
returnBlockToOriginalPosition = () => {
let activeBlockCurrentPosition = this._getActiveBlock().currentPosition
activeBlockCurrentPosition.flattenOffset()
Animated.timing(
activeBlockCurrentPosition,
{
toValue: this._getActiveBlock().origin,
duration: this.activeBlockCenteringDuration
}
).start()
}
afterDragRelease = () => {
let itemOrder = _.sortBy( this.itemOrder, item => item.order )
this.onDragRelease({ itemOrder })
this.setState({ activeBlock: null })
this.panCapture = false
}
deleteModeMove = ({x, y}) => {
let slideDistance = 50
let moveY = y + this.activeBlockOffset.y - this._getActiveBlock().origin.y
let adjustY = 0
if (moveY < 0) adjustY = moveY
else if (moveY > slideDistance) adjustY = moveY - slideDistance
let deletionSwipePercent = (moveY - adjustY) / slideDistance * 100
this._getActiveBlock().currentPosition.y.setValue(y - adjustY)
this.setState({deletionSwipePercent})
}
assessGridSize = ({nativeEvent}) => {
this.blockWidth = nativeEvent.layout.width / this.itemsPerRow
if (this.state.gridLayout != nativeEvent.layout) {
this.setState({
gridLayout: nativeEvent.layout,
blockWidth: this.blockWidth
})
}
}
reAssessGridRows = () => {
let oldRows = this.rows
this.rows = Math.ceil(this.items.length / this.itemsPerRow)
if (this.state.blockWidth && oldRows != this.rows) this._animateGridHeight()
}
saveBlockPositions = (key) => ({nativeEvent}) => {
let blockPositions = this.state.blockPositions
if (!blockPositions[key]) {
let blockPositionsSetCount = blockPositions[key] ? this.state.blockPositionsSetCount : ++this.state.blockPositionsSetCount
let thisPosition = {
x: nativeEvent.layout.x,
y: nativeEvent.layout.y
}
blockPositions[key] = {
currentPosition : new Animated.ValueXY( thisPosition ),
origin : thisPosition
}
this.setState({ blockPositions, blockPositionsSetCount })
if (this._blockPositionsSet()) {
this.setGhostPositions()
this.initialLayoutDone = true
}
}
}
getNextBlockCoordinates = () => {
let blockWidth = this.state.blockWidth
let placeOnRow = this.items.length % this.itemsPerRow
let y = blockWidth * Math.floor(this.items.length / this.itemsPerRow)
let x = placeOnRow * blockWidth
return { x, y }
}
setGhostPositions = () => {
this.ghostBlocks = []
this.reAssessGridRows()
let blockWidth = this.state.blockWidth
let fullGridItemCount = this.rows * this.itemsPerRow
let ghostBlockCount = fullGridItemCount - this.items.length
let y = blockWidth * (this.rows - 1)
let initialX = blockWidth * (this.itemsPerRow - ghostBlockCount)
for (let i = 0; i < ghostBlockCount; ++i) {
let x = initialX + blockWidth * i
this.ghostBlocks.push({x, y})
}
}
activateDrag = (key) => () => {
this.panCapture = true
this.onDragStart( this.itemOrder[key] )
this.setState({ activeBlock: key })
this._defaultDragActivationWiggle()
}
handleTap = ({ onTap = NULL_FN, onDoubleTap = NULL_FN }) => () => {
if (this.tapIgnore) this._resetTapIgnoreTime()
else if (onDoubleTap != null) {
this.doubleTapWait ? this._onDoubleTap(onDoubleTap) : this._onSingleTap(onTap)
} else onTap()
}
// Helpers & other boring stuff
_getActiveBlock = () => this.state.blockPositions[ this.state.activeBlock ]
_getBlock = (blockIndex) => this.state.blockPositions[ blockIndex ]
_blockPositionsSet = () => this.state.blockPositionsSetCount === this.items.length
_saveItemOrder = (items) => {
items.forEach( (item, index) => {
if (!_.findKey(this.itemOrder, (oldItem) => oldItem.key === item.key)) {
this.itemOrder.push({ key: item.key, ref: item.ref, order: this.items.length })
if (!this.initialLayoutDone) {
this.items.push(item)
}
else {
let blockPositions = this.state.blockPositions
let blockPositionsSetCount = ++this.state.blockPositionsSetCount
let thisPosition = this.getNextBlockCoordinates()
blockPositions.push({
currentPosition : new Animated.ValueXY( thisPosition ),
origin : thisPosition
})
this.items.push(item)
this.setState({ blockPositions, blockPositionsSetCount })
this.setGhostPositions()
}
}
})
}
_removeDisappearedChildren = (items) => {
let deleteBlockIndices = []
_.cloneDeep(this.itemOrder).forEach( (item, index) => {
if (!_.findKey(items, (oldItem) => oldItem.key === item.key)) {
deleteBlockIndices.push(index)
}
})
if (deleteBlockIndices.length > 0) {
this.deleteBlocks(deleteBlockIndices)
}
}
deleteBlocks = (deleteBlockIndices) => {
let blockPositions = this.state.blockPositions
let blockPositionsSetCount = this.state.blockPositionsSetCount
_.sortBy(deleteBlockIndices, index => -index).forEach(index => {
--blockPositionsSetCount
let order = this.itemOrder[index].order
blockPositions.splice(index, 1)
this._fixItemOrderOnDeletion(this.itemOrder[index])
this.itemOrder.splice(index, 1)
this.items.splice(index, 1)
})
this.setState({ blockPositions, blockPositionsSetCount }, () => {
this.items.forEach( (item, order) => {
let blockIndex = _.findIndex(this.itemOrder, item => item.order === order)
let x = (order * this.state.blockWidth) % (this.itemsPerRow * this.state.blockWidth)
let y = Math.floor(order / this.itemsPerRow) * this.state.blockWidth
this.state.blockPositions[blockIndex].origin = {x, y}
this.animateBlockMove(blockIndex, {x, y})
})
this.setGhostPositions()
})
}
_fixItemOrderOnDeletion = (orderItem) => {
if (!orderItem) return false
orderItem.order--
this._fixItemOrderOnDeletion(_.find(this.itemOrder, item => item.order === orderItem.order + 2))
}
_animateGridHeight = () => {
this.gridHeightTarget = this.rows * this.state.blockWidth
if (this.gridHeightTarget === this.state.gridLayout.height || this.state.gridLayout.height === 0)
this.state.gridHeight.setValue(this.gridHeightTarget)
else if (this.state.gridHeight._value !== this.gridHeightTarget) {
Animated.timing(
this.state.gridHeight,
{
toValue: this.gridHeightTarget,
duration: this.blockTransitionDuration
}
).start()
}
}
_getDistanceTo = (point) => {
let xDistance = this.dragPosition.x + this.activeBlockOffset.x - point.x
let yDistance = this.dragPosition.y + this.activeBlockOffset.y - point.y
return Math.sqrt( Math.pow(xDistance, 2) + Math.pow(yDistance, 2) )
}
_defaultDragActivationWiggle = () => {
if (!this.dragStartAnimation) {
this.state.startDragWiggle.setValue(20)
Animated.spring(this.state.startDragWiggle, {
toValue: 0,
velocity: 2000,
tension: 2000,
friction: 5
}).start()
}
}
_blockActivationWiggle = () => {
return this.dragStartAnimation ||
{ transform: [{ rotate: this.state.startDragWiggle.interpolate({
inputRange: [0, 360],
outputRange: ['0 deg', '360 deg']})}]}
}
_assignReceivedPropertiesIntoThis(properties) {
Object.keys(properties).forEach(property => {
if (this[property])
this[property] = properties[property]
})
this.dragStartAnimation = properties.dragStartAnimation
}
_onSingleTap = (onTap) => {
this.doubleTapWait = true
this.tapTimer = setTimeout( () => {
this.doubleTapWait = false
onTap()
}, this.doubleTapTreshold)
}
_onDoubleTap = (onDoubleTap) => {
this._resetTapIgnoreTime()
this.doubleTapWait = false
this.tapIgnore = true
onDoubleTap()
}
_resetTapIgnoreTime = () => {
clearTimeout(this.tapTimer)
this.tapTimer = setTimeout(() => this.tapIgnore = false, this.doubleTapTreshold)
}
createTouchHandlers = () =>
this._panResponder = PanResponder.create({
onPanResponderTerminate: (evt, gestureState) => {},
onStartShouldSetPanResponder: (evt, gestureState) => true,
onStartShouldSetPanResponderCapture: (evt, gestureState) => false,
onMoveShouldSetPanResponder: (evt, gestureState) => this.panCapture,
onMoveShouldSetPanResponderCapture: (evt, gestureState) => this.panCapture,
onShouldBlockNativeResponder: (evt, gestureState) => false,
onPanResponderTerminationRequest: (evt, gestureState) => false,
onPanResponderGrant: this.onActiveBlockIsSet(this.onStartDrag),
onPanResponderMove: this.onActiveBlockIsSet(this.onMoveBlock),
onPanResponderRelease: this.onActiveBlockIsSet(this.onReleaseBlock)
})
onActiveBlockIsSet = (fn) => (evt, gestureState) => {
if (this.state.activeBlock != null) fn(evt, gestureState)
}
// Style getters
_getGridStyle = () => [
styles.sortableGrid,
this.props.style,
this._blockPositionsSet() && { height: this.state.gridHeight }
]
_getDeletionView = (key) => {
if (this.state.deleteModeOn)
return <Image style={ this._getImageDeleteIconStyle(key) } source={require('./assets/trash.png')} />
}
_getItemWrapperStyle = (key) => [
{ flex: 1 },
this.state.activeBlock == key
&& this.state.deleteModeOn
&& this._getBlock( key ).origin
&&
{ opacity: 1.5 - this._getDynamicOpacity(key) }
]
_getImageDeleteIconStyle = (key) => [
{ position: 'absolute',
top: this.state.blockWidth/2 - 15,
left: this.state.blockWidth/2 - 15,
width: 30,
height: 30,
opacity: .5
},
this.state.activeBlock == key
&& this._getBlock( key ).origin
&&
{ opacity: .5 + this._getDynamicOpacity(key) }
]
_getDynamicOpacity = (key) =>
( this._getBlock( key ).currentPosition.y._value
+ this._getBlock( key ).currentPosition.y._offset
- this._getBlock( key ).origin.y
) / 50
_getBlockStyle = (key) => [
{ width: this.state.blockWidth,
height: this.state.blockWidth,
justifyContent: 'center' },
this._blockPositionsSet() && (this.initialDragDone || this.state.deleteModeOn) &&
{ position: 'absolute',
top: this._getBlock(key).currentPosition.getLayout().top,
left: this._getBlock(key).currentPosition.getLayout().left
},
this.state.activeBlock == key && this._blockActivationWiggle(),
this.state.activeBlock == key && { zIndex: 1 },
this.state.deleteBlock != null && { zIndex: 2 },
this.state.deleteBlock == key && { opacity: this.state.deleteBlockOpacity },
this.state.deletedItems.indexOf(key) !== -1 && styles.deletedBlock
]
}
const styles = StyleSheet.create(
{
sortableGrid: {
flexDirection: 'row',
flexWrap: 'wrap'
},
deletedBlock: {
opacity: 0,
position: 'absolute',
left: 0,
top: 0,
height: 0,
width: 0
},
itemImageContainer: {
flex: 1,
justifyContent: 'center'
}
})
module.exports = SortableGrid