/** * react-native-draggable-gridview */ import React, { memo, useRef, useState, useCallback } from 'react' import { Dimensions, LayoutRectangle } from 'react-native' import { View, ViewStyle, TouchableOpacity } from 'react-native' import { Animated, Easing, EasingFunction } from 'react-native' import { ScrollView, ScrollViewProps } from 'react-native' import { PanResponder, PanResponderInstance } from 'react-native' import _ from 'lodash' const { width: screenWidth } = Dimensions.get('window') interface GridViewProps extends ScrollViewProps { numColumns?: number containerMargin?: ContainerMargin width?: number data: any[] activeOpacity?: number delayLongPress?: number selectedStyle?: ViewStyle animationConfig?: AnimationConfig keyExtractor?: (item: any) => string renderItem: (item: any, index?: number) => JSX.Element renderLockedItem?: (item: any, index?: number) => JSX.Element locked?: (item: any, index?: number) => boolean onBeginDragging?: () => void onPressCell?: (item: any, index?: number) => void onReleaseCell?: (data: any[]) => void onEndAddAnimation?: (item: any) => void onEndDeleteAnimation?: (item: any) => void } interface AnimationConfig { isInteraction?: boolean useNativeDriver: boolean easing?: EasingFunction duration?: number delay?: number } interface ContainerMargin { top?: number bottom?: number left?: number right?: number } interface Point { x: number y: number } interface Item { item: any pos: Animated.ValueXY opacity: Animated.Value } interface State { scrollView?: ScrollView frame?: LayoutRectangle contentOffset: number numRows?: number cellSize?: number grid: Point[] items: Item[] animation?: Animated.CompositeAnimation animationId?: number // Callback ID for requestAnimationFrame startPoint?: Point // Starting position when dragging startPointOffset?: number // Offset for the starting point for scrolling move?: number // The position for dragging panResponder?: PanResponderInstance } const GridView = memo((props: GridViewProps) => { const { data, keyExtractor, renderItem, renderLockedItem, locked, onBeginDragging, onPressCell, onReleaseCell, onEndAddAnimation, onEndDeleteAnimation, ...rest } = props const numColumns = rest.numColumns || 1 const top = rest.containerMargin?.top || 0 const bottom = rest.containerMargin?.bottom || 0 const left = rest.containerMargin?.left || 0 const right = rest.containerMargin?.right || 0 const width = rest.width || screenWidth const activeOpacity = rest.activeOpacity || 0.5 const delayLongPress = rest.delayLongPress || 500 const selectedStyle = rest.selectedStyle || { shadowColor: '#000', shadowRadius: 8, shadowOpacity: 0.2, elevation: 10, } const [selectedItem, setSelectedItem] = useState(null) const self = useRef({ contentOffset: 0, grid: [], items: [], startPointOffset: 0, }).current //-------------------------------------------------- Preparing const prepare = useCallback(() => { if (!data) return // console.log('[GridView] prepare') const diff = data.length - self.grid.length if (Math.abs(diff) == 1) { prepareAnimations(diff) } else if (diff != 0) { onUpdateGrid() } else if ( _.findIndex(self.items, (v: Item, i: number) => v.item != data[i]) >= 0 ) { onUpdateData() } }, [data, selectedItem]) const onUpdateGrid = useCallback(() => { // console.log('[GridView] onUpdateGrid') const cellSize = (width - left - right) / numColumns self.cellSize = cellSize self.numRows = Math.ceil(data.length / numColumns) const grid: Point[] = [] for (let i = 0; i < data.length; i++) { const x = (i % numColumns) * cellSize const y = Math.floor(i / numColumns) * cellSize grid.push({ x, y }) } self.grid = grid onUpdateData() }, [data, selectedItem]) const onUpdateData = useCallback(() => { // console.log('[GridView] onUpdateData') // Stop animation stopAnimation() const { grid } = self self.items = data.map((item, i) => { const pos = new Animated.ValueXY(grid[i]) const opacity = new Animated.Value(1) const item0: Item = { item, pos, opacity } // While dragging if (selectedItem && selectedItem.item == item) { const { x: x0, y: y0 } = selectedItem.pos const x = x0['_value'] const y = y0['_value'] if (!self.animation) pos.setValue({ x, y }) selectedItem.item = item selectedItem.pos = pos selectedItem.opacity = opacity self.startPoint = { x, y } } return item0 }) }, [data, selectedItem]) const prepareAnimations = useCallback( (diff: number) => { const config = rest.animationConfig || { easing: Easing.ease, duration: 300, useNativeDriver: true, } const grid0 = self.grid const items0 = self.items onUpdateGrid() const { grid, items } = self const diffItem: Item = _.head( _.differenceWith( diff < 0 ? items0 : items, diff < 0 ? items : items0, (v1: Item, v2: Item) => v1.item == v2.item ) ) // console.log('[GridView] diffItem', diffItem) const animations = (diff < 0 ? items0 : items).reduce((prev, curr, i) => { // Ignore while dragging if (selectedItem && curr.item == selectedItem.item) return prev let toValue: { x: number; y: number } if (diff < 0) { // Delete const index = _.findIndex(items, { item: curr.item }) toValue = index < 0 ? grid0[i] : grid[index] if (index < 0) { prev.push(Animated.timing(curr.opacity, { toValue: 0, ...config })) } } else { // Add const index = _.findIndex(items0, { item: curr.item }) if (index >= 0) curr.pos.setValue(grid0[index]) toValue = grid[i] if (diffItem.item == curr.item) { curr.opacity.setValue(0) prev.push(Animated.timing(curr.opacity, { toValue: 1, ...config })) } } // Animation for position prev.push(Animated.timing(curr.pos, { toValue, ...config })) return prev }, []) if (diff < 0) { self.items = items0 self.grid = grid0 } // Stop animation stopAnimation() self.animation = Animated.parallel(animations) self.animation.start(() => { // console.log('[Gird] end animation') self.animation = undefined if (diff < 0) { self.items = items self.grid = grid onEndDeleteAnimation && onEndDeleteAnimation(diffItem.item) } else { onEndAddAnimation && onEndAddAnimation(diffItem.item) } }) }, [data, selectedItem] ) const stopAnimation = useCallback(() => { if (self.animation) { self.animation.stop() self.animation = undefined } }, []) prepare() //-------------------------------------------------- Handller const onLayout = useCallback( ({ nativeEvent: { layout }, }: { nativeEvent: { layout: LayoutRectangle } }) => (self.frame = layout), [] ) const animate = useCallback(() => { if (!selectedItem) return const { move, frame, cellSize } = self const s = cellSize / 2 let a = 0 if (move < top + s) { a = Math.max(-s, move - (top + s)) // above } else if (move > frame.height - bottom - s) { a = Math.min(s, move - (frame.height - bottom - s)) // below } a && scroll((a / s) * 10) // scrolling self.animationId = requestAnimationFrame(animate) }, [selectedItem]) const scroll = useCallback( (offset: number) => { const { scrollView, cellSize, numRows, frame, contentOffset } = self const max = cellSize * numRows - frame.height + top + bottom const offY = Math.max(0, Math.min(max, contentOffset + offset)) const diff = offY - contentOffset if (Math.abs(diff) > 0.2) { // Set offset for the starting point of dragging self.startPointOffset += diff // Move the dragging cell const { x: x0, y: y0 } = selectedItem.pos const x = x0['_value'] const y = y0['_value'] + diff selectedItem.pos.setValue({ x, y }) reorder(x, y) scrollView.scrollTo({ y: offY, animated: false }) } }, [selectedItem] ) const onScroll = useCallback( ({ nativeEvent: { contentOffset: { y }, }, }: { nativeEvent: { contentOffset: { y: number } } }) => (self.contentOffset = y), [] ) const onLongPress = useCallback( (item: string, index: number, position: Point) => { if (self.animation) return // console.log('[GridView] onLongPress', item, index) self.startPoint = position self.startPointOffset = 0 setSelectedItem(self.items[index]) onBeginDragging && onBeginDragging() }, [onBeginDragging] ) const reorder = useCallback( (x: number, y: number) => { if (self.animation) return const { numRows, cellSize, grid, items } = self let colum = Math.floor((x + cellSize / 2) / cellSize) colum = Math.max(0, Math.min(numColumns, colum)) let row = Math.floor((y + cellSize / 2) / cellSize) row = Math.max(0, Math.min(numRows, row)) const index = Math.min(items.length - 1, colum + row * numColumns) const isLocked = locked && locked(items[index].item, index) const itemIndex = _.findIndex(items, (v) => v.item == selectedItem.item) if (isLocked || itemIndex == index) return swap(items, index, itemIndex) const animations = items.reduce((prev, curr, i) => { index != i && prev.push( Animated.timing(curr.pos, { toValue: grid[i], easing: Easing.ease, duration: 200, useNativeDriver: true, }) ) return prev }, [] as Animated.CompositeAnimation[]) self.animation = Animated.parallel(animations) self.animation.start(() => (self.animation = undefined)) }, [selectedItem] ) //-------------------------------------------------- PanResponder const onMoveShouldSetPanResponder = useCallback((): boolean => { if (!self.startPoint) return false const shoudSet = selectedItem != null if (shoudSet) { // console.log('[GridView] onMoveShouldSetPanResponder animate') animate() } return shoudSet }, [selectedItem]) const onMove = useCallback( (event, { moveY, dx, dy }: { moveY: number; dx: number; dy: number }) => { const { startPoint, startPointOffset, frame } = self self.move = moveY - frame.y let { x, y } = startPoint // console.log('[GridView] onMove', dx, dy, moveY, x, y) x += dx y += dy + startPointOffset selectedItem.pos.setValue({ x, y }) reorder(x, y) }, [selectedItem] ) const onRelease = useCallback(() => { if (!self.startPoint) return // console.log('[GridView] onRelease') cancelAnimationFrame(self.animationId) self.animationId = undefined self.startPoint = undefined const { grid, items } = self const itemIndex = _.findIndex(items, (v) => v.item == selectedItem.item) itemIndex >= 0 && Animated.timing(selectedItem.pos, { toValue: grid[itemIndex], easing: Easing.out(Easing.quad), duration: 200, useNativeDriver: true, }).start(onEndRelease) }, [selectedItem]) const onEndRelease = useCallback(() => { // console.log('[GridView] onEndRelease') onReleaseCell && onReleaseCell(self.items.map((v) => v.item)) setSelectedItem(undefined) }, [onReleaseCell]) //-------------------------------------------------- Render const _renderItem = useCallback( (value: Item, index: number) => { // Update pan responder if (index == 0) { self.panResponder = PanResponder.create({ onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponderCapture: () => false, onMoveShouldSetPanResponder: onMoveShouldSetPanResponder, onMoveShouldSetPanResponderCapture: onMoveShouldSetPanResponder, onShouldBlockNativeResponder: () => false, onPanResponderTerminationRequest: () => false, onPanResponderMove: onMove, onPanResponderRelease: onRelease, onPanResponderEnd: onRelease, }) } const { item, pos, opacity } = value // console.log('[GridView] renderItem', index, id) const { cellSize, grid } = self const p = grid[index] const isLocked = locked && locked(item, index) const key = (keyExtractor && keyExtractor(item)) || (typeof item == 'string' ? item : `${index}`) let style: ViewStyle = { position: 'absolute', width: cellSize, height: cellSize, } if (!isLocked && selectedItem && value.item == selectedItem.item) style = { zIndex: 1, ...style, ...selectedStyle } return isLocked ? ( {renderLockedItem(item, index)} ) : ( onLongPress(item, index, p)} onPress={() => onPressCell && onPressCell(item, index)} > {renderItem(item, index)} ) }, [selectedItem, renderLockedItem, renderItem] ) // console.log('[GridView] render', data.length) return ( (self.scrollView = ref)} onLayout={onLayout} onScroll={onScroll} scrollEnabled={!selectedItem} scrollEventThrottle={16} contentContainerStyle={{ marginTop: top, marginBottom: bottom, marginLeft: left, marginRight: right, }} > {self.items.map((v, i) => _renderItem(v, i))} ) }) /** * swap * @param array * @param i * @param j */ const swap = (array: any[], i: number, j: number) => array.splice(j, 1, array.splice(i, 1, array[j])[0]) export default GridView