-
-
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> | |
</> | |
); | |
} |
@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
how about if i want to make the height variable?