Last active
November 2, 2024 23:36
-
-
Save eveningkid/00dc171095eb6d64f45afdbaa50a76c3 to your computer and use it in GitHub Desktop.
React Native Reanimated 2 Multiple Drag and Sort: Apple Music Example
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
// Expo SDK40 | |
// expo-blur: ~8.2.2 | |
// expo-haptics: ~8.4.0 | |
// react-native-gesture-handler: ~1.8.0 | |
// react-native-reanimated: ^2.0.0-rc.0 | |
// react-native-safe-area-context: 3.1.9 | |
import React, { useState } from 'react'; | |
import { | |
Image, | |
Platform, | |
StatusBar, | |
Text, | |
useWindowDimensions, | |
View, | |
} from 'react-native'; | |
import { | |
SafeAreaProvider, | |
SafeAreaView, | |
useSafeAreaInsets, | |
} from 'react-native-safe-area-context'; | |
import Animated, { | |
cancelAnimation, | |
runOnJS, | |
scrollTo, | |
useAnimatedGestureHandler, | |
useAnimatedReaction, | |
useAnimatedRef, | |
useAnimatedScrollHandler, | |
useAnimatedStyle, | |
useSharedValue, | |
withSpring, | |
withTiming, | |
} from 'react-native-reanimated'; | |
import { PanGestureHandler } from 'react-native-gesture-handler'; | |
import * as Haptics from 'expo-haptics'; | |
import { BlurView } from 'expo-blur'; | |
function clamp(value, lowerBound, upperBound) { | |
'worklet'; | |
return Math.max(lowerBound, Math.min(value, upperBound)); | |
} | |
function shuffle(array) { | |
let counter = array.length; | |
while (counter > 0) { | |
let index = Math.floor(Math.random() * counter); | |
counter--; | |
let temp = array[counter]; | |
array[counter] = array[index]; | |
array[index] = temp; | |
} | |
return array; | |
} | |
function objectMove(object, from, to) { | |
'worklet'; | |
const newObject = Object.assign({}, object); | |
for (const id in object) { | |
if (object[id] === from) { | |
newObject[id] = to; | |
} | |
if (object[id] === to) { | |
newObject[id] = from; | |
} | |
} | |
return newObject; | |
} | |
function listToObject(list) { | |
const values = Object.values(list); | |
const object = {}; | |
for (let i = 0; i < values.length; i++) { | |
object[values[i].id] = i; | |
} | |
return object; | |
} | |
const ALBUM_COVERS = { | |
DISCOVERY: | |
'https://upload.wikimedia.org/wikipedia/en/a/ae/Daft_Punk_-_Discovery.jpg', | |
HUMAN_AFTER_ALL: | |
'https://upload.wikimedia.org/wikipedia/en/0/0d/Humanafterall.jpg', | |
HOMEWORK: | |
'https://upload.wikimedia.org/wikipedia/en/9/9c/Daftpunk-homework.jpg', | |
RANDOM_ACCESS_MEMORIES: | |
'https://upload.wikimedia.org/wikipedia/en/a/a7/Random_Access_Memories.jpg', | |
}; | |
const DAFT_PUNK = 'Daft Punk'; | |
const SONGS = shuffle([ | |
{ | |
id: 'one-more-time', | |
title: 'One More Time', | |
artist: DAFT_PUNK, | |
cover: ALBUM_COVERS.DISCOVERY, | |
}, | |
{ | |
id: 'digital-love', | |
title: 'Digital Love', | |
artist: DAFT_PUNK, | |
cover: ALBUM_COVERS.DISCOVERY, | |
}, | |
{ | |
id: 'nightvision', | |
title: 'Nightvision', | |
artist: DAFT_PUNK, | |
cover: ALBUM_COVERS.DISCOVERY, | |
}, | |
{ | |
id: 'something-about-us', | |
title: 'Something About Us', | |
artist: DAFT_PUNK, | |
cover: ALBUM_COVERS.DISCOVERY, | |
}, | |
{ | |
id: 'veridis-quo', | |
title: 'Veridis Quo', | |
artist: DAFT_PUNK, | |
cover: ALBUM_COVERS.DISCOVERY, | |
}, | |
{ | |
id: 'make-love', | |
title: 'Make Love', | |
artist: DAFT_PUNK, | |
cover: ALBUM_COVERS.HUMAN_AFTER_ALL, | |
}, | |
{ | |
id: 'television-rules-the-nation', | |
title: 'Television Rules the Nation', | |
artist: DAFT_PUNK, | |
cover: ALBUM_COVERS.HUMAN_AFTER_ALL, | |
}, | |
{ | |
id: 'phoenix', | |
title: 'Phoenix', | |
artist: DAFT_PUNK, | |
cover: ALBUM_COVERS.HOMEWORK, | |
}, | |
{ | |
id: 'revolution-909', | |
title: 'Revolution 909', | |
artist: DAFT_PUNK, | |
cover: ALBUM_COVERS.HOMEWORK, | |
}, | |
{ | |
id: 'around-the-world', | |
title: 'Around the World', | |
artist: DAFT_PUNK, | |
cover: ALBUM_COVERS.HOMEWORK, | |
}, | |
{ | |
id: 'within', | |
title: 'Within', | |
artist: DAFT_PUNK, | |
cover: ALBUM_COVERS.RANDOM_ACCESS_MEMORIES, | |
}, | |
{ | |
id: 'touch', | |
title: 'Touch (feat. Paul Williams)', | |
artist: DAFT_PUNK, | |
cover: ALBUM_COVERS.RANDOM_ACCESS_MEMORIES, | |
}, | |
{ | |
id: 'beyond', | |
title: 'Beyond', | |
artist: DAFT_PUNK, | |
cover: ALBUM_COVERS.RANDOM_ACCESS_MEMORIES, | |
}, | |
{ | |
id: 'motherboard', | |
title: 'Motherboard', | |
artist: DAFT_PUNK, | |
cover: ALBUM_COVERS.RANDOM_ACCESS_MEMORIES, | |
}, | |
]); | |
const SONG_HEIGHT = 70; | |
const SCROLL_HEIGHT_THRESHOLD = SONG_HEIGHT; | |
function Song({ artist, cover, title }) { | |
return ( | |
<View | |
style={{ | |
flexDirection: 'row', | |
alignItems: 'center', | |
height: SONG_HEIGHT, | |
padding: 10, | |
}} | |
> | |
<Image | |
source={{ uri: cover }} | |
style={{ height: 50, width: 50, borderRadius: 4 }} | |
/> | |
<View | |
style={{ | |
marginLeft: 10, | |
}} | |
> | |
<Text | |
style={{ | |
fontSize: 16, | |
fontWeight: '600', | |
marginBottom: 4, | |
}} | |
> | |
{title} | |
</Text> | |
<Text style={{ fontSize: 12, color: 'gray' }}>{artist}</Text> | |
</View> | |
</View> | |
); | |
} | |
function MovableSong({ | |
id, | |
artist, | |
cover, | |
title, | |
positions, | |
scrollY, | |
songsCount, | |
}) { | |
const dimensions = useWindowDimensions(); | |
const insets = useSafeAreaInsets(); | |
const [moving, setMoving] = useState(false); | |
const top = useSharedValue(positions.value[id] * SONG_HEIGHT); | |
useAnimatedReaction( | |
() => positions.value[id], | |
(currentPosition, previousPosition) => { | |
if (currentPosition !== previousPosition) { | |
if (!moving) { | |
top.value = withSpring(currentPosition * SONG_HEIGHT); | |
} | |
} | |
}, | |
[moving] | |
); | |
const gestureHandler = useAnimatedGestureHandler({ | |
onStart() { | |
runOnJS(setMoving)(true); | |
if (Platform.OS === 'ios') { | |
runOnJS(Haptics.impactAsync)( | |
Haptics.ImpactFeedbackStyle.Medium | |
); | |
} | |
}, | |
onActive(event) { | |
const positionY = event.absoluteY + scrollY.value; | |
if (positionY <= scrollY.value + SCROLL_HEIGHT_THRESHOLD) { | |
// Scroll up | |
scrollY.value = withTiming(0, { duration: 1500 }); | |
} else if ( | |
positionY >= | |
scrollY.value + dimensions.height - SCROLL_HEIGHT_THRESHOLD | |
) { | |
// Scroll down | |
const contentHeight = songsCount * SONG_HEIGHT; | |
const containerHeight = | |
dimensions.height - insets.top - insets.bottom; | |
const maxScroll = contentHeight - containerHeight; | |
scrollY.value = withTiming(maxScroll, { duration: 1500 }); | |
} else { | |
cancelAnimation(scrollY); | |
} | |
top.value = withTiming(positionY - SONG_HEIGHT, { | |
duration: 16, | |
}); | |
const newPosition = clamp( | |
Math.floor(positionY / SONG_HEIGHT), | |
0, | |
songsCount - 1 | |
); | |
if (newPosition !== positions.value[id]) { | |
positions.value = objectMove( | |
positions.value, | |
positions.value[id], | |
newPosition | |
); | |
if (Platform.OS === 'ios') { | |
runOnJS(Haptics.impactAsync)( | |
Haptics.ImpactFeedbackStyle.Light | |
); | |
} | |
} | |
}, | |
onFinish() { | |
top.value = positions.value[id] * SONG_HEIGHT; | |
runOnJS(setMoving)(false); | |
}, | |
}); | |
const animatedStyle = useAnimatedStyle(() => { | |
return { | |
position: 'absolute', | |
left: 0, | |
right: 0, | |
top: top.value, | |
zIndex: moving ? 1 : 0, | |
shadowColor: 'black', | |
shadowOffset: { | |
height: 0, | |
width: 0, | |
}, | |
shadowOpacity: withSpring(moving ? 0.2 : 0), | |
shadowRadius: 10, | |
}; | |
}, [moving]); | |
return ( | |
<Animated.View style={animatedStyle}> | |
<BlurView intensity={moving ? 100 : 0} tint="light"> | |
<PanGestureHandler onGestureEvent={gestureHandler}> | |
<Animated.View style={{ maxWidth: '80%' }}> | |
<Song artist={artist} cover={cover} title={title} /> | |
</Animated.View> | |
</PanGestureHandler> | |
</BlurView> | |
</Animated.View> | |
); | |
} | |
export default function App() { | |
const positions = useSharedValue(listToObject(SONGS)); | |
const scrollY = useSharedValue(0); | |
const scrollViewRef = useAnimatedRef(); | |
useAnimatedReaction( | |
() => scrollY.value, | |
(scrolling) => scrollTo(scrollViewRef, 0, scrolling, false) | |
); | |
const handleScroll = useAnimatedScrollHandler((event) => { | |
scrollY.value = event.contentOffset.y; | |
}); | |
return ( | |
<> | |
<StatusBar barStyle="dark-content" /> | |
<SafeAreaProvider> | |
<SafeAreaView style={{ flex: 1 }}> | |
<Animated.ScrollView | |
ref={scrollViewRef} | |
onScroll={handleScroll} | |
scrollEventThrottle={16} | |
style={{ | |
flex: 1, | |
position: 'relative', | |
backgroundColor: 'white', | |
}} | |
contentContainerStyle={{ | |
height: SONGS.length * SONG_HEIGHT, | |
}} | |
> | |
{SONGS.map((song) => ( | |
<MovableSong | |
key={song.id} | |
id={song.id} | |
artist={song.artist} | |
cover={song.cover} | |
title={song.title} | |
positions={positions} | |
scrollY={scrollY} | |
songsCount={SONGS.length} | |
/> | |
))} | |
</Animated.ScrollView> | |
</SafeAreaView> | |
</SafeAreaProvider> | |
</> | |
); | |
} |
I'm not sure what you are asking. Are you if I have an idea why scrollTo crashes the app? Unfortunately, I don't. But I should be getting back to that soon. Currently working on foundation stuff (monorepo for building multiple universal expo apps that share code). I'll look at adding this into the demo app and see if I can recreate/fix then. I'll share the repo here when it's ready.
Yes. Also I have raised the issue software-mansion/react-native-reanimated#6418 and it is with the RN framework.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Thanks @Stewartarmbrecht Nice snack here, saved lot of my time. The scrollTo crashes the app the on the latest RN fabric with react-native-gesture-handler @ 2.18.1 & [email protected], Yes there are some deprecations in the new API like
useAnimatedGestureHandler
but i have replaced them with the newer API's. Do you have any idea or guess on the app screen on scrollTo?