├── .gitignore
├── README.md
├── app.json
├── app
├── (tabs)
│ ├── _layout.tsx
│ ├── explore.tsx
│ └── index.tsx
├── +html.tsx
├── +not-found.tsx
└── _layout.tsx
├── assets
├── fonts
│ └── SpaceMono-Regular.ttf
└── images
│ ├── adaptive-icon.png
│ ├── favicon.png
│ ├── icon.png
│ ├── partial-react-logo.png
│ ├── react-logo.png
│ ├── react-logo@2x.png
│ ├── react-logo@3x.png
│ └── splash.png
├── babel.config.js
├── components
├── Collapsible.tsx
├── ExternalLink.tsx
├── HelloWave.tsx
├── ParallaxScrollView.tsx
├── ThemedText.tsx
├── ThemedView.tsx
├── __tests__
│ ├── ThemedText-test.tsx
│ └── __snapshots__
│ │ └── ThemedText-test.tsx.snap
└── navigation
│ └── TabBarIcon.tsx
├── constants
└── Colors.ts
├── hooks
├── useColorScheme.ts
├── useColorScheme.web.ts
└── useThemeColor.ts
├── package.json
├── patches
└── react-native-gesture-handler+2.18.1.patch
├── scripts
└── reset-project.js
├── tsconfig.json
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .expo/
3 | dist/
4 | npm-debug.*
5 | *.jks
6 | *.p8
7 | *.p12
8 | *.key
9 | *.mobileprovision
10 | *.orig.*
11 | web-build/
12 |
13 | # macOS
14 | .DS_Store
15 |
16 | # @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
17 | # The following patterns were generated by expo-cli
18 |
19 | expo-env.d.ts
20 | # @end expo-cli
21 |
22 | ios/
23 | android/
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # FlashList Drag to Reorder
2 |
3 | ## Motivation
4 |
5 | Inspired by [Drag and Drop List in React Native](https://www.youtube.com/watch?v=qeKP2A7bLUw) among other examples, I wanted to learn how to make this work with Shopify's FlashList. Many examples out there are implemented via ``, `react-native-flashdrag-list`, `react-native-draglist` or something else. These didn't fit my needs as I wanted the recycler view to be involved.
6 |
7 | I also need it to support web.
8 |
9 | ## Screenshots
10 |
11 | https://github.com/user-attachments/assets/3dc80750-2cc5-428c-a8b9-d82b1d4d1ba1
12 |
13 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "ExpoGestureHandler",
4 | "slug": "ExpoGestureHandler",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/images/icon.png",
8 | "scheme": "myapp",
9 | "userInterfaceStyle": "automatic",
10 | "splash": {
11 | "image": "./assets/images/splash.png",
12 | "resizeMode": "contain",
13 | "backgroundColor": "#ffffff"
14 | },
15 | "ios": {
16 | "supportsTablet": true,
17 | "bundleIdentifier": "com.frankcalise.ExpoGestureHandler"
18 | },
19 | "android": {
20 | "adaptiveIcon": {
21 | "foregroundImage": "./assets/images/adaptive-icon.png",
22 | "backgroundColor": "#ffffff"
23 | },
24 | "package": "com.frankcalise.ExpoGestureHandler"
25 | },
26 | "web": {
27 | "bundler": "metro",
28 | "output": "static",
29 | "favicon": "./assets/images/favicon.png"
30 | },
31 | "plugins": [
32 | "expo-router"
33 | ],
34 | "experiments": {
35 | "typedRoutes": true
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/app/(tabs)/_layout.tsx:
--------------------------------------------------------------------------------
1 | import { Tabs } from 'expo-router';
2 | import React from 'react';
3 |
4 | import { TabBarIcon } from '@/components/navigation/TabBarIcon';
5 | import { Colors } from '@/constants/Colors';
6 | import { useColorScheme } from '@/hooks/useColorScheme';
7 |
8 | export default function TabLayout() {
9 | const colorScheme = useColorScheme();
10 |
11 | return (
12 |
17 | (
22 |
23 | ),
24 | }}
25 | />
26 | (
31 |
32 | ),
33 | }}
34 | />
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/app/(tabs)/explore.tsx:
--------------------------------------------------------------------------------
1 | import Ionicons from '@expo/vector-icons/Ionicons';
2 | import { StyleSheet, Image, Platform } from 'react-native';
3 |
4 | import { Collapsible } from '@/components/Collapsible';
5 | import { ExternalLink } from '@/components/ExternalLink';
6 | import ParallaxScrollView from '@/components/ParallaxScrollView';
7 | import { ThemedText } from '@/components/ThemedText';
8 | import { ThemedView } from '@/components/ThemedView';
9 |
10 | export default function TabTwoScreen() {
11 | return (
12 | }>
15 |
16 | Explore
17 |
18 | This app includes example code to help you get started.
19 |
20 |
21 | This app has two screens:{' '}
22 | app/(tabs)/index.tsx and{' '}
23 | app/(tabs)/explore.tsx
24 |
25 |
26 | The layout file in app/(tabs)/_layout.tsx{' '}
27 | sets up the tab navigator.
28 |
29 |
30 | Learn more
31 |
32 |
33 |
34 |
35 | You can open this project on Android, iOS, and the web. To open the web version, press{' '}
36 | w in the terminal running this project.
37 |
38 |
39 |
40 |
41 | For static images, you can use the @2x and{' '}
42 | @3x suffixes to provide files for
43 | different screen densities
44 |
45 |
46 |
47 | Learn more
48 |
49 |
50 |
51 |
52 | Open app/_layout.tsx to see how to load{' '}
53 |
54 | custom fonts such as this one.
55 |
56 |
57 |
58 | Learn more
59 |
60 |
61 |
62 |
63 | This template has light and dark mode support. The{' '}
64 | useColorScheme() hook lets you inspect
65 | what the user's current color scheme is, and so you can adjust UI colors accordingly.
66 |
67 |
68 | Learn more
69 |
70 |
71 |
72 |
73 | This template includes an example of an animated component. The{' '}
74 | components/HelloWave.tsx component uses
75 | the powerful react-native-reanimated library
76 | to create a waving hand animation.
77 |
78 | {Platform.select({
79 | ios: (
80 |
81 | The components/ParallaxScrollView.tsx{' '}
82 | component provides a parallax effect for the header image.
83 |
84 | ),
85 | })}
86 |
87 |
88 | );
89 | }
90 |
91 | const styles = StyleSheet.create({
92 | headerImage: {
93 | color: '#808080',
94 | bottom: -90,
95 | left: -35,
96 | position: 'absolute',
97 | },
98 | titleContainer: {
99 | flexDirection: 'row',
100 | gap: 8,
101 | },
102 | });
103 |
--------------------------------------------------------------------------------
/app/(tabs)/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Button, Dimensions, View } from "react-native";
3 |
4 | import { ThemedText } from "@/components/ThemedText";
5 | import { useSafeAreaInsets } from "react-native-safe-area-context";
6 | import { FlashList } from "@shopify/flash-list";
7 | import {
8 | Gesture,
9 | GestureDetector,
10 | GestureType,
11 | PanGestureHandler,
12 | } from "react-native-gesture-handler";
13 | import Animated, {
14 | runOnJS,
15 | scrollTo,
16 | useAnimatedRef,
17 | useAnimatedScrollHandler,
18 | useAnimatedStyle,
19 | useSharedValue,
20 | } from "react-native-reanimated";
21 |
22 | /**
23 | * Things learned:
24 | * 1. The error handling provided by a native build is much better than the Expo Go client.
25 | * 2. Worklets must be defined before they are used (doing 1 earlier would have helped realize this sooner)
26 | * 3. setState is not allowed in a worklet, but you can use runOnJS to call a function that uses setState
27 | * 4. To use a `GestureDetector` with FlashList, you need to pass a ref of the detector to
28 | * the `simultaneousHandlers` prop to the FlashList via `overrideProps`
29 | */
30 |
31 | const AnimatedFlashList = Animated.createAnimatedComponent(FlashList);
32 |
33 | const colorMap: Record = {};
34 | const ROW_HEIGHT = 70;
35 | const HEADER_HEIGHT = 0;
36 |
37 | function getRandomColor() {
38 | const letters = "0123456789ABCDEF";
39 | let color = "#";
40 | for (let i = 0; i < 6; i++) {
41 | color += letters[Math.floor(Math.random() * 16)];
42 | }
43 | return color;
44 | }
45 |
46 | const INITIAL_DATA = Array.from(Array(200), (_, i) => {
47 | colorMap[i] = getRandomColor();
48 | return i;
49 | });
50 |
51 | export default function HomeScreen() {
52 | const insets = useSafeAreaInsets();
53 | const listRef = useAnimatedRef>();
54 | const scrollOffsetY = useSharedValue(0);
55 | const dragItemAbsY = useSharedValue(0);
56 | const headerHeight = useSharedValue(0);
57 | const [enabled, setEnabled] = React.useState(true);
58 | const [isDraggingItem, setIsDraggingItem] = React.useState(false);
59 | const [draggingIndex, setDraggingIndex] = React.useState(-1);
60 | const [floatingOverIndex, setFloatingOverIndex] = React.useState(-1);
61 | const [data, setData] = React.useState(INITIAL_DATA);
62 |
63 | // TODO get the list height
64 | const listHeight = useSharedValue(0);
65 |
66 | /**
67 | * ! Worklets must be defined before they are used, otherwise you will experience a crash.
68 | *
69 | * Calculate header height if your list would render ListHeaderComponent
70 | */
71 | const getIndexFromY = React.useCallback(
72 | (y: number) => {
73 | "worklet";
74 |
75 | return Math.floor(
76 | Math.min(
77 | data.length - 1,
78 | Math.max(
79 | 0,
80 | Math.floor(y + scrollOffsetY.value - headerHeight.value) /
81 | ROW_HEIGHT
82 | )
83 | )
84 | );
85 | },
86 | [data, scrollOffsetY, insets, headerHeight]
87 | );
88 |
89 | // TODO doesn't seem to work on Android
90 | const scrollLogic = React.useCallback(
91 | ({ absoluteY }: { absoluteY: number }) => {
92 | "worklet";
93 | const lowerBound = 1.5 * ROW_HEIGHT;
94 | const upperBound = scrollOffsetY.value + listHeight.value;
95 |
96 | // scroll speed is proportional to the item height (the bigger the item, the faster it scrolls)
97 | const scrollSpeed = ROW_HEIGHT * 0.8;
98 |
99 | if (absoluteY <= lowerBound) {
100 | // while scrolling to the top of the list
101 | const nextPosition = scrollOffsetY.value - scrollSpeed;
102 | scrollTo(listRef, 0, Math.max(nextPosition, 0), false);
103 | } else if (absoluteY + scrollOffsetY.value >= upperBound) {
104 | // while scrolling to the bottom of the list
105 | const nextPosition = scrollOffsetY.value + scrollSpeed;
106 | scrollTo(listRef, 0, Math.max(nextPosition, 0), false);
107 | }
108 | },
109 | [scrollOffsetY, listRef]
110 | );
111 |
112 | /**
113 | * This gesture will run on a native thread, if you try to access JS code, such as setState, it will crash.
114 | * Thanks Hirbod - https://github.com/software-mansion/react-native-gesture-handler/discussions/2061#discussioncomment-2794942
115 | */
116 | const panGesture = Gesture.Pan()
117 | /**
118 | * ! Issue with toggling this from UI
119 | * Tracked here: https://github.com/software-mansion/react-native-gesture-handler/issues/3074
120 | */
121 | .enabled(enabled)
122 | .maxPointers(1)
123 | .activateAfterLongPress(300)
124 | .onBegin(() => {
125 | console.log("onBegin");
126 | })
127 | .onStart(({ y, absoluteY, oldState, state }) => {
128 | // Get item index from the Y position where the gesture started
129 | const index = getIndexFromY(y);
130 | console.log("onStart", { index, y, absoluteY, oldState, state });
131 | runOnJS(setIsDraggingItem)(true);
132 | runOnJS(setDraggingIndex)(index);
133 | runOnJS(setFloatingOverIndex)(index);
134 | dragItemAbsY.value = absoluteY;
135 | })
136 | .onUpdate(({ absoluteY, y }) => {
137 | scrollLogic({ absoluteY });
138 | dragItemAbsY.value = absoluteY;
139 |
140 | const index = getIndexFromY(y);
141 | runOnJS(setFloatingOverIndex)(index);
142 | })
143 | .onFinalize(() => {
144 | // update the data array to reflect the new order
145 | const updatedData = [...data];
146 | const [removed] = updatedData.splice(draggingIndex, 1);
147 | // when dragging down, we have to subtract 1 from the floating index
148 | // but when dragging the item up, just insert it as is
149 | const directionOffset = floatingOverIndex > draggingIndex ? 1 : 0;
150 | updatedData.splice(floatingOverIndex - directionOffset, 0, removed);
151 |
152 | runOnJS(setData)(updatedData);
153 |
154 | // reset things
155 | runOnJS(setIsDraggingItem)(false);
156 | runOnJS(setDraggingIndex)(-1);
157 | runOnJS(setFloatingOverIndex)(-1);
158 | });
159 |
160 | /**
161 | * Ran into `useAnimatedScrollHandler` not working with the version that ships with Expo Go
162 | * https://github.com/software-mansion/react-native-reanimated/issues/5941
163 | */
164 | const onScroll = useAnimatedScrollHandler({
165 | onScroll: ({ contentOffset: { y } }) => {
166 | console.log("onscroll", y);
167 | scrollOffsetY.value = y;
168 | },
169 | });
170 |
171 | const $floatingItem = useAnimatedStyle(() => {
172 | return {
173 | top: dragItemAbsY.value,
174 | };
175 | });
176 |
177 | // TODO extract list item into component to use inbetween the floating Animated layer and for the FlashList
178 | return (
179 |
180 | {isDraggingItem && (
181 |
192 |
205 |
211 |
212 | Dragging idx {draggingIndex} (val={data[draggingIndex]})
213 |
214 |
215 | Float over idx {floatingOverIndex} (val=
216 | {data[floatingOverIndex]})
217 |
218 |
219 |
220 |
221 | )}
222 |
223 |
224 | {
231 | listHeight.value = nativeEvent.layout.height;
232 | }}
233 | ListHeaderComponent={
234 | {
236 | headerHeight.value = nativeEvent.layout.height;
237 | }}
238 | style={{ height: 50 }}
239 | >
240 |
247 | }
248 | renderItem={({ item, index }) => {
249 | return (
250 |
268 | =
269 |
272 | {item}
273 |
274 |
275 | );
276 | }}
277 | estimatedItemSize={ROW_HEIGHT}
278 | />
279 |
280 |
281 | );
282 | }
283 |
--------------------------------------------------------------------------------
/app/+html.tsx:
--------------------------------------------------------------------------------
1 | import { ScrollViewStyleReset } from 'expo-router/html';
2 | import { type PropsWithChildren } from 'react';
3 |
4 | /**
5 | * This file is web-only and used to configure the root HTML for every web page during static rendering.
6 | * The contents of this function only run in Node.js environments and do not have access to the DOM or browser APIs.
7 | */
8 | export default function Root({ children }: PropsWithChildren) {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 |
16 | {/*
17 | Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
18 | However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
19 | */}
20 |
21 |
22 | {/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
23 |
24 | {/* Add any additional elements that you want globally available on web... */}
25 |
26 | {children}
27 |
28 | );
29 | }
30 |
31 | const responsiveBackground = `
32 | body {
33 | background-color: #fff;
34 | }
35 | @media (prefers-color-scheme: dark) {
36 | body {
37 | background-color: #000;
38 | }
39 | }`;
40 |
--------------------------------------------------------------------------------
/app/+not-found.tsx:
--------------------------------------------------------------------------------
1 | import { Link, Stack } from 'expo-router';
2 | import { StyleSheet } from 'react-native';
3 |
4 | import { ThemedText } from '@/components/ThemedText';
5 | import { ThemedView } from '@/components/ThemedView';
6 |
7 | export default function NotFoundScreen() {
8 | return (
9 | <>
10 |
11 |
12 | This screen doesn't exist.
13 |
14 | Go to home screen!
15 |
16 |
17 | >
18 | );
19 | }
20 |
21 | const styles = StyleSheet.create({
22 | container: {
23 | flex: 1,
24 | alignItems: 'center',
25 | justifyContent: 'center',
26 | padding: 20,
27 | },
28 | link: {
29 | marginTop: 15,
30 | paddingVertical: 15,
31 | },
32 | });
33 |
--------------------------------------------------------------------------------
/app/_layout.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | DarkTheme,
3 | DefaultTheme,
4 | ThemeProvider,
5 | } from "@react-navigation/native";
6 | import { useFonts } from "expo-font";
7 | import { Stack } from "expo-router";
8 | import * as SplashScreen from "expo-splash-screen";
9 | import { useEffect } from "react";
10 | import "react-native-reanimated";
11 |
12 | LogBox.ignoreLogs(["React.forwardRef"]);
13 |
14 | import { useColorScheme } from "@/hooks/useColorScheme";
15 | import { GestureHandlerRootView } from "react-native-gesture-handler";
16 | import { LogBox } from "react-native";
17 |
18 | // Prevent the splash screen from auto-hiding before asset loading is complete.
19 | SplashScreen.preventAutoHideAsync();
20 |
21 | export default function RootLayout() {
22 | const colorScheme = useColorScheme();
23 | const [loaded] = useFonts({
24 | SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
25 | });
26 |
27 | useEffect(() => {
28 | if (loaded) {
29 | SplashScreen.hideAsync();
30 | }
31 | }, [loaded]);
32 |
33 | if (!loaded) {
34 | return null;
35 | }
36 |
37 | return (
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/assets/fonts/SpaceMono-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/frankcalise/flash-list-drag-order/b3f48b1fd1f3867634c4dc6a068bbc000cea9126/assets/fonts/SpaceMono-Regular.ttf
--------------------------------------------------------------------------------
/assets/images/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/frankcalise/flash-list-drag-order/b3f48b1fd1f3867634c4dc6a068bbc000cea9126/assets/images/adaptive-icon.png
--------------------------------------------------------------------------------
/assets/images/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/frankcalise/flash-list-drag-order/b3f48b1fd1f3867634c4dc6a068bbc000cea9126/assets/images/favicon.png
--------------------------------------------------------------------------------
/assets/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/frankcalise/flash-list-drag-order/b3f48b1fd1f3867634c4dc6a068bbc000cea9126/assets/images/icon.png
--------------------------------------------------------------------------------
/assets/images/partial-react-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/frankcalise/flash-list-drag-order/b3f48b1fd1f3867634c4dc6a068bbc000cea9126/assets/images/partial-react-logo.png
--------------------------------------------------------------------------------
/assets/images/react-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/frankcalise/flash-list-drag-order/b3f48b1fd1f3867634c4dc6a068bbc000cea9126/assets/images/react-logo.png
--------------------------------------------------------------------------------
/assets/images/react-logo@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/frankcalise/flash-list-drag-order/b3f48b1fd1f3867634c4dc6a068bbc000cea9126/assets/images/react-logo@2x.png
--------------------------------------------------------------------------------
/assets/images/react-logo@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/frankcalise/flash-list-drag-order/b3f48b1fd1f3867634c4dc6a068bbc000cea9126/assets/images/react-logo@3x.png
--------------------------------------------------------------------------------
/assets/images/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/frankcalise/flash-list-drag-order/b3f48b1fd1f3867634c4dc6a068bbc000cea9126/assets/images/splash.png
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function (api) {
2 | api.cache(true);
3 | return {
4 | presets: ['babel-preset-expo'],
5 | };
6 | };
7 |
--------------------------------------------------------------------------------
/components/Collapsible.tsx:
--------------------------------------------------------------------------------
1 | import Ionicons from '@expo/vector-icons/Ionicons';
2 | import { PropsWithChildren, useState } from 'react';
3 | import { StyleSheet, TouchableOpacity, useColorScheme } from 'react-native';
4 |
5 | import { ThemedText } from '@/components/ThemedText';
6 | import { ThemedView } from '@/components/ThemedView';
7 | import { Colors } from '@/constants/Colors';
8 |
9 | export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
10 | const [isOpen, setIsOpen] = useState(false);
11 | const theme = useColorScheme() ?? 'light';
12 |
13 | return (
14 |
15 | setIsOpen((value) => !value)}
18 | activeOpacity={0.8}>
19 |
24 | {title}
25 |
26 | {isOpen && {children}}
27 |
28 | );
29 | }
30 |
31 | const styles = StyleSheet.create({
32 | heading: {
33 | flexDirection: 'row',
34 | alignItems: 'center',
35 | gap: 6,
36 | },
37 | content: {
38 | marginTop: 6,
39 | marginLeft: 24,
40 | },
41 | });
42 |
--------------------------------------------------------------------------------
/components/ExternalLink.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'expo-router';
2 | import { openBrowserAsync } from 'expo-web-browser';
3 | import { type ComponentProps } from 'react';
4 | import { Platform } from 'react-native';
5 |
6 | type Props = Omit, 'href'> & { href: string };
7 |
8 | export function ExternalLink({ href, ...rest }: Props) {
9 | return (
10 | {
15 | if (Platform.OS !== 'web') {
16 | // Prevent the default behavior of linking to the default browser on native.
17 | event.preventDefault();
18 | // Open the link in an in-app browser.
19 | await openBrowserAsync(href);
20 | }
21 | }}
22 | />
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/components/HelloWave.tsx:
--------------------------------------------------------------------------------
1 | import { StyleSheet } from 'react-native';
2 | import Animated, {
3 | useSharedValue,
4 | useAnimatedStyle,
5 | withTiming,
6 | withRepeat,
7 | withSequence,
8 | } from 'react-native-reanimated';
9 |
10 | import { ThemedText } from '@/components/ThemedText';
11 |
12 | export function HelloWave() {
13 | const rotationAnimation = useSharedValue(0);
14 |
15 | rotationAnimation.value = withRepeat(
16 | withSequence(withTiming(25, { duration: 150 }), withTiming(0, { duration: 150 })),
17 | 4 // Run the animation 4 times
18 | );
19 |
20 | const animatedStyle = useAnimatedStyle(() => ({
21 | transform: [{ rotate: `${rotationAnimation.value}deg` }],
22 | }));
23 |
24 | return (
25 |
26 | 👋
27 |
28 | );
29 | }
30 |
31 | const styles = StyleSheet.create({
32 | text: {
33 | fontSize: 28,
34 | lineHeight: 32,
35 | marginTop: -6,
36 | },
37 | });
38 |
--------------------------------------------------------------------------------
/components/ParallaxScrollView.tsx:
--------------------------------------------------------------------------------
1 | import type { PropsWithChildren, ReactElement } from 'react';
2 | import { StyleSheet, useColorScheme } from 'react-native';
3 | import Animated, {
4 | interpolate,
5 | useAnimatedRef,
6 | useAnimatedStyle,
7 | useScrollViewOffset,
8 | } from 'react-native-reanimated';
9 |
10 | import { ThemedView } from '@/components/ThemedView';
11 |
12 | const HEADER_HEIGHT = 250;
13 |
14 | type Props = PropsWithChildren<{
15 | headerImage: ReactElement;
16 | headerBackgroundColor: { dark: string; light: string };
17 | }>;
18 |
19 | export default function ParallaxScrollView({
20 | children,
21 | headerImage,
22 | headerBackgroundColor,
23 | }: Props) {
24 | const colorScheme = useColorScheme() ?? 'light';
25 | const scrollRef = useAnimatedRef();
26 | const scrollOffset = useScrollViewOffset(scrollRef);
27 |
28 | const headerAnimatedStyle = useAnimatedStyle(() => {
29 | return {
30 | transform: [
31 | {
32 | translateY: interpolate(
33 | scrollOffset.value,
34 | [-HEADER_HEIGHT, 0, HEADER_HEIGHT],
35 | [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
36 | ),
37 | },
38 | {
39 | scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
40 | },
41 | ],
42 | };
43 | });
44 |
45 | return (
46 |
47 |
48 |
54 | {headerImage}
55 |
56 | {children}
57 |
58 |
59 | );
60 | }
61 |
62 | const styles = StyleSheet.create({
63 | container: {
64 | flex: 1,
65 | },
66 | header: {
67 | height: 250,
68 | overflow: 'hidden',
69 | },
70 | content: {
71 | flex: 1,
72 | padding: 32,
73 | gap: 16,
74 | overflow: 'hidden',
75 | },
76 | });
77 |
--------------------------------------------------------------------------------
/components/ThemedText.tsx:
--------------------------------------------------------------------------------
1 | import { Text, type TextProps, StyleSheet } from 'react-native';
2 |
3 | import { useThemeColor } from '@/hooks/useThemeColor';
4 |
5 | export type ThemedTextProps = TextProps & {
6 | lightColor?: string;
7 | darkColor?: string;
8 | type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
9 | };
10 |
11 | export function ThemedText({
12 | style,
13 | lightColor,
14 | darkColor,
15 | type = 'default',
16 | ...rest
17 | }: ThemedTextProps) {
18 | const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
19 |
20 | return (
21 |
33 | );
34 | }
35 |
36 | const styles = StyleSheet.create({
37 | default: {
38 | fontSize: 16,
39 | lineHeight: 24,
40 | },
41 | defaultSemiBold: {
42 | fontSize: 16,
43 | lineHeight: 24,
44 | fontWeight: '600',
45 | },
46 | title: {
47 | fontSize: 32,
48 | fontWeight: 'bold',
49 | lineHeight: 32,
50 | },
51 | subtitle: {
52 | fontSize: 20,
53 | fontWeight: 'bold',
54 | },
55 | link: {
56 | lineHeight: 30,
57 | fontSize: 16,
58 | color: '#0a7ea4',
59 | },
60 | });
61 |
--------------------------------------------------------------------------------
/components/ThemedView.tsx:
--------------------------------------------------------------------------------
1 | import { View, type ViewProps } from 'react-native';
2 |
3 | import { useThemeColor } from '@/hooks/useThemeColor';
4 |
5 | export type ThemedViewProps = ViewProps & {
6 | lightColor?: string;
7 | darkColor?: string;
8 | };
9 |
10 | export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
11 | const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
12 |
13 | return ;
14 | }
15 |
--------------------------------------------------------------------------------
/components/__tests__/ThemedText-test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import renderer from 'react-test-renderer';
3 |
4 | import { ThemedText } from '../ThemedText';
5 |
6 | it(`renders correctly`, () => {
7 | const tree = renderer.create(Snapshot test!).toJSON();
8 |
9 | expect(tree).toMatchSnapshot();
10 | });
11 |
--------------------------------------------------------------------------------
/components/__tests__/__snapshots__/ThemedText-test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`renders correctly 1`] = `
4 |
22 | Snapshot test!
23 |
24 | `;
25 |
--------------------------------------------------------------------------------
/components/navigation/TabBarIcon.tsx:
--------------------------------------------------------------------------------
1 | // You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
2 |
3 | import Ionicons from '@expo/vector-icons/Ionicons';
4 | import { type IconProps } from '@expo/vector-icons/build/createIconSet';
5 | import { type ComponentProps } from 'react';
6 |
7 | export function TabBarIcon({ style, ...rest }: IconProps['name']>) {
8 | return ;
9 | }
10 |
--------------------------------------------------------------------------------
/constants/Colors.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Below are the colors that are used in the app. The colors are defined in the light and dark mode.
3 | * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
4 | */
5 |
6 | const tintColorLight = '#0a7ea4';
7 | const tintColorDark = '#fff';
8 |
9 | export const Colors = {
10 | light: {
11 | text: '#11181C',
12 | background: '#fff',
13 | tint: tintColorLight,
14 | icon: '#687076',
15 | tabIconDefault: '#687076',
16 | tabIconSelected: tintColorLight,
17 | },
18 | dark: {
19 | text: '#ECEDEE',
20 | background: '#151718',
21 | tint: tintColorDark,
22 | icon: '#9BA1A6',
23 | tabIconDefault: '#9BA1A6',
24 | tabIconSelected: tintColorDark,
25 | },
26 | };
27 |
--------------------------------------------------------------------------------
/hooks/useColorScheme.ts:
--------------------------------------------------------------------------------
1 | export { useColorScheme } from 'react-native';
2 |
--------------------------------------------------------------------------------
/hooks/useColorScheme.web.ts:
--------------------------------------------------------------------------------
1 | // NOTE: The default React Native styling doesn't support server rendering.
2 | // Server rendered styles should not change between the first render of the HTML
3 | // and the first render on the client. Typically, web developers will use CSS media queries
4 | // to render different styles on the client and server, these aren't directly supported in React Native
5 | // but can be achieved using a styling library like Nativewind.
6 | export function useColorScheme() {
7 | return 'light';
8 | }
9 |
--------------------------------------------------------------------------------
/hooks/useThemeColor.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Learn more about light and dark modes:
3 | * https://docs.expo.dev/guides/color-schemes/
4 | */
5 |
6 | import { useColorScheme } from 'react-native';
7 |
8 | import { Colors } from '@/constants/Colors';
9 |
10 | export function useThemeColor(
11 | props: { light?: string; dark?: string },
12 | colorName: keyof typeof Colors.light & keyof typeof Colors.dark
13 | ) {
14 | const theme = useColorScheme() ?? 'light';
15 | const colorFromProps = props[theme];
16 |
17 | if (colorFromProps) {
18 | return colorFromProps;
19 | } else {
20 | return Colors[theme][colorName];
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "expogesturehandler",
3 | "main": "expo-router/entry",
4 | "version": "1.0.0",
5 | "scripts": {
6 | "start": "expo start",
7 | "reset-project": "node ./scripts/reset-project.js",
8 | "android": "expo run:android",
9 | "ios": "expo run:ios",
10 | "web": "expo start --web",
11 | "test": "jest --watchAll",
12 | "lint": "expo lint"
13 | },
14 | "jest": {
15 | "preset": "jest-expo"
16 | },
17 | "dependencies": {
18 | "@expo/vector-icons": "^14.0.2",
19 | "@react-navigation/native": "^6.0.2",
20 | "@shopify/flash-list": "1.6.4",
21 | "expo": "~51.0.28",
22 | "expo-constants": "~16.0.2",
23 | "expo-font": "~12.0.9",
24 | "expo-linking": "~6.3.1",
25 | "expo-router": "~3.5.23",
26 | "expo-splash-screen": "~0.27.5",
27 | "expo-status-bar": "~1.12.1",
28 | "expo-system-ui": "~3.0.7",
29 | "expo-web-browser": "~13.0.3",
30 | "react": "18.2.0",
31 | "react-dom": "18.2.0",
32 | "react-native": "0.74.5",
33 | "react-native-gesture-handler": "^2.18.1",
34 | "react-native-reanimated": "^3.15.0",
35 | "react-native-safe-area-context": "4.10.5",
36 | "react-native-screens": "3.31.1",
37 | "react-native-web": "~0.19.10"
38 | },
39 | "devDependencies": {
40 | "@babel/core": "^7.20.0",
41 | "@types/jest": "^29.5.12",
42 | "@types/react": "~18.2.45",
43 | "@types/react-test-renderer": "^18.0.7",
44 | "jest": "^29.2.1",
45 | "jest-expo": "~51.0.3",
46 | "react-test-renderer": "18.2.0",
47 | "typescript": "~5.3.3"
48 | },
49 | "private": true
50 | }
51 |
--------------------------------------------------------------------------------
/patches/react-native-gesture-handler+2.18.1.patch:
--------------------------------------------------------------------------------
1 | diff --git a/node_modules/react-native-gesture-handler/lib/commonjs/web/handlers/PanGestureHandler.js b/node_modules/react-native-gesture-handler/lib/commonjs/web/handlers/PanGestureHandler.js
2 | index da00cef..babbcc4 100644
3 | --- a/node_modules/react-native-gesture-handler/lib/commonjs/web/handlers/PanGestureHandler.js
4 | +++ b/node_modules/react-native-gesture-handler/lib/commonjs/web/handlers/PanGestureHandler.js
5 | @@ -279,6 +279,10 @@ class PanGestureHandler extends _GestureHandler.default {
6 |
7 | this.tracker.removeFromTracker(event.pointerId);
8 |
9 | + if (this.tracker.getTrackedPointersCount() === 0) {
10 | + this.clearActivationTimeout();
11 | + }
12 | +
13 | if (this.currentState === _State.State.ACTIVE) {
14 | this.end();
15 | } else {
16 |
--------------------------------------------------------------------------------
/scripts/reset-project.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * This script is used to reset the project to a blank state.
5 | * It moves the /app directory to /app-example and creates a new /app directory with an index.tsx and _layout.tsx file.
6 | * You can remove the `reset-project` script from package.json and safely delete this file after running it.
7 | */
8 |
9 | const fs = require('fs');
10 | const path = require('path');
11 |
12 | const root = process.cwd();
13 | const oldDirPath = path.join(root, 'app');
14 | const newDirPath = path.join(root, 'app-example');
15 | const newAppDirPath = path.join(root, 'app');
16 |
17 | const indexContent = `import { Text, View } from "react-native";
18 |
19 | export default function Index() {
20 | return (
21 |
28 | Edit app/index.tsx to edit this screen.
29 |
30 | );
31 | }
32 | `;
33 |
34 | const layoutContent = `import { Stack } from "expo-router";
35 |
36 | export default function RootLayout() {
37 | return (
38 |
39 |
40 |
41 | );
42 | }
43 | `;
44 |
45 | fs.rename(oldDirPath, newDirPath, (error) => {
46 | if (error) {
47 | return console.error(`Error renaming directory: ${error}`);
48 | }
49 | console.log('/app moved to /app-example.');
50 |
51 | fs.mkdir(newAppDirPath, { recursive: true }, (error) => {
52 | if (error) {
53 | return console.error(`Error creating new app directory: ${error}`);
54 | }
55 | console.log('New /app directory created.');
56 |
57 | const indexPath = path.join(newAppDirPath, 'index.tsx');
58 | fs.writeFile(indexPath, indexContent, (error) => {
59 | if (error) {
60 | return console.error(`Error creating index.tsx: ${error}`);
61 | }
62 | console.log('app/index.tsx created.');
63 |
64 | const layoutPath = path.join(newAppDirPath, '_layout.tsx');
65 | fs.writeFile(layoutPath, layoutContent, (error) => {
66 | if (error) {
67 | return console.error(`Error creating _layout.tsx: ${error}`);
68 | }
69 | console.log('app/_layout.tsx created.');
70 | });
71 | });
72 | });
73 | });
74 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo/tsconfig.base",
3 | "compilerOptions": {
4 | "strict": true,
5 | "paths": {
6 | "@/*": [
7 | "./*"
8 | ]
9 | }
10 | },
11 | "include": [
12 | "**/*.ts",
13 | "**/*.tsx",
14 | ".expo/types/**/*.ts",
15 | "expo-env.d.ts"
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------