Bluesky app fork with some witchin' additions 馃挮
at main 407 lines 12 kB view raw
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}