Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save pSapien/c9f4e1aa7d59dca838a92faf3f3daeba to your computer and use it in GitHub Desktop.
Save pSapien/c9f4e1aa7d59dca838a92faf3f3daeba to your computer and use it in GitHub Desktop.
React Native Reanimated 2 Multiple Drag and Sort: Apple Music Example
// 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>
</>
);
}
@viniciusmoreria
Copy link

can we still achieve this animation with dynamic heights on the items?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment