Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 47 You must be signed in to star a gist
  • Fork 17 You must be signed in to fork a gist
  • Save eveningkid/00dc171095eb6d64f45afdbaa50a76c3 to your computer and use it in GitHub Desktop.
Save eveningkid/00dc171095eb6d64f45afdbaa50a76c3 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>
</>
);
}
@pSapien
Copy link

pSapien commented Jun 27, 2021

spread crashes in the new reanimated versions.

Instead of using spread in function objectMove. Use Object.assign instead.
Turn out spreading doesn't work in worklets.

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;
}

Reference -> software-mansion/react-native-reanimated#1694

@eveningkid
Copy link
Author

@pSapien thanks a lot for the update, I edited the code using your changes!

@pSapien
Copy link

pSapien commented Jun 27, 2021

@pSapien thanks a lot for the update, I edited the code using your changes!

Also, in my own implementation of this (inspired by this), rather than using useState for moving.
I used useSharedValue hook. Is there anything wrong with this approach?

@dheysonalves
Copy link

@eveningkid for me it crashes using the clamp function. Can someone check my code ?
https://gist.github.com/dheysonalves/120f3022f721ffd9f43cc71823164187

@pSapien
Copy link

pSapien commented Aug 11, 2021

@dheysonalves

Could you check using

const newObject = Object.assign({}, object);

instead of

 const newObject = { ...object };

This should resolve the error.

@dheysonalves
Copy link

Thank you, it solved my error.

@Hyodori04
Copy link

thank you for your nice video and code, but it doesn't work on android. do you know the reason?

@wyeo
Copy link

wyeo commented Jan 22, 2022

@paulwongx
Copy link

paulwongx commented Feb 18, 2022

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

@tomwaitforitmy
Copy link

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?

@dgentilli
Copy link

dgentilli commented Jul 20, 2022

@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

@tomwaitforitmy
Copy link

Yes, that solved it for me as well. Sorry, I forgot to post the solution here.

@Ayubur
Copy link

Ayubur commented Dec 6, 2022

Hello, thanks for sharing

@Stewartarmbrecht
Copy link

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

@Stewartarmbrecht
Copy link

@Puetz
Copy link

Puetz commented Mar 24, 2023

@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 👍

@perrylau0821
Copy link

how about if i want to make the height variable?

@dutchkillscreative
Copy link

@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.

@taikisenju
Copy link

@Stewartarmbrecht Thank you for your help 👍

@harrigee
Copy link

harrigee commented Nov 8, 2023

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 :)

@ankitlb
Copy link

ankitlb commented Nov 25, 2023

If i'm adding heading on the top then the movable songs not starting from the initial p[osiotion it started from little downword

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