Bluesky app fork with some witchin' additions 馃挮
at readme-update 389 lines 11 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 {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}