Created
December 1, 2021 01:16
-
-
Save dive2Pro/1122df0d25e68e7af64a0f0c6d436084 to your computer and use it in GitHub Desktop.
给 flatlist 添加左滑&drag&drop 功能
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { createContext, Fragment, useContext, useEffect, useReducer, useRef, useState } from 'react'; | |
import { | |
findNodeHandle, | |
TouchableHighlight, | |
Animated, | |
Button, | |
UIManager, | |
StyleSheet, | |
Text, | |
View, | |
TextInput, | |
PanResponder, | |
FlatList, | |
Easing, | |
TouchableOpacity | |
} from 'react-native'; | |
import _ from 'lodash'; | |
import NativeUtil from '../lib/native'; | |
import { Page, RadioButton, Row } from '../baseComponents'; | |
const PubSub = { | |
subs: {}, | |
sub(name, cb) { | |
if (!this.subs[name]) { | |
this.subs[name] = []; | |
} | |
const index = this.subs[name].push(cb); | |
return () => { | |
if (this.subs[name]) this.subs[name].splice(index, 1); | |
}; | |
}, | |
emit(name, value) { | |
if (this.subs[name]) { | |
console.log('emit', name); | |
this.subs[name].forEach(cb => cb(value)); | |
} | |
if (this.subs['*']) { | |
this.subs['*'].forEach(cb => { | |
cb(value); | |
}); | |
} | |
} | |
}; | |
class Todo { | |
status = 'OPEN'; | |
title = ''; | |
desc = ''; | |
due = null; | |
images = []; | |
sync = -1; // -1 未保存; 0, 保存中; 1 已保存 | |
index = 0; | |
/** | |
* | |
* @param {TodoManager} store | |
*/ | |
constructor(manager) { | |
this.manager = manager; | |
} | |
isOpen() { | |
return this.status === 'OPEN'; | |
} | |
isFinish() { | |
return this.status === 'FINISH'; | |
} | |
_update() { | |
this.manager.update(this); | |
} | |
updateTitle(title) { | |
this.title = title; | |
this._update(); | |
} | |
updateDesc(desc) { | |
this.desc = desc; | |
this._update(); | |
} | |
toggle() { | |
this.status = this.status === 'FINISH' ? 'OPEN' : 'FINISH'; | |
this._update(); | |
} | |
setDue(dueDate) { | |
this.due = dueDate; | |
this._update(); | |
} | |
updateImages(images) { | |
this.images = images; | |
this._update(); | |
} | |
remove() { | |
this.manager.delete(this); | |
} | |
toString() { | |
return { | |
status: this.status, | |
desc: this.desc, | |
title: this.title, | |
images: this.images, | |
sync: this.sync, | |
index: this.index | |
}; | |
} | |
} | |
const EventName = { | |
// 其实可以换成 list 和 item 两个就能够满足了。 | |
// 在 hooks 中 listContainer 只关心 list 什么时候更新,更新了什么会在 render 中反映出来 | |
refreshing: 'refreshing', | |
delete: 'delete', | |
add: 'add', | |
loadMore: 'loadMore', | |
item: id => 'item' + id | |
}; | |
function useForceUpdate() { | |
return useReducer(i => i + 1, 0)[1]; | |
} | |
function useTodoItem(propTodo) { | |
const forceUpdate = useForceUpdate(); | |
useEffect( | |
() => { | |
const unSub = PubSub.sub(EventName.item(propTodo.id), todo => { | |
forceUpdate(); | |
}); | |
return unSub; | |
}, | |
[propTodo.id] | |
); | |
// console.log(propTodo.toString(), ' --- '); | |
return { todo: propTodo }; | |
} | |
function useCbState(initOrFn) { | |
const ref = useRef(initOrFn); | |
const [, _setState] = useState(initOrFn); | |
const setState = initOrFn => { | |
_setState(prev => { | |
let result = initOrFn; | |
if (typeof initOrFn === 'function') { | |
result = initOrFn(prev); | |
} | |
ref.current = result; | |
return result; | |
}); | |
}; | |
return [() => ref.current, setState]; | |
} | |
function Swiper(props) { | |
const [rightActionLayout, setRightActionLayout] = useCbState({}); | |
const pan = useRef(new Animated.ValueXY()).current; | |
const [rightOpen, setRightOpen] = useCbState(false); | |
const [isSwip, setIsSwip] = useState(false); | |
const xyValueRef = useRef({ | |
x: 0, | |
y: 0 | |
}); | |
useEffect( | |
() => { | |
props.onSwipper && props.onSwipper(rightOpen() || isSwip); | |
}, | |
[isSwip, rightOpen()] | |
); | |
const panResponder = useRef( | |
PanResponder.create({ | |
onStartShouldSetPanResponderCapture: (evt, gestureState) => { | |
return Math.abs(gestureState.dx) > 5; | |
}, | |
onMoveShouldSetPanResponderCapture: (evt, gestureState) => { | |
return Math.abs(gestureState.dx) > 5; | |
}, | |
onMoveShouldSetPanResponder: (evt, gesture) => { | |
// 只有在向左滑动的时候 | |
console.log(evt, ' move should ,'); | |
return false; | |
}, | |
onPanResponderGrant: () => { | |
pan.setOffset({ | |
x: xyValueRef.current.x | |
}); | |
pan.setValue({ | |
x: 0, | |
y: 0 | |
}); | |
}, | |
onPanResponderMove: (evt, gesture) => { | |
if (!rightOpen() && gesture.dx > 0) { | |
return; | |
} | |
setIsSwip(true); | |
pan.setValue({ | |
x: gesture.dx | |
}); | |
}, | |
onPanResponderRelease: (evt, gestureState) => { | |
setIsSwip(false); | |
// 当释放的时候,要对 | |
console.log('release: !', pan.x._value, rightOpen()); | |
// 向左滑 | |
if (gestureState.dx < 0) { | |
// 关闭状态 | |
if (!rightOpen()) { | |
if (xyValueRef.current.x < rightActionLayout().width * -0.3) { | |
// 打开 rightActions | |
Animated.spring(pan, { | |
toValue: { x: rightActionLayout().width * -1, y: 0 } | |
}).start(({ finished }) => { | |
finished && pan.flattenOffset(); | |
}); | |
setRightOpen(true); | |
return; | |
} | |
} | |
if (xyValueRef.current.x <= rightActionLayout().width * -1) { | |
close(); | |
return; | |
} | |
} | |
pan.flattenOffset(); | |
Animated.spring(pan, { | |
toValue: { x: 0, y: 0 } | |
}).start(); | |
setRightOpen(false); | |
}, | |
onPanResponderReject: evt => { | |
// console.log(evt, ' = evnt '); | |
} | |
}) | |
).current; | |
useEffect( | |
() => { | |
pan.addListener(xy => { | |
xyValueRef.current = xy; | |
}); | |
}, | |
[pan] | |
); | |
const close = () => { | |
Animated.spring(pan, { | |
toValue: { x: 0, y: 0 } | |
}).start(({ finished }) => { | |
finished && pan.flattenOffset(); | |
}); | |
}; | |
const passenger = { | |
hide: () => { | |
close(); | |
setRightOpen(false); | |
} | |
}; | |
return ( | |
<View> | |
<TouchableHighlight | |
style={{ | |
position: 'absolute', | |
right: 0, | |
top: 0, | |
bottom: 0, | |
zIndex: -1 | |
}} | |
onPress={() => { | |
console.log('touch Press'); | |
}} | |
onLayout={({ nativeEvent: { layout } }) => { | |
setRightActionLayout(layout); | |
}} | |
> | |
{props.rightActions && props.rightActions(passenger)} | |
</TouchableHighlight> | |
<Animated.View | |
style={{ | |
transform: [{ translateX: pan.x }], | |
zIndex: 1, | |
backgroundColor: 'white' | |
}} | |
{...panResponder.panHandlers} | |
> | |
{props.children} | |
</Animated.View> | |
</View> | |
); | |
} | |
function TodoItem(props) { | |
const { todo } = useTodoItem(props.todo); | |
return ( | |
<Swiper | |
onSwipper={swipe => { | |
props.onSwipe(swipe); | |
}} | |
rightActions={action => ( | |
<Row> | |
<Text>删除</Text> | |
<Text | |
onPress={() => { | |
console.log(' click hello world'); | |
action.hide(); | |
}} | |
> | |
Hello World! | |
</Text> | |
</Row> | |
)} | |
> | |
<DropObj> | |
{trigger => { | |
return ( | |
<TouchableOpacity | |
minDuration={800} | |
onLongPress={() => { | |
props.drag(); | |
}} | |
style={{ | |
flexDirection: 'row', | |
alignItems: 'center', | |
padding: 10, | |
borderBottomWidth: 0.5, | |
borderBottomColor: 'red' | |
}} | |
> | |
<RadioButton | |
status={!todo.isOpen() ? 'checked' : ''} | |
onPress={() => { | |
todo.toggle(); | |
console.log(' toggle '); | |
trigger(); | |
}} | |
/> | |
<View style={{}}> | |
<TextInput | |
onChangeText={text => { | |
console.log(text, ' tesxt '); | |
todo.updateTitle(text); | |
}} | |
style={{ fontSize: 18 }} | |
value={todo.title} | |
placeholder="例如: " | |
/> | |
<Text>{todo.desc}</Text> | |
{todo.images.length ? <Row /> : <Text />} | |
<Text>{todo.due ? todo.due : '设置时间'}</Text> | |
<Text>{todo.sync}</Text> | |
</View> | |
</TouchableOpacity> | |
); | |
}} | |
</DropObj> | |
</Swiper> | |
); | |
} | |
const DB = { | |
ids: new Set(), | |
async load() { | |
const keys = await Storage.loadMasterData('DB_KEYS'); | |
if (keys) { | |
this.ids = new Set(JSON.parse(keys)); | |
} else { | |
this.ids = new Set(); | |
} | |
const result = []; | |
for await (const id of this.ids) { | |
result.push(await Storage.loadMasterData(id)); | |
} | |
return result; | |
}, | |
save(obj) { | |
console.log(obj, 'save', obj.toString()); | |
// localStorage.setItem(obj.id, obj); | |
this.ids.add(obj.id); | |
Storage.saveMasterData('DB_KEYS', this.ids); | |
Storage.saveMasterData(obj.id, obj.toString()); | |
}, | |
remove(obj) { | |
console.log(obj, 'remove', this.ids); | |
this.ids.delete(obj.id); | |
// | |
// localStorage.removeItem(obj.id); | |
Storage.saveMasterData('DB_KEYS', this.ids); | |
Storage.clearMasterData(obj.id, obj); | |
}, | |
clean() { | |
// localStorage.clear(); | |
// Storage.remove | |
Storage.clearMasterData('DB_KEYS'); | |
this.ids.forEach(id => { | |
Storage.clearMasterData(id); | |
}); | |
} | |
}; | |
function TodoList(props) { | |
const ref = React.useRef(); | |
if (!ref.current) { | |
ref.current = new TodoManager(new TodoStore(DB)); | |
} | |
const manager = ref.current; | |
const forceUpdate = React.useReducer(i => i + 1, 0)[1]; | |
useEffect(() => { | |
[EventName.delete, EventName.add, EventName.refreshing, EventName.loadMore].forEach(name => { | |
PubSub.sub(name, () => { | |
forceUpdate(); | |
}); | |
}); | |
return () => { | |
PubSub.subs = []; | |
}; | |
}, []); | |
const [isSwipe, setSwipe] = useState(new Set()); | |
// console.log('isSwipe = ', isSwipe); | |
return ( | |
<View style={{ flex: 1 }}> | |
<DragAndDropFlatList | |
data={manager.todos} | |
renderItem={({ item, drag, dragEnd }) => { | |
return ( | |
<TodoItem | |
onSwipe={swipping => | |
setSwipe(prev => { | |
if (swipping) { | |
prev.add(item.id); | |
} else { | |
prev.delete(item.id); | |
} | |
return new Set(prev); | |
}) | |
} | |
drag={isSwipe.size ? null : drag} | |
dragEnd={dragEnd} | |
key={item.id} | |
todo={item} | |
/> | |
); | |
}} | |
refreshing={manager.refreshing} | |
onRefresh={() => manager.init()} | |
keyExtractor={(item, index) => { | |
return item.id; | |
}} | |
enableMJLoadMore={true} | |
mjLoadingMore={manager.loadingMore} | |
onMJLoadMore={() => manager.loadMore()} | |
/> | |
<Button | |
onPress={() => { | |
manager.add(); | |
}} | |
title="Example button" | |
/> | |
</View> | |
); | |
} | |
function Spacer(props) { | |
useEffect( | |
() => { | |
if (props.open) { | |
Animated.timing(h, { | |
duration: 150, | |
toValue: props.h | |
}).start(); | |
} else { | |
Animated.timing(h, { | |
toValue: 0, | |
duration: 150 | |
}).start(); | |
// h.setValue(0); | |
} | |
return () => { | |
h.stopAnimation(); | |
}; | |
}, | |
[props.open] | |
); | |
// useInterval(() => { | |
// console.log('interval ---') | |
// } , 1000) | |
// | |
// useEffect(() => { | |
// console.log('Mounted'); | |
// }, []); | |
const h = useRef(new Animated.Value(props.open ? props.h : 0)).current; | |
return ( | |
<Animated.View style={{ height: h }}> | |
{props.children} | |
<Text>{props.index} = index</Text> | |
</Animated.View> | |
); | |
} | |
function DragAndDropFlatList(props) { | |
const flRef = useRef(); | |
const [, setII] = useState(1); | |
const [hoverComponent, setHoverComponent] = useCbState(); | |
const [dragging, setDragging] = useCbState(false); | |
const [data, setData] = useCbState(props.data); | |
useEffect( | |
() => { | |
setData(props.data); | |
}, | |
[props.data] | |
); | |
const drag = (item, index) => { | |
return event => { | |
const comp = props.renderItem({ | |
item, | |
drag: () => {} | |
}); | |
heightRef.current = _positions[index].height; | |
setHoverComponent({ | |
comp, | |
item, | |
index | |
}); | |
}; | |
}; | |
// TODO 计算 spacer 出现的位置 | |
const _positions = useRef([]).current; | |
const _spacerOpens = useRef([]).current; | |
const initPositions = () => { | |
if (!props.data.every((item, i) => _positions[i])) { | |
return; | |
} | |
if (!flRef.current.layout) { | |
return; | |
} | |
_positions[0].y = 0; | |
for (let i = 1; i < _positions.length; i++) { | |
_positions[i].y = _positions[i - 1].y + _positions[i - 1].height; | |
} | |
}; | |
const renderPlaceHolder = hc => { | |
return ( | |
<View | |
style={{ | |
backgroundColor: 'lightgreen', | |
width: _positions[hc.index].width, | |
height: _positions[hc.index].height | |
}} | |
/> | |
); | |
}; | |
const heightRef = useRef(0); | |
const renderItem = ({ item, index }) => { | |
const space1 = ( | |
<Spacer | |
index={-1} | |
key={`${dragging() ? 'true' : 'false'} -1`} | |
open={dragging() && _spacerOpens[-1]} | |
h={index === 0 && _spacerOpens[-1] ? heightRef.current : 0} | |
> | |
{dragging() && renderPlaceHolder(hoverComponent())} | |
</Spacer> | |
); | |
console.log(_spacerOpens[index], ' = open '); | |
const space2 = ( | |
<Spacer | |
index={index} | |
// key 设置为 dragging(),滑动时它会改变,导致 Spacer 重启,而不是更新,这样就不会有动画。 | |
// 关闭时, 也是重启,此时会变成立即消失,配合消失的 dragging 给人一种回弹的感觉 | |
key={`${dragging() ? 'true' : 'false'} ${index}`} | |
open={_spacerOpens[index]} | |
h={heightRef.current} | |
> | |
{dragging() && renderPlaceHolder(hoverComponent())} | |
</Spacer> | |
); | |
return ( | |
<Animated.View | |
onLayout={({ nativeEvent: { layout } }) => { | |
if (dragging()) { | |
return; | |
} | |
_positions[index] = layout; | |
_spacerOpens[index] = false; | |
initPositions(); | |
}} | |
> | |
{space1} | |
<View | |
style={ | |
dragging() && hoverComponent()?.index === index | |
? { | |
position: 'absolute' | |
} | |
: hoverComponent()?.index === index | |
? {} | |
: {} | |
} | |
> | |
{props.renderItem({ item, drag: drag(item, index), dragEnd })} | |
</View> | |
{space2} | |
</Animated.View> | |
); | |
}; | |
const hoverAnim = useRef(new Animated.Value(0)).current; | |
const dragAndDropResponder = useRef( | |
PanResponder.create({ | |
onMoveShouldSetPanResponder: (evt, gesture) => { | |
if (hoverComponent()) { | |
return true; | |
} | |
return false; | |
}, | |
onPanResponderGrant: (evt, gesture) => { | |
dragStart(hoverComponent().index); | |
setDragging(true); | |
}, | |
onPanResponderMove: (evt, gesture) => { | |
// 同时处理 FlatList 的滑动 | |
hoverAnim.setValue(gesture.dy); | |
prevDyRef.current = gesture.dy; | |
anime(gesture.dy, draggingScrollOffsetDiffRef.current); | |
}, | |
onPanResponderReject: evt => false, | |
onPanResponderRelease: evt => { | |
dragEnd(); | |
} | |
}) | |
).current; | |
const forceUpdate = useForceUpdate(); | |
const prevDyRef = useRef(0); | |
// 当 drag&moving 同步滚动 scrollView | |
const scrollViewAnim = (dy, scrollOffset) => { | |
if (!dragging()) { | |
return; | |
} | |
const index = hoverComponent().index; | |
const { y } = _positions[index]; | |
const currentPosition = dy + y + scrollOffset; | |
const scrollSpeed = 100; | |
const scrollViewHeight = flRef.current.layout.height; | |
const scrollOffsetY = contentOffsetRef.current.y; | |
const startRect = [scrollOffsetY, scrollOffsetY + scrollViewHeight * 0.15]; | |
const endRect = [scrollOffsetY + scrollViewHeight * 0.8, scrollOffsetY + scrollViewHeight]; | |
const isInDuration = (y, range) => { | |
return y >= range[0] && y <= range[1]; | |
}; | |
const shouldScrollUp = isInDuration(currentPosition, startRect); | |
const shouldScrollDown = isInDuration(currentPosition, endRect); | |
// 根据 currentPosition 在 rect 中的位置,计算出百分比,百分比 * speed 则为滑动的速度 | |
let result; | |
if (shouldScrollUp) { | |
const percent = (startRect[1] - currentPosition) / startRect[1]; | |
result = scrollSpeed * -1 * percent; | |
console.log(percent, result); | |
} else if (shouldScrollDown) { | |
const percent = (currentPosition - endRect[0]) / (endRect[1] - endRect[0]); | |
result = scrollSpeed * percent; | |
console.log(percent, result); | |
} | |
if (result) { | |
flRef.current.scrollToOffset({ | |
animated: false, | |
offset: contentOffsetRef.current.y + result | |
}); | |
} | |
}; | |
const anime = (dy, scrollOffset = 0) => { | |
const openTo = num => { | |
// console.log(' to num ', num); | |
setII(prev => { | |
if (prev !== num) { | |
resetSpacerOpens(); | |
_spacerOpens[num] = true; | |
} | |
return num; | |
}); | |
}; | |
// console.log('anime = dy = ', dy); | |
scrollViewAnim(dy, scrollOffset); | |
// dy 移动时以 index 如果 dy | |
const index = hoverComponent().index; | |
const { y, height } = _positions[index]; | |
// 聚合成一条线 | |
// 将超过 index 的 positions 都减去一个 height | |
const positions = _.cloneDeep(_positions); | |
for (let i = index + 1; i < positions.length; i++) { | |
positions[i].y -= height; | |
} | |
// 先找到 y + dy 最大的目标 items | |
// 还要加上 dragging 时滑动滚动的距离 | |
let targetIndex = -1; | |
const diff = y + dy + scrollOffset; | |
for (let i = positions.length - 1; i >= 0; i--) { | |
if (diff >= positions[i].y) { | |
targetIndex = i; | |
break; | |
} | |
} | |
if (targetIndex === -1) { | |
return openTo(-1); | |
} | |
// console.log('target = ', targetIndex, positions[targetIndex], ' y + dy = ', y + dy); | |
if (diff > Math.ceil(positions[targetIndex].y + positions[targetIndex].height / 2)) { | |
openTo(targetIndex); | |
} else if (diff > positions[targetIndex].y) { | |
openTo(targetIndex - 1); | |
} | |
}; | |
const dragStart = index => { | |
// offset 要等于 y - flatList 滑动的距离 | |
setII(index); | |
// dragging 起时: | |
_spacerOpens[index] = true; | |
hoverAnim.setOffset(_positions[index].y - contentOffsetRef.current.y); | |
hoverAnim.setValue(0); | |
forceUpdate(); | |
}; | |
const resetSpacerOpens = () => { | |
for (let k in _spacerOpens) { | |
_spacerOpens[k] = false; | |
} | |
}; | |
const drop = () => { | |
// | |
const { index } = hoverComponent(); | |
const opened = _spacerOpens.findIndex(opened => opened); | |
if (index !== opened) { | |
// 移动位置 | |
const oldData = data(); | |
const oldItem = oldData[index]; | |
oldData.splice(index, 1); | |
if (opened <= 1) { | |
oldData.splice(opened + 1, 0, oldItem); | |
} else { | |
if (opened < index) { | |
oldData.splice(opened + 1, 0, oldItem); | |
} else { | |
oldData.splice(opened, 0, oldItem); | |
} | |
} | |
// console.log(opened, index, ' ======'); | |
// console.log(oldData, ' ==== ', data()); | |
setData([...oldData]); | |
props.onChange && props.onChange([...oldData]); | |
} | |
}; | |
const dragEnd = () => { | |
drop(); | |
resetSpacerOpens(); | |
setDragging(false); | |
hoverAnim.flattenOffset(); | |
setHoverComponent(null); | |
draggingScrollOffsetStartRef.current = 0; | |
draggingScrollOffsetDiffRef.current = 0; | |
prevDyRef.current = 0; | |
setII(-1); | |
}; | |
const renderHoverComponent = obj => { | |
const position = _positions[obj.index]; | |
console.log(position, '= posiiton'); | |
return ( | |
<Animated.View | |
style={[ | |
{ | |
position: 'absolute', | |
width: position.width, | |
height: position.height, | |
shadowColor: '#999', | |
shadowOffset: { width: 1, height: -2 }, // 设置阴影偏移,该值会设置整个阴影的偏移,width可以看做x,height可以看做y,x向右为正,y向下为正 | |
shadowOpacity: 1, | |
shadowRadius: 1.5 | |
}, | |
{ | |
transform: [ | |
{ | |
translateY: hoverAnim | |
} | |
] | |
} | |
]} | |
> | |
{obj.comp} | |
</Animated.View> | |
); | |
}; | |
useEffect( | |
() => { | |
if (flRef.current) { | |
setTimeout(() => { | |
UIManager.measure(findNodeHandle(flRef.current), (x, y, width, height, pageX, pageY, ...rest) => { | |
console.log(rest, ' = rest ', height, pageY, y); | |
flRef.current.layout = { | |
pageY, | |
x, | |
y, | |
width, | |
height, | |
pageX | |
}; | |
}); | |
}); | |
} | |
}, | |
[props.data] | |
); | |
const contentOffsetRef = useRef({ y: 0 }); | |
const draggingScrollOffsetStartRef = useRef(0); | |
const draggingScrollOffsetDiffRef = useRef(0); | |
return ( | |
<Animated.View style={{ backgroundColor: 'red' }} {...dragAndDropResponder.panHandlers}> | |
{/* TODO 处理添加了这些元素后 hoverComponent 的位置 */} | |
{/* <Text onPress={() => setII(ii + 1)}>+++</Text> */} | |
{/* <Text onPress={() => setII(ii - 1)}>---</Text> */} | |
<FlatList | |
ref={flRef} | |
onLayout={({ nativeEvent }) => { | |
console.log(JSON.stringify(nativeEvent), ' ---- ', nativeEvent); | |
}} | |
scrollEnabled={!hoverComponent()} | |
onScroll={({ nativeEvent: { contentOffset } }) => { | |
if (dragging() && !draggingScrollOffsetStartRef.current) { | |
draggingScrollOffsetStartRef.current = contentOffset.y; | |
} else if (dragging()) { | |
draggingScrollOffsetDiffRef.current = contentOffset.y - draggingScrollOffsetStartRef.current; | |
anime(prevDyRef.current, draggingScrollOffsetDiffRef.current); | |
} | |
contentOffsetRef.current = contentOffset; | |
console.log('onscroll -> ', contentOffset); | |
}} | |
{...props} | |
data={data()} | |
renderItem={renderItem} | |
refreshing={dragging() ? false : props.refreshing} | |
enableMJLoadMore={dragging() ? false : props.enableMJLoadMore} | |
/> | |
{dragging() && renderHoverComponent(hoverComponent())} | |
</Animated.View> | |
); | |
} | |
class TodoManager { | |
refreshing = false; // refreshing | |
loadingMore = false; | |
constructor(store) { | |
this.store = store; | |
this.store.manager = this; | |
this.init(); | |
} | |
async init() { | |
try { | |
// 🟢 | |
// 1. 更新(上拉)之前,要将本地所有未处理的先上传到服务端 | |
// 2. 再将本地的内容清空。本地数据的唯一用处就是用来保证实时性 | |
// 🔴 | |
// 0. 只有在本地无数据的时候,才使用服务端数据初始化 | |
// 1. 本地已经是最新的了,即使刷新,也是以本地的为准 | |
PubSub.emit('refreshing', true); | |
this.refreshing = true; | |
await this.store.refresh(); | |
} catch (e) { | |
console.error('初始化错误', e); | |
} finally { | |
this.refreshing = false; | |
PubSub.emit('refreshing', false); | |
} | |
} | |
/** | |
* | |
* 加载更多 | |
* | |
*/ | |
async loadMore() { | |
try { | |
PubSub.emit(EventName.loadMore, true); | |
this.loadingMore = true; | |
await this.store.loadMore(); | |
} catch (e) { | |
console.error('加载更多错误:🔴', e); | |
} finally { | |
this.loadingMore = false; | |
PubSub.emit(EventName.loadMore, false); | |
} | |
} | |
get todos() { | |
return this.store.getTodos(); | |
} | |
add() { | |
const todo = new Todo(this); | |
todo.id = id++; | |
todo.title = id + ' title '; | |
this.store.add(todo); | |
PubSub.emit(EventName.add, todo); | |
return todo; | |
} | |
/** | |
* | |
* @param {Todo} todo | |
*/ | |
update(todo) { | |
this._save(todo); | |
} | |
/** | |
* | |
* @param {Todo} todo | |
*/ | |
delete(todo) { | |
this._delete(todo); | |
} | |
deleteAll() { | |
this._deleteAll(); | |
} | |
/** | |
* | |
* @param {Todo} todo | |
*/ | |
async _save(todo) { | |
try { | |
todo.sync = 0; | |
PubSub.emit(EventName.item(todo.id), todo); | |
await this.store.update(todo); | |
todo.sync = 1; | |
} catch (e) { | |
todo.sync = -1; | |
} | |
PubSub.emit(EventName.item(todo.id), todo); | |
} | |
async _delete(todo) { | |
await this.store.delete(todo); | |
PubSub.emit(EventName.delete, todo); | |
} | |
async _deleteAll() { | |
await this.store.clean(); | |
PubSub.emit(EventName.delete); | |
} | |
} | |
function fakeUpdateTodo(todo) { | |
return new Promise(resolve => { | |
setTimeout(() => { | |
resolve(); | |
}, 1000); | |
}); | |
} | |
let id = 1; | |
let index = 1; | |
class TodoStore { | |
todos = []; | |
manager = null; // TodoManager | |
constructor(db) { | |
this.db = db; | |
this.update = _.debounce(this.update, 500); | |
} | |
async refresh() { | |
// clean todos | |
// clean database | |
await this._clean(); | |
console.log('refresh'); | |
await this.loadMore(); | |
} | |
async loadMore() { | |
// 写入 db | |
// 写入 todos | |
function fakeLoadMore() { | |
return new Promise(resolve => | |
setTimeout(() => { | |
resolve( | |
new Array(8).fill(0).map(() => ({ | |
id: id++, | |
title: id + 'title', | |
index: index++ | |
})) | |
); | |
}, 200) | |
); | |
} | |
const data = await fakeLoadMore(); | |
console.log('loadmore, ', data); | |
this.todos = this.todos.concat( | |
data.map(item => { | |
const todo = new Todo(this.manager); | |
todo.id = item.id; | |
todo.title = item.title; | |
todo.index = item.index; | |
return todo; | |
}) | |
); | |
} | |
getTodos() { | |
console.log('get todos', this); | |
return this.todos; | |
} | |
/** | |
* | |
* @param {Todo} todo | |
*/ | |
async add(todo) { | |
try { | |
todo.sync = 0; | |
// 异步执行,这里要考虑请求接口的方案了 | |
// 写入 db | |
this.todos.push(todo); | |
this._updateToDB(todo); | |
await fakeUpdateTodo(todo); | |
todo.sync = 1; | |
this._removeFromDB(todo); | |
} catch (e) { | |
console.error('更新 todo 时发生错误:', e); | |
todo.sync = -1; | |
} | |
} | |
/** | |
* | |
* @param {Todo} todo | |
*/ | |
async update(todo) { | |
// 异步执行,这里要考虑请求接口的方案了 | |
// 写入 db | |
this._updateToDB(todo); | |
await fakeUpdateTodo(todo); | |
this._removeFromDB(todo); | |
} | |
async delete(todo) { | |
try { | |
// 异步执行,这里要考虑请求接口的方案了 | |
// 写入 db | |
todo.sync = 0; | |
await fakeUpdateTodo(todo); | |
todo.sync = 1; | |
this._removeFromDB(todo); | |
} catch (e) { | |
console.error('删除 todo 时发生错误:', e); | |
todo.sync = -1; | |
} | |
} | |
async _clean() { | |
await this._sync(); | |
await this._cleanDB(); | |
this.todos = []; | |
} | |
_updateToDB(todo) { | |
this.db.save(todo); | |
} | |
_removeFromDB(todo) { | |
this.db.remove(todo); | |
} | |
async _sync() { | |
// 同步数据到服务端 | |
const allUnSync = await this.db.load(); | |
if (allUnSync.length > 0) { | |
// | |
} | |
for await (const todo of allUnSync) { | |
// 请求接口。更新单条数据 | |
await this.update(todo); | |
} | |
} | |
_cleanDB() { | |
this.db.clean(); | |
} | |
} | |
function App() { | |
useEffect(() => { | |
NativeUtil.hideSplash(); | |
}, []); | |
return ( | |
<Page> | |
<DropToProvider> | |
<View style={styles.header}> | |
<DropToTarget> | |
<Text style={styles.title}>React Native for Web</Text> | |
</DropToTarget> | |
</View> | |
<TodoList /> | |
</DropToProvider> | |
</Page> | |
); | |
} | |
const dropToCtx = createContext(); | |
function DropToProvider(props) { | |
const targetPosition = useRef({ | |
x: 0, | |
y: 0 | |
}); | |
const obj = useRef({ | |
comp: null, | |
coordinate: { | |
width: 0, | |
height: 0 | |
} | |
}); | |
const anim = useRef(new Animated.Value(0)).current; | |
// round anim | |
const opacityInterpolate = anim.interpolate({ | |
inputRange: [0, 1, 2], | |
outputRange: [0, 1, 0] | |
}); | |
const roundInterpolate = anim.interpolate({ | |
inputRange: [0, 1, 2], | |
outputRange: [0, 50, 0], | |
easing: Easing.bounce | |
}); | |
const widthInterpolate = anim.interpolate({ | |
inputRange: [0, 1, 2], | |
outputRange: [obj.current.coordinate.width, 80, 0] | |
}); | |
const heightInterpolate = anim.interpolate({ | |
inputRange: [0, 1, 2], | |
outputRange: [obj.current.coordinate.height, 80, 0] | |
}); | |
const setTarget = (pageX, pageY) => { | |
targetPosition.current = { | |
pageX, | |
pageY | |
}; | |
}; | |
const forceUpdate = useForceUpdate(); | |
const roadToAnim = useRef(new Animated.ValueXY(targetPosition.current.pageX, targetPosition.current.pageY)).current; | |
const start = (comp, coordinate) => { | |
obj.current = { comp, coordinate }; | |
anim.setValue(0); | |
roadToAnim.setValue({ | |
x: coordinate.pageX, | |
y: coordinate.pageY | |
}); | |
shakeAnim.setValue(0); | |
Animated.sequence([ | |
Animated.parallel( | |
// 初始化位置, 变形 | |
[ | |
Animated.timing(roadToAnim, { | |
toValue: { | |
x: 200, | |
y: coordinate.pageY - 50 | |
}, | |
duration: 250 | |
}), | |
Animated.timing(anim, { | |
toValue: 1, | |
duration: 250 | |
}) | |
], | |
{} | |
), | |
// 触发移动动画 | |
Animated.parallel( | |
[ | |
Animated.timing(anim, { toValue: 2, duration: 250 }), | |
Animated.timing(roadToAnim, { | |
toValue: { | |
x: targetPosition.current.pageX + 50, | |
y: targetPosition.current.pageY | |
}, | |
duration: 250 | |
}) | |
], | |
{ | |
stopTogether: true | |
} | |
), | |
Animated.spring(shakeAnim, { toValue: 2, duration: 150 }) | |
]).start(); | |
forceUpdate(); | |
}; | |
const shakeAnim = useRef(new Animated.Value(0)).current; | |
const rotate = useRef( | |
shakeAnim.interpolate({ | |
inputRange: [0, 1, 2], | |
outputRange: ['0deg', '45deg', '0deg'] | |
}) | |
).current; | |
return ( | |
<dropToCtx.Provider | |
value={{ | |
setTarget, | |
start, | |
targetStyle: { | |
transform: [ | |
{ | |
rotate | |
} | |
] | |
} | |
}} | |
> | |
{props.children} | |
<Animated.View | |
style={{ | |
overflow: 'hidden', | |
borderWidth: 1, | |
borderColor: 'red', | |
borderRadius: roundInterpolate, | |
transform: [], | |
opacity: opacityInterpolate, | |
width: widthInterpolate, | |
height: heightInterpolate, | |
position: 'absolute', | |
top: roadToAnim.y, | |
left: roadToAnim.x | |
}} | |
> | |
{obj.current.comp} | |
</Animated.View> | |
</dropToCtx.Provider> | |
); | |
} | |
function useMounted(cb) { | |
useEffect(cb, []); | |
} | |
export function useUnmounted(cb) { | |
useEffect(() => cb, []); | |
} | |
function DropObj(props) { | |
const ctx = useContext(dropToCtx); | |
const ref = useRef(); | |
const trigger = () => { | |
const comp = props.children(); | |
UIManager.measure(findNodeHandle(ref.current), (x, y, width, height, pageX, pageY) => { | |
console.log(pageY, pageX, x, y, ' = x x x x x x'); | |
ctx.start(comp, { pageX, pageY, x, y, width, height }); | |
}); | |
}; | |
return <Fragment>{React.cloneElement(props.children(trigger), { ref })}</Fragment>; | |
} | |
function DropToTarget(props) { | |
const ctx = useContext(dropToCtx); | |
const ref = useRef(); | |
useMounted(() => { | |
// 找到组件的位置 | |
UIManager.measure(findNodeHandle(ref.current), (x, y, width, height, pageX, pageY, ...rest) => { | |
console.log(pageY, pageX); | |
ctx.setTarget(pageX, pageY); | |
}); | |
}); | |
console.log(ctx.targetStyle, ' = '); | |
return ( | |
<Fragment> | |
<Animated.View style={ctx.targetStyle}>{props.children}</Animated.View> | |
<View ref={ref} /> | |
</Fragment> | |
); | |
} | |
const styles = StyleSheet.create({ | |
app: { | |
marginHorizontal: 'auto', | |
maxWidth: 500 | |
}, | |
logo: { | |
height: 80 | |
}, | |
header: { | |
padding: 20 | |
}, | |
title: {}, | |
text: {}, | |
link: { | |
color: '#1B95E0' | |
}, | |
code: { | |
fontFamily: 'monospace, monospace' | |
} | |
}); | |
export default App; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment