├── .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 | --------------------------------------------------------------------------------