├── .gitignore
├── App.tsx
├── Provider.tsx
├── app.json
├── assets
├── adaptive-icon.png
├── favicon.png
├── icon.png
└── splash.png
├── babel.config.js
├── bun.lockb
├── package.json
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
2 |
3 | # dependencies
4 | node_modules/
5 |
6 | # Expo
7 | .expo/
8 | dist/
9 | web-build/
10 |
11 | # Native
12 | *.orig.*
13 | *.jks
14 | *.p8
15 | *.p12
16 | *.key
17 | *.mobileprovision
18 |
19 | # Metro
20 | .metro-health-check*
21 |
22 | # debug
23 | npm-debug.*
24 | yarn-debug.*
25 | yarn-error.*
26 |
27 | # macOS
28 | .DS_Store
29 | *.pem
30 |
31 | # local env files
32 | .env*.local
33 |
34 | # typescript
35 | *.tsbuildinfo
36 |
--------------------------------------------------------------------------------
/App.tsx:
--------------------------------------------------------------------------------
1 | import { Text, View } from 'react-native'
2 | import { Provider, ScrollView, StickyItem } from './Provider'
3 |
4 | export default function App() {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 | This is a sticky header
12 |
13 |
14 |
15 |
16 |
17 |
20 | This is a sticky header 2
21 |
22 |
23 |
24 |
25 |
26 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/Provider.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Children,
3 | ComponentProps,
4 | ContextType,
5 | createContext,
6 | useCallback,
7 | useContext,
8 | useEffect,
9 | useId,
10 | useReducer,
11 | useRef,
12 | useState,
13 | } from 'react'
14 | import { ScrollView as Scroll, View, Animated } from 'react-native'
15 | import StickyHeader from 'react-native/Libraries/Components/ScrollView/ScrollViewStickyHeader'
16 |
17 | export function StickyItem({
18 | children,
19 | side = 'top',
20 | index = 0,
21 | }: {
22 | children: React.ReactElement
23 | side?: 'top' | 'bottom'
24 | index?: number
25 | }) {
26 | const {
27 | register,
28 | unregister,
29 | onLayout,
30 | scrollPosition,
31 | scrollViewLayout,
32 | ...context
33 | } = useContext(Context)
34 | const child = Children.only(children)
35 | const id = useId()
36 | const key = child.key || id
37 | useEffect(
38 | function mount() {
39 | return () => {
40 | if (key) unregister(key, side)
41 | }
42 | },
43 | [key, side],
44 | )
45 | const layouts = context[side].layouts
46 | const nextLayout =
47 | index > -1 ? layouts.get(context[side].ids[index + 1]) : null
48 | return (
49 | {
52 | if (key && ref) register(key, side, ref, index)
53 | },
54 | [key, side, index],
55 | )}
56 | hiddenOnScroll={false}
57 | nextHeaderLayoutY={nextLayout}
58 | onLayout={(event: any) => {
59 | onLayout(key, side, event.nativeEvent.layout.y, index)
60 | }}
61 | scrollAnimatedValue={scrollPosition}
62 | scrollViewHeight={scrollViewLayout?.height ?? null}
63 | >
64 | {children}
65 |
66 | )
67 | }
68 |
69 | type ViewRef = View & {
70 | setNextHeaderY: (y: number) => void
71 | }
72 |
73 | const Context = createContext(
74 | null as any as {
75 | top: {
76 | ids: Array
77 | layouts: Map
78 | refs: Map
79 | }
80 | bottom: {
81 | ids: Array
82 | layouts: Map
83 | refs: Map
84 | }
85 | register: (
86 | id: string,
87 | position: 'top' | 'bottom',
88 | ref: ViewRef,
89 | i: number,
90 | ) => void
91 | unregister: (id: string, position: 'top' | 'bottom') => void
92 | onLayout: (
93 | id: string,
94 | position: 'top' | 'bottom',
95 | layout: number,
96 | i: number,
97 | ) => void
98 | scrollPosition: Animated.Value
99 | scrollViewLayout: { width: number; height: number } | null
100 | setScrollViewLayout: (layout: { width: number; height: number }) => void
101 | hasStickyHeaders: boolean
102 | },
103 | )
104 | export function Provider({ children }: { children: React.ReactNode }) {
105 | const [scrollViewLayout, setScrollViewLayout] = useState<{
106 | width: number
107 | height: number
108 | } | null>(null)
109 | const state = useRef, 'top' | 'bottom'>>({
110 | bottom: {
111 | ids: [],
112 | layouts: new Map(),
113 | refs: new Map(),
114 | },
115 | top: {
116 | ids: [],
117 | layouts: new Map(),
118 | refs: new Map(),
119 | },
120 | })
121 | const scrollPosition = useRef(new Animated.Value(0)).current
122 | const [hasStickyHeaders, setHasStickyHeaders] = useState(false)
123 | const [, render] = useReducer(() => ({}), {})
124 | return (
125 | i !== id)
136 | },
137 | onLayout(id, position, layout, index) {
138 | state.current[position].layouts.set(id, layout)
139 |
140 | const nextId = state.current[position].ids[index - 1]
141 |
142 | console.log('[onLayout]', id, state.current[position].ids)
143 |
144 | if (nextId) {
145 | const nextRef = state.current[position].refs.get(nextId)
146 | if (nextRef) {
147 | console.log('next-ref', nextRef, 'layout', layout)
148 | nextRef.setNextHeaderY(layout)
149 | }
150 | }
151 | render()
152 | },
153 | scrollPosition,
154 | scrollViewLayout,
155 | setScrollViewLayout,
156 | hasStickyHeaders,
157 | }}
158 | >
159 | {children}
160 |
161 | )
162 | }
163 | export const ScrollView = (props: ComponentProps) => {
164 | const { top, scrollPosition, setScrollViewLayout, hasStickyHeaders } =
165 | useContext(Context)
166 | return (
167 | {
170 | props.onContentSizeChange?.(width, height)
171 | setScrollViewLayout({ width, height })
172 | }}
173 | onScroll={Animated.event(
174 | [
175 | {
176 | nativeEvent: {
177 | contentOffset: {
178 | y: scrollPosition,
179 | },
180 | },
181 | },
182 | ],
183 | {
184 | useNativeDriver: false,
185 | },
186 | )}
187 | scrollEventThrottle={1}
188 | />
189 | )
190 | }
191 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "sticky",
4 | "slug": "sticky",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/icon.png",
8 | "userInterfaceStyle": "light",
9 | "splash": {
10 | "image": "./assets/splash.png",
11 | "resizeMode": "contain",
12 | "backgroundColor": "#ffffff"
13 | },
14 | "assetBundlePatterns": [
15 | "**/*"
16 | ],
17 | "ios": {
18 | "supportsTablet": true
19 | },
20 | "android": {
21 | "adaptiveIcon": {
22 | "foregroundImage": "./assets/adaptive-icon.png",
23 | "backgroundColor": "#ffffff"
24 | }
25 | },
26 | "web": {
27 | "favicon": "./assets/favicon.png"
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/assets/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nandorojo/sticky/f00a73bcb611cad9e938b4383fd2fe358661f381/assets/adaptive-icon.png
--------------------------------------------------------------------------------
/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nandorojo/sticky/f00a73bcb611cad9e938b4383fd2fe358661f381/assets/favicon.png
--------------------------------------------------------------------------------
/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nandorojo/sticky/f00a73bcb611cad9e938b4383fd2fe358661f381/assets/icon.png
--------------------------------------------------------------------------------
/assets/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nandorojo/sticky/f00a73bcb611cad9e938b4383fd2fe358661f381/assets/splash.png
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function(api) {
2 | api.cache(true);
3 | return {
4 | presets: ['babel-preset-expo'],
5 | };
6 | };
7 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nandorojo/sticky/f00a73bcb611cad9e938b4383fd2fe358661f381/bun.lockb
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sticky",
3 | "version": "1.0.0",
4 | "main": "node_modules/expo/AppEntry.js",
5 | "scripts": {
6 | "start": "expo start",
7 | "android": "expo start --android",
8 | "ios": "expo start --ios",
9 | "web": "expo start --web"
10 | },
11 | "dependencies": {
12 | "expo": "~50.0.6",
13 | "expo-status-bar": "~1.11.1",
14 | "react": "18.2.0",
15 | "react-native": "0.73.4"
16 | },
17 | "devDependencies": {
18 | "@babel/core": "^7.20.0",
19 | "@types/react": "~18.2.45",
20 | "typescript": "^5.1.3"
21 | },
22 | "private": true,
23 | "prettier": {
24 | "tabWidth": 2,
25 | "useTabs": false,
26 | "semi": false,
27 | "singleQuote": true,
28 | "trailingComma": "all",
29 | "bracketSpacing": true,
30 | "jsxBracketSameLine": false
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo/tsconfig.base",
3 | "compilerOptions": {
4 | "strict": true
5 | }
6 | }
7 |
--------------------------------------------------------------------------------