-
-
Save eveningkid/00dc171095eb6d64f45afdbaa50a76c3 to your computer and use it in GitHub Desktop.
// 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> | |
</> | |
); | |
} |
Could you check using
const newObject = Object.assign({}, object);
instead of
const newObject = { ...object };
This should resolve the error.
Thank you, it solved my error.
thank you for your nice video and code, but it doesn't work on android. do you know the reason?
@Hyodori04 You should read this : https://docs.swmansion.com/react-native-gesture-handler/docs/#android
Does Reanimated 2 work in Expo? I'm confused by a lot of the information. It looks like as of Feb 2022, it can work but you can't debug on Android or something? Can someone confirm? I guess as per this, it works Source
I have the same issue like @Hyodori04: Not working for me on android. Neither the documentation of expo, nor reanimated, nor react-native-gesture-handler say anything about it. @wyeo Your link just shows the official docs of react-native-gesture-handler. Installation via expo is supposed to be as easy as expo install react-native-gesture-handler. What am I missing?
@tomwaitforitmy @Hyodori04 I encountered a similar issue. Fixed it by wrapping the app entry point in GestureHandlerRootView . See this link from the docs https://docs.swmansion.com/react-native-gesture-handler/docs/installation/#js
Yes, that solved it for me as well. Sorry, I forgot to post the solution here.
Hello, thanks for sharing
Hi @eveningkid , I've been trying to get this to work on an iPhone. Everything seems fine except the scroll up response to dragging to the top or bottom only scrolls a tiny bit when and only scrolls while your finger is moving. It looks like the gesture handler is killing the withTiming function for setting the scrollY.value. Any idea how to fix this? I have created a snack for it: https://snack.expo.dev/@stewartarmbrecht/apple-music-drag-to-sort
I think I figured it out. New snack here: https://snack.expo.dev/@stewartarmbrecht/apple-music-drag-and-sort-working
@Stewartarmbrecht thank you so much. I was having the exact same issue on Android and was desperatly trying to fix it. I wish I would have checked the comments immediately.
So if anybody else is having this issue: Stewart's snack is still working 👍
how about if i want to make the height variable?
@Stewartarmbrecht your fix works well, but it looks like theres an issue if you drop the song while it's still scrolling it doesn't seem to fit in where it's supposed to.
@Stewartarmbrecht Thank you for your help 👍
Hello. For everyone who has issues with @Stewartarmbrecht `s snack while the scrollView is scrolling and the item is not correct positioned afterwards. The reason is that moving is still using useState. It should be a SharedValue. This small changes fixes the incorrect placement when dropping an item while scrolling.
Thanks a lot though. Without this snack I would have needed an expensive amount of more time :)
If i'm adding heading on the top then the movable songs not starting from the initial p[osiotion it started from little downword
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?
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.
@eveningkid for me it crashes using the clamp function. Can someone check my code ?
https://gist.github.com/dheysonalves/120f3022f721ffd9f43cc71823164187