├── .gitignore
├── README.md
├── app.json
├── app
├── _layout.tsx
└── index.tsx
├── assets
└── 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
├── package.json
└── tsconfig.json
/.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
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Custom Scroll Indicator Demo
2 |
3 | This project showcases a custom scroll indicator inspired by the Samsung Gallery scroll indicator, designed for both Android and iOS. Built using [Expo](https://expo.dev/), [react-native-reanimated](https://docs.swmansion.com/react-native-reanimated/), and [react-native-gesture-handler](https://docs.swmansion.com/react-native-gesture-handler/), this example highlights how to create an interactive scroll indicator.
4 |
5 | ## Demo
6 |
7 | Check out the custom scroll indicator in action 👇:
8 |
9 | | Android | iOS |
10 | |--------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------|
11 | | | |
12 |
13 | ## Features
14 |
15 | - **Pan Gesture Support**: Users can manually adjust the scroll indicator using pan gestures, enhancing the interactive experience.
16 | - **Automatic Sync with Scroll**: The indicator automatically syncs with the content scroll, mimicing the native scroll indicator behaviour.
17 | - **Layout Transitions for Chip Width**: Using layout transitions from Reanimated, the width of the indicator chips dynamically adjusts based on content changes, creating a polished feel.
18 |
19 | ## How It Works
20 |
21 | - **Pan Gesture Handling**: The scroll indicator utilizes pan gestures to allow users to slide the indicator manually. The `onTouchStart` and `onTouchEnd` events from the `View` are leveraged to manage the interaction states.
22 |
23 | - **Automatic Sliding**: As the user scrolls through content, the indicator starts sliding automatically, which is calculated based on the scroll offset.
24 |
25 | - **Chip Transition**: The chips that showing content have layout transition it makes their width increased or decreased depending on their content size, giving a satisfying responsive feel to the user.
26 |
27 | - **Calculated Content**: The calculated content simplifies the implementation process, allowing for straightforward adjustments.
28 |
29 | ### Note:
30 | This project serves as a demonstration of a custom scroll indicator implementation. If you're interested in building upon this code or contributing enhancements, feel free to submit a pull request (PR). Contributions such as bug fixes, new features, or general improvements are always welcome!
31 |
32 | ## Acknowledgments
33 |
34 | - **[Expo](https://expo.dev/)**: For streamlining cross-platform mobile development.
35 | - **[Reanimated](https://docs.swmansion.com/react-native-reanimated/)**: For enabling the smooth animations that enhance the scroll indicator experience.
36 | - **[React Native Gesture Handler](https://docs.swmansion.com/react-native-gesture-handler/)**: For providing robust gesture handling capabilities.
37 |
38 | Feel free to contribute!
39 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "expo-scroll-anim-2",
4 | "slug": "expo-scroll-anim-2",
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 | },
18 | "android": {
19 | "adaptiveIcon": {
20 | "foregroundImage": "./assets/images/adaptive-icon.png",
21 | "backgroundColor": "#ffffff"
22 | }
23 | },
24 | "web": {
25 | "bundler": "metro",
26 | "output": "static",
27 | "favicon": "./assets/images/favicon.png"
28 | },
29 | "plugins": [
30 | "expo-router"
31 | ],
32 | "experiments": {
33 | "typedRoutes": true
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/_layout.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | DarkTheme,
3 | DefaultTheme,
4 | ThemeProvider,
5 | } from "@react-navigation/native";
6 | import { Stack } from "expo-router";
7 | import { useColorScheme } from "react-native";
8 | import "react-native-reanimated";
9 |
10 | declare module "@react-navigation/native" {
11 | export type ExtendedTheme = {
12 | dark: boolean;
13 | colors: {
14 | primary: string;
15 | background: string;
16 | card: string;
17 | text: string;
18 | border: string;
19 | notification: string;
20 | indicator: string;
21 | };
22 | };
23 | export function useTheme(): ExtendedTheme;
24 | }
25 |
26 | const dark = {
27 | ...DarkTheme,
28 | colors: {
29 | ...DarkTheme.colors,
30 | indicator: "#333333",
31 | },
32 | };
33 |
34 | const light = {
35 | ...DefaultTheme,
36 | colors: {
37 | ...DefaultTheme.colors,
38 | indicator: "#333333",
39 | },
40 | };
41 |
42 | export default function RootLayout() {
43 | const colorScheme = useColorScheme();
44 |
45 | return (
46 |
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/app/index.tsx:
--------------------------------------------------------------------------------
1 | import { StyleSheet, Text, useWindowDimensions, View } from "react-native";
2 | import React, { useState } from "react";
3 | import { useTheme } from "@react-navigation/native";
4 | import Animated, {
5 | Extrapolation,
6 | interpolate,
7 | LinearTransition,
8 | runOnJS,
9 | scrollTo,
10 | SharedValue,
11 | useAnimatedRef,
12 | useAnimatedStyle,
13 | useDerivedValue,
14 | useScrollViewOffset,
15 | useSharedValue,
16 | withTiming,
17 | } from "react-native-reanimated";
18 | import { useSafeAreaInsets } from "react-native-safe-area-context";
19 | import { useHeaderHeight } from "@react-navigation/elements";
20 | import {
21 | Gesture,
22 | GestureDetector,
23 | GestureHandlerRootView,
24 | } from "react-native-gesture-handler";
25 |
26 | const total = 1000;
27 | const size = 100;
28 | const indicatorSize = 45;
29 | const indicatorWidth = 15;
30 |
31 | const Main = () => {
32 | const { bottom } = useSafeAreaInsets();
33 | const headerHeight = useHeaderHeight();
34 | const [activeBlock, setActiveBlock] = useState(0);
35 | const theme = useTheme();
36 | const scrollRef = useAnimatedRef();
37 | const scrollOffset = useScrollViewOffset(scrollRef);
38 | const contentHeight = total * size;
39 | const { height } = useWindowDimensions();
40 | const isActive: SharedValue = useSharedValue(false);
41 | const isDragActive: SharedValue = useSharedValue(false);
42 | const initialVal: SharedValue = useSharedValue(10);
43 | const indicatorOffset: SharedValue = useSharedValue(10);
44 | const deviceHeight = height - bottom - headerHeight - indicatorSize;
45 |
46 | const numbersAlphabetically = [
47 | "Zero",
48 | "One Hundred",
49 | "Two Hundred",
50 | "Three Hundred",
51 | "Four Hundred",
52 | "Five Hundred",
53 | "Six Hundred",
54 | "Seven Hundred",
55 | "Eight Hundred",
56 | "Nine Hundred",
57 | "Ten Hundred",
58 | ];
59 |
60 | useDerivedValue(() => {
61 | if (isDragActive.value) {
62 | runOnJS(setActiveBlock)(+(scrollOffset.value / total / 10).toFixed(0));
63 | }
64 | });
65 |
66 | const animatedScrubberBg = useAnimatedStyle(() => {
67 | return {
68 | backgroundColor: withTiming(
69 | isActive.value ? theme.colors.primary : theme.colors.indicator,
70 | { duration: 200 }
71 | ),
72 | };
73 | });
74 |
75 | const animatedScrubber = useAnimatedStyle(() => {
76 | return {
77 | opacity: withTiming(isDragActive.value ? 1 : 0, { duration: 200 }),
78 | transform: [{ translateX: withTiming(isDragActive.value ? 0 : 20) }],
79 | };
80 | });
81 |
82 | const animatedScrubberContainer = useAnimatedStyle(() => {
83 | return {
84 | transform: [
85 | {
86 | translateY: isDragActive.value
87 | ? indicatorOffset.value
88 | : interpolate(
89 | scrollOffset.value,
90 | [0, contentHeight],
91 | [10, deviceHeight],
92 | Extrapolation.CLAMP
93 | ),
94 | },
95 | ],
96 | };
97 | });
98 |
99 | const pan = Gesture.Pan()
100 | .onStart((e) => {
101 | initialVal.value = interpolate(
102 | scrollOffset.value,
103 | [0, contentHeight],
104 | [10, deviceHeight],
105 | Extrapolation.CLAMP
106 | );
107 | isDragActive.value = true;
108 | })
109 | .onChange((e) => {
110 | const val = initialVal.value + e.translationY;
111 | const clampVal = Math.min(deviceHeight, Math.max(0, val));
112 | indicatorOffset.value = clampVal;
113 | scrollTo(
114 | scrollRef,
115 | 0,
116 | interpolate(
117 | clampVal,
118 | [10, deviceHeight],
119 | [0, contentHeight],
120 | Extrapolation.CLAMP
121 | ),
122 | false
123 | );
124 | })
125 | .onEnd(() => {
126 | initialVal.value = indicatorOffset.value;
127 | isDragActive.value = false;
128 | isActive.value = false;
129 | });
130 |
131 | return (
132 |
133 |
134 |
139 | {new Array(total).fill(1).map((_, i) => (
140 |
141 |
142 | {i + 1}
143 |
144 |
145 | ))}
146 |
147 |
148 | (isActive.value = true)}
150 | onTouchEnd={() => (isActive.value = false)}
151 | style={[animatedScrubberContainer, styles.scrubContainer]}
152 | >
153 |
161 |
162 | {numbersAlphabetically[activeBlock]}
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 | );
171 | };
172 |
173 | export default Main;
174 |
175 | const styles = StyleSheet.create({
176 | flexOne: {
177 | flex: 1,
178 | },
179 | countWrapper: {
180 | height: size,
181 | justifyContent: "center",
182 | paddingLeft: size / 2,
183 | },
184 | countText: {
185 | fontSize: 18,
186 | fontWeight: "bold",
187 | },
188 | scrubContainer: {
189 | width: 200,
190 | height: indicatorSize,
191 | position: "absolute",
192 | right: 10,
193 | flexDirection: "row",
194 | justifyContent: "flex-end",
195 | alignItems: "center",
196 | gap: 20,
197 | },
198 | scrubLabelWrapper: {
199 | paddingVertical: 8,
200 | paddingHorizontal: 15,
201 | borderRadius: 100,
202 | overflow: "hidden",
203 | },
204 | scrubLabel: {
205 | fontSize: 15,
206 | fontWeight: "500",
207 | color: "white",
208 | textAlign: "center",
209 | },
210 | scrubStyle: {
211 | width: indicatorWidth,
212 | height: indicatorSize,
213 | borderRadius: indicatorWidth / 2,
214 | },
215 | });
216 |
--------------------------------------------------------------------------------
/assets/images/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arunabhverma/expo-anim-scroll-indicator/3f586c1431ac254370af198a1ef4a539420e9717/assets/images/adaptive-icon.png
--------------------------------------------------------------------------------
/assets/images/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arunabhverma/expo-anim-scroll-indicator/3f586c1431ac254370af198a1ef4a539420e9717/assets/images/favicon.png
--------------------------------------------------------------------------------
/assets/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arunabhverma/expo-anim-scroll-indicator/3f586c1431ac254370af198a1ef4a539420e9717/assets/images/icon.png
--------------------------------------------------------------------------------
/assets/images/partial-react-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arunabhverma/expo-anim-scroll-indicator/3f586c1431ac254370af198a1ef4a539420e9717/assets/images/partial-react-logo.png
--------------------------------------------------------------------------------
/assets/images/react-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arunabhverma/expo-anim-scroll-indicator/3f586c1431ac254370af198a1ef4a539420e9717/assets/images/react-logo.png
--------------------------------------------------------------------------------
/assets/images/react-logo@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arunabhverma/expo-anim-scroll-indicator/3f586c1431ac254370af198a1ef4a539420e9717/assets/images/react-logo@2x.png
--------------------------------------------------------------------------------
/assets/images/react-logo@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arunabhverma/expo-anim-scroll-indicator/3f586c1431ac254370af198a1ef4a539420e9717/assets/images/react-logo@3x.png
--------------------------------------------------------------------------------
/assets/images/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arunabhverma/expo-anim-scroll-indicator/3f586c1431ac254370af198a1ef4a539420e9717/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 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "expo-scroll-anim-2",
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 start --android",
9 | "ios": "expo start --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/elements": "^1.3.31",
20 | "@react-navigation/native": "^6.0.2",
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.16.1",
34 | "react-native-reanimated": "~3.10.1",
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 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
51 | }
52 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------