0. 背景
react-native-draggable-gridviewを使うことで、ドラッグ可能なリスト形式のビューを表示できる(FlatListにドラッグ機能を追加したようなもの)
しかし、デフォルトではpropsでwidthしか指定できず、セルサイズを正方形でしか表示できない(widthは縦、横両サイズに反映される)
1. 目的
react-native-draggable-gridviewのセルサイズを長方形で表示
→propsでheightを指定できるようにする
2. 環境
- React : 16.8.6
- React Native : 0.63.4
3. 準備
react-native-draggable-gridviewのインストール
npm install react-native-draggable-gridview
react-native-responsive-screenのインストール(端末画面の比率でセルサイズを指定できる)
npm install react-native-responsive-screen
4. ソースコード変更
[プロジェクト名]/node_modules/react-native-draggable-gridview/src/index.tsx
を書き換える( ページ末尾に記載 )
→index.tsxの中身をページ末尾のソースコードで全て書き換える
5. 変更前(デフォルト)
import React from 'react';
import { View, Text, Alert } from 'react-native';
import { useState } from "react";
import GridView from 'react-native-draggable-gridview'
import {
widthPercentageToDP as wp,
heightPercentageToDP as hp,
} from 'react-native-responsive-screen';
function App() {
const [data, setData] = useState([
{ id: 0, name: 'AAA' },
{ id: 1, name: 'BBB' },
{ id: 2, name: 'CCC' },
{ id: 3, name: 'DDD' },
{ id: 4, name: 'EEE' }
])
return (
<>
<GridView
data={data}
numColumns={2}
delayLongPress={150}
// width={wp('80%')}
renderItem={item => (
<View style={{ flex: 1, margin: 1, justifyContent: 'center', backgroundColor: 'cyan' }}>
<Text style={{ textAlign: 'center' }}>{item.name}</Text>
</View>
)}
onPressCell={item => Alert.alert(item.name)}
onReleaseCell={item => setData(item)}
keyExtractor={item => item.id}
/>
</>
)
}
export default App;
numColumns={2}, width指定なし | numColumns={2}, width={wp('80%')} |
セルが正方形で描画される.
widthを指定した場合、縦横両方のサイズに反映される.
6. 変更後
import React from 'react';
import { View, Text, Alert } from 'react-native';
import { useState } from "react";
import GridView from 'react-native-draggable-gridview'
import {
widthPercentageToDP as wp,
heightPercentageToDP as hp,
} from 'react-native-responsive-screen';
function App() {
const [data, setData] = useState([
{ id: 0, name: 'AAA' },
{ id: 1, name: 'BBB' },
{ id: 2, name: 'CCC' },
{ id: 3, name: 'DDD' },
{ id: 4, name: 'EEE' }
])
return (
<>
<GridView
data={data}
numColumns={2}
// numColumns={1}
delayLongPress={150}
height={hp('20%')}
// weight={hp('80%')}
renderItem={(item, index) => (
<View style={{ flex: 1, margin: 1, justifyContent: 'center', backgroundColor: 'cyan' }}>
<Text style={{ textAlign: 'center' }}>{item.name}</Text>
</View>
)}
onPressCell={item => Alert.alert(item.name)}
onReleaseCell={item => setData(item)}
keyExtractor={item => item.id}
/>
</>
)
}
export default App;
numColumns={2}, height={hp('20%')} | numColumns={1}, height={hp('20%')}, weight={hp('80%')} |
heightを設定することで縦方向だけサイズを変更可能.
また、heightと同時にweightも設定可能.
7. ソースコード
/**
* 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, height: screenHeight} = Dimensions.get('window')
interface GridViewProps extends ScrollViewProps {
numColumns?: number
containerMargin?: ContainerMargin
width?: number
height?: 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
cellWidth?: number
cellHeight?: 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 height = rest.height || screenHeight // 追加
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<Item>(null)
const self = useRef<State>({
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 cellWidth = (width - left - right) / numColumns
self.cellWidth = cellWidth
self.cellHeight = height
self.numRows = Math.ceil(data.length / numColumns)
const grid: Point[] = []
for (let i = 0; i < data.length; i++) {
const x = (i % numColumns) * cellWidth
const y = Math.floor(i / numColumns) * height
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, cellWidth } = self
const s = cellWidth / 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, cellWidth, numRows, frame, contentOffset } = self
const max = cellWidth * 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, cellWidth, cellHeight, grid, items } = self
let colum = Math.floor((x + cellWidth / 2) / cellWidth)
colum = Math.max(0, Math.min(numColumns, colum))
let row = Math.floor((y + cellHeight / 2) / cellHeight)
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 { cellWidth, cellHeight, 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: cellWidth,
height: cellHeight,
}
if (!isLocked && selectedItem && value.item == selectedItem.item)
style = { zIndex: 1, ...style, ...selectedStyle }
return isLocked ? (
<View key={key} style={[style, { left: p.x, top: p.y }]}>
{renderLockedItem(item, index)}
</View>
) : (
<Animated.View
{...self.panResponder.panHandlers}
key={key}
style={[
style,
{
transform: pos.getTranslateTransform(),
opacity,
},
]}
>
<TouchableOpacity
style={{ flex: 1 }}
activeOpacity={activeOpacity}
delayLongPress={delayLongPress}
onLongPress={() => onLongPress(item, index, p)}
onPress={() => onPressCell && onPressCell(item, index)}
>
{renderItem(item, index)}
</TouchableOpacity>
</Animated.View>
)
},
[selectedItem, renderLockedItem, renderItem]
)
// console.log('[GridView] render', data.length)
return (
<ScrollView
{...rest}
ref={(ref) => (self.scrollView = ref)}
onLayout={onLayout}
onScroll={onScroll}
scrollEnabled={!selectedItem}
scrollEventThrottle={16}
contentContainerStyle={{
marginTop: top,
marginBottom: bottom,
marginLeft: left,
marginRight: right,
}}
>
<View
style={{
height: top + self.numRows * self.cellHeight + bottom,
}}
/>
{self.items.map((v, i) => _renderItem(v, i))}
</ScrollView>
)
})
/**
* 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