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