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