mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import * as React from 'react'
2import {
3 LayoutChangeEvent,
4 NativeScrollEvent,
5 ScrollView,
6 StyleSheet,
7 View,
8} from 'react-native'
9import Animated, {
10 AnimatedRef,
11 runOnJS,
12 runOnUI,
13 scrollTo,
14 SharedValue,
15 useAnimatedRef,
16 useAnimatedStyle,
17 useSharedValue,
18} from 'react-native-reanimated'
19
20import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
21import {ScrollProvider} from '#/lib/ScrollContext'
22import {isIOS} from '#/platform/detection'
23import {Pager, PagerRef, RenderTabBarFnProps} from '#/view/com/pager/Pager'
24import {useTheme} from '#/alf'
25import {ListMethods} from '../util/List'
26import {PagerHeaderProvider} from './PagerHeaderContext'
27import {TabBar} from './TabBar'
28
29export interface PagerWithHeaderChildParams {
30 headerHeight: number
31 isFocused: boolean
32 scrollElRef: React.MutableRefObject<ListMethods | ScrollView | null>
33}
34
35export interface PagerWithHeaderProps {
36 testID?: string
37 children:
38 | (((props: PagerWithHeaderChildParams) => JSX.Element) | null)[]
39 | ((props: PagerWithHeaderChildParams) => JSX.Element)
40 items: string[]
41 isHeaderReady: boolean
42 renderHeader?: ({
43 setMinimumHeight,
44 }: {
45 setMinimumHeight: (height: number) => void
46 }) => JSX.Element
47 initialPage?: number
48 onPageSelected?: (index: number) => void
49 onCurrentPageSelected?: (index: number) => void
50 allowHeaderOverScroll?: boolean
51}
52export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
53 function PageWithHeaderImpl(
54 {
55 children,
56 testID,
57 items,
58 isHeaderReady,
59 renderHeader,
60 initialPage,
61 onPageSelected,
62 onCurrentPageSelected,
63 allowHeaderOverScroll,
64 }: PagerWithHeaderProps,
65 ref,
66 ) {
67 const [currentPage, setCurrentPage] = React.useState(0)
68 const [tabBarHeight, setTabBarHeight] = React.useState(0)
69 const [headerOnlyHeight, setHeaderOnlyHeight] = React.useState(0)
70 const scrollY = useSharedValue(0)
71 const headerHeight = headerOnlyHeight + tabBarHeight
72
73 // capture the header bar sizing
74 const onTabBarLayout = useNonReactiveCallback((evt: LayoutChangeEvent) => {
75 const height = evt.nativeEvent.layout.height
76 if (height > 0) {
77 // The rounding is necessary to prevent jumps on iOS
78 setTabBarHeight(Math.round(height * 2) / 2)
79 }
80 })
81 const onHeaderOnlyLayout = useNonReactiveCallback((height: number) => {
82 if (height > 0) {
83 // The rounding is necessary to prevent jumps on iOS
84 setHeaderOnlyHeight(Math.round(height * 2) / 2)
85 }
86 })
87
88 const renderTabBar = React.useCallback(
89 (props: RenderTabBarFnProps) => {
90 return (
91 <PagerHeaderProvider
92 scrollY={scrollY}
93 headerHeight={headerOnlyHeight}>
94 <PagerTabBar
95 headerOnlyHeight={headerOnlyHeight}
96 items={items}
97 isHeaderReady={isHeaderReady}
98 renderHeader={renderHeader}
99 currentPage={currentPage}
100 onCurrentPageSelected={onCurrentPageSelected}
101 onTabBarLayout={onTabBarLayout}
102 onHeaderOnlyLayout={onHeaderOnlyLayout}
103 onSelect={props.onSelect}
104 scrollY={scrollY}
105 testID={testID}
106 allowHeaderOverScroll={allowHeaderOverScroll}
107 dragProgress={props.dragProgress}
108 dragState={props.dragState}
109 />
110 </PagerHeaderProvider>
111 )
112 },
113 [
114 headerOnlyHeight,
115 items,
116 isHeaderReady,
117 renderHeader,
118 currentPage,
119 onCurrentPageSelected,
120 onTabBarLayout,
121 onHeaderOnlyLayout,
122 scrollY,
123 testID,
124 allowHeaderOverScroll,
125 ],
126 )
127
128 const scrollRefs = useSharedValue<Array<AnimatedRef<any> | null>>([])
129 const registerRef = React.useCallback(
130 (scrollRef: AnimatedRef<any> | null, atIndex: number) => {
131 scrollRefs.modify(refs => {
132 'worklet'
133 refs[atIndex] = scrollRef
134 return refs
135 })
136 },
137 [scrollRefs],
138 )
139
140 const lastForcedScrollY = useSharedValue(0)
141 const adjustScrollForOtherPages = () => {
142 'worklet'
143 const currentScrollY = scrollY.get()
144 const forcedScrollY = Math.min(currentScrollY, headerOnlyHeight)
145 if (lastForcedScrollY.get() !== forcedScrollY) {
146 lastForcedScrollY.set(forcedScrollY)
147 const refs = scrollRefs.get()
148 for (let i = 0; i < refs.length; i++) {
149 const scollRef = refs[i]
150 if (i !== currentPage && scollRef != null) {
151 scrollTo(scollRef, 0, forcedScrollY, false)
152 }
153 }
154 }
155 }
156
157 const throttleTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(
158 null,
159 )
160 const queueThrottledOnScroll = useNonReactiveCallback(() => {
161 if (!throttleTimeout.current) {
162 throttleTimeout.current = setTimeout(() => {
163 throttleTimeout.current = null
164 runOnUI(adjustScrollForOtherPages)()
165 }, 80 /* Sync often enough you're unlikely to catch it unsynced */)
166 }
167 })
168
169 const onScrollWorklet = React.useCallback(
170 (e: NativeScrollEvent) => {
171 'worklet'
172 const nextScrollY = e.contentOffset.y
173 // HACK: onScroll is reporting some strange values on load (negative header height).
174 // Highly improbable that you'd be overscrolled by over 400px -
175 // in fact, I actually can't do it, so let's just ignore those. -sfn
176 const isPossiblyInvalid =
177 headerHeight > 0 && Math.round(nextScrollY * 2) / 2 === -headerHeight
178 if (!isPossiblyInvalid) {
179 scrollY.set(nextScrollY)
180 runOnJS(queueThrottledOnScroll)()
181 }
182 },
183 [scrollY, queueThrottledOnScroll, headerHeight],
184 )
185
186 const onPageSelectedInner = React.useCallback(
187 (index: number) => {
188 setCurrentPage(index)
189 onPageSelected?.(index)
190 },
191 [onPageSelected, setCurrentPage],
192 )
193
194 return (
195 <Pager
196 ref={ref}
197 testID={testID}
198 initialPage={initialPage}
199 onPageSelected={onPageSelectedInner}
200 renderTabBar={renderTabBar}>
201 {toArray(children)
202 .filter(Boolean)
203 .map((child, i) => {
204 const isReady =
205 isHeaderReady && headerOnlyHeight > 0 && tabBarHeight > 0
206 return (
207 <View key={i} collapsable={false}>
208 <PagerItem
209 headerHeight={headerHeight}
210 index={i}
211 isReady={isReady}
212 isFocused={i === currentPage}
213 onScrollWorklet={i === currentPage ? onScrollWorklet : noop}
214 registerRef={registerRef}
215 renderTab={child}
216 />
217 </View>
218 )
219 })}
220 </Pager>
221 )
222 },
223)
224
225let PagerTabBar = ({
226 currentPage,
227 headerOnlyHeight,
228 isHeaderReady,
229 items,
230 scrollY,
231 testID,
232 renderHeader,
233 onHeaderOnlyLayout,
234 onTabBarLayout,
235 onCurrentPageSelected,
236 onSelect,
237 allowHeaderOverScroll,
238 dragProgress,
239 dragState,
240}: {
241 currentPage: number
242 headerOnlyHeight: number
243 isHeaderReady: boolean
244 items: string[]
245 testID?: string
246 scrollY: SharedValue<number>
247 renderHeader?: ({
248 setMinimumHeight,
249 }: {
250 setMinimumHeight: (height: number) => void
251 }) => JSX.Element
252 onHeaderOnlyLayout: (height: number) => void
253 onTabBarLayout: (e: LayoutChangeEvent) => void
254 onCurrentPageSelected?: (index: number) => void
255 onSelect?: (index: number) => void
256 allowHeaderOverScroll?: boolean
257 dragProgress: SharedValue<number>
258 dragState: SharedValue<'idle' | 'dragging' | 'settling'>
259}): React.ReactNode => {
260 const t = useTheme()
261 const [minimumHeaderHeight, setMinimumHeaderHeight] = React.useState(0)
262 const headerTransform = useAnimatedStyle(() => {
263 const translateY =
264 Math.min(
265 scrollY.get(),
266 Math.max(headerOnlyHeight - minimumHeaderHeight, 0),
267 ) * -1
268 return {
269 transform: [
270 {
271 translateY: allowHeaderOverScroll
272 ? translateY
273 : Math.min(translateY, 0),
274 },
275 ],
276 }
277 })
278 const headerRef = React.useRef(null)
279 return (
280 <Animated.View
281 pointerEvents={isIOS ? 'auto' : 'box-none'}
282 style={[styles.tabBarMobile, headerTransform, t.atoms.bg]}>
283 <View
284 ref={headerRef}
285 pointerEvents={isIOS ? 'auto' : 'box-none'}
286 collapsable={false}>
287 {renderHeader?.({setMinimumHeight: setMinimumHeaderHeight})}
288 {
289 // It wouldn't be enough to place `onLayout` on the parent node because
290 // this would risk measuring before `isHeaderReady` has turned `true`.
291 // Instead, we'll render a brand node conditionally and get fresh layout.
292 isHeaderReady && (
293 <View
294 // It wouldn't be enough to do this in a `ref` of an effect because,
295 // even if `isHeaderReady` might have turned `true`, the associated
296 // layout might not have been performed yet on the native side.
297 onLayout={() => {
298 // @ts-ignore
299 headerRef.current?.measure(
300 (_x: number, _y: number, _width: number, height: number) => {
301 onHeaderOnlyLayout(height)
302 },
303 )
304 }}
305 />
306 )
307 }
308 </View>
309 <View
310 onLayout={onTabBarLayout}
311 style={{
312 // Render it immediately to measure it early since its size doesn't depend on the content.
313 // However, keep it invisible until the header above stabilizes in order to prevent jumps.
314 opacity: isHeaderReady ? 1 : 0,
315 pointerEvents: isHeaderReady ? 'auto' : 'none',
316 }}>
317 <TabBar
318 testID={testID}
319 items={items}
320 selectedPage={currentPage}
321 onSelect={onSelect}
322 onPressSelected={onCurrentPageSelected}
323 dragProgress={dragProgress}
324 dragState={dragState}
325 />
326 </View>
327 </Animated.View>
328 )
329}
330PagerTabBar = React.memo(PagerTabBar)
331
332function PagerItem({
333 headerHeight,
334 index,
335 isReady,
336 isFocused,
337 onScrollWorklet,
338 renderTab,
339 registerRef,
340}: {
341 headerHeight: number
342 index: number
343 isFocused: boolean
344 isReady: boolean
345 registerRef: (scrollRef: AnimatedRef<any> | null, atIndex: number) => void
346 onScrollWorklet: (e: NativeScrollEvent) => void
347 renderTab: ((props: PagerWithHeaderChildParams) => JSX.Element) | null
348}) {
349 const scrollElRef = useAnimatedRef()
350
351 React.useEffect(() => {
352 registerRef(scrollElRef, index)
353 return () => {
354 registerRef(null, index)
355 }
356 }, [scrollElRef, registerRef, index])
357
358 if (!isReady || renderTab == null) {
359 return null
360 }
361
362 return (
363 <ScrollProvider onScroll={onScrollWorklet}>
364 {renderTab({
365 headerHeight,
366 isFocused,
367 scrollElRef: scrollElRef as React.MutableRefObject<
368 ListMethods | ScrollView | null
369 >,
370 })}
371 </ScrollProvider>
372 )
373}
374
375const styles = StyleSheet.create({
376 tabBarMobile: {
377 position: 'absolute',
378 zIndex: 1,
379 top: 0,
380 left: 0,
381 width: '100%',
382 },
383})
384
385function noop() {
386 'worklet'
387}
388
389function toArray<T>(v: T | T[]): T[] {
390 if (Array.isArray(v)) {
391 return v
392 }
393 return [v]
394}