Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at linkat-integration 476 lines 13 kB view raw
1import {useCallback} from 'react' 2import { 3 type LayoutChangeEvent, 4 ScrollView, 5 StyleSheet, 6 View, 7} from 'react-native' 8import Animated, { 9 interpolate, 10 runOnJS, 11 runOnUI, 12 scrollTo, 13 type SharedValue, 14 useAnimatedReaction, 15 useAnimatedRef, 16 useAnimatedStyle, 17 useSharedValue, 18} from 'react-native-reanimated' 19 20import {PressableWithHover} from '#/view/com/util/PressableWithHover' 21import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' 22import {atoms as a, useTheme} from '#/alf' 23import {Text} from '#/components/Typography' 24 25export interface TabBarProps { 26 testID?: string 27 selectedPage: number 28 items: string[] 29 onSelect?: (index: number) => void 30 onPressSelected?: (index: number) => void 31 dragProgress: SharedValue<number> 32 dragState: SharedValue<'idle' | 'dragging' | 'settling'> 33 transparent?: boolean 34} 35 36const ITEM_PADDING = 10 37const CONTENT_PADDING = 6 38// How much of the previous/next item we're requiring 39// when deciding whether to scroll into view on tap. 40const OFFSCREEN_ITEM_WIDTH = 20 41 42export function TabBar({ 43 testID, 44 selectedPage, 45 items, 46 onSelect, 47 onPressSelected, 48 dragProgress, 49 dragState, 50 transparent, 51}: TabBarProps) { 52 const t = useTheme() 53 const scrollElRef = useAnimatedRef<ScrollView>() 54 const syncScrollState = useSharedValue<'synced' | 'unsynced' | 'needs-sync'>( 55 'synced', 56 ) 57 const didInitialScroll = useSharedValue(false) 58 const contentSize = useSharedValue(0) 59 const containerSize = useSharedValue(0) 60 const scrollX = useSharedValue(0) 61 const layouts = useSharedValue<{x: number; width: number}[]>([]) 62 const textLayouts = useSharedValue<{width: number}[]>([]) 63 const itemsLength = items.length 64 65 const scrollToOffsetJS = useCallback( 66 (x: number) => { 67 scrollElRef.current?.scrollTo({ 68 x, 69 y: 0, 70 animated: true, 71 }) 72 }, 73 [scrollElRef], 74 ) 75 76 const indexToOffset = useCallback( 77 (index: number) => { 78 'worklet' 79 const layout = layouts.get()[index] 80 const availableSize = containerSize.get() - 2 * CONTENT_PADDING 81 if (!layout) { 82 // Should not happen, but fall back to equal sizes. 83 const offsetPerPage = contentSize.get() - availableSize 84 return (index / (itemsLength - 1)) * offsetPerPage 85 } 86 const freeSpace = availableSize - layout.width 87 const accumulatingOffset = interpolate( 88 index, 89 // Gradually shift every next item to the left so that the first item 90 // is positioned like "left: 0" but the last item is like "right: 0". 91 [0, itemsLength - 1], 92 [0, freeSpace], 93 'clamp', 94 ) 95 return layout.x - accumulatingOffset 96 }, 97 [itemsLength, contentSize, containerSize, layouts], 98 ) 99 100 const progressToOffset = useCallback( 101 (progress: number) => { 102 'worklet' 103 return interpolate( 104 progress, 105 [Math.floor(progress), Math.ceil(progress)], 106 [ 107 indexToOffset(Math.floor(progress)), 108 indexToOffset(Math.ceil(progress)), 109 ], 110 'clamp', 111 ) 112 }, 113 [indexToOffset], 114 ) 115 116 // When we know the entire layout for the first time, scroll selection into view. 117 useAnimatedReaction( 118 () => layouts.get().length, 119 (nextLayoutsLength, prevLayoutsLength) => { 120 if (nextLayoutsLength !== prevLayoutsLength) { 121 if ( 122 nextLayoutsLength === itemsLength && 123 didInitialScroll.get() === false 124 ) { 125 didInitialScroll.set(true) 126 const progress = dragProgress.get() 127 const offset = progressToOffset(progress) 128 // It's unclear why we need to go back to JS here. It seems iOS-specific. 129 runOnJS(scrollToOffsetJS)(offset) 130 } 131 } 132 }, 133 ) 134 135 // When you swipe the pager, the tabbar should scroll automatically 136 // as you're dragging the page and then even during deceleration. 137 useAnimatedReaction( 138 () => dragProgress.get(), 139 (nextProgress, prevProgress) => { 140 if ( 141 nextProgress !== prevProgress && 142 dragState.value !== 'idle' && 143 // This is only OK to do when we're 100% sure we're synced. 144 // Otherwise, there would be a jump at the beginning of the swipe. 145 syncScrollState.get() === 'synced' 146 ) { 147 const offset = progressToOffset(nextProgress) 148 scrollTo(scrollElRef, offset, 0, false) 149 } 150 }, 151 ) 152 153 // If the syncing is currently off but you've just finished swiping, 154 // it's an opportunity to resync. It won't feel disruptive because 155 // you're not directly interacting with the tabbar at the moment. 156 useAnimatedReaction( 157 () => dragState.value, 158 (nextDragState, prevDragState) => { 159 if ( 160 nextDragState !== prevDragState && 161 nextDragState === 'idle' && 162 (syncScrollState.get() === 'unsynced' || 163 syncScrollState.get() === 'needs-sync') 164 ) { 165 const progress = dragProgress.get() 166 const offset = progressToOffset(progress) 167 scrollTo(scrollElRef, offset, 0, true) 168 syncScrollState.set('synced') 169 } 170 }, 171 ) 172 173 // When you press on the item, we'll scroll into view -- unless you previously 174 // have scrolled the tabbar manually, in which case it'll re-sync on next press. 175 const onPressUIThread = useCallback( 176 (index: number) => { 177 'worklet' 178 const itemLayout = layouts.get()[index] 179 if (!itemLayout) { 180 // Should not happen. 181 return 182 } 183 const leftEdge = itemLayout.x - OFFSCREEN_ITEM_WIDTH 184 const rightEdge = itemLayout.x + itemLayout.width + OFFSCREEN_ITEM_WIDTH 185 const scrollLeft = scrollX.get() 186 const scrollRight = scrollLeft + containerSize.get() 187 const scrollIntoView = leftEdge < scrollLeft || rightEdge > scrollRight 188 if ( 189 syncScrollState.get() === 'synced' || 190 syncScrollState.get() === 'needs-sync' || 191 scrollIntoView 192 ) { 193 const offset = progressToOffset(index) 194 scrollTo(scrollElRef, offset, 0, true) 195 syncScrollState.set('synced') 196 } else { 197 // The item is already in view so it's disruptive to 198 // scroll right now. Do it on the next opportunity. 199 syncScrollState.set('needs-sync') 200 } 201 }, 202 [ 203 syncScrollState, 204 scrollElRef, 205 scrollX, 206 progressToOffset, 207 containerSize, 208 layouts, 209 ], 210 ) 211 212 const onItemLayout = useCallback( 213 (i: number, layout: {x: number; width: number}) => { 214 'worklet' 215 layouts.modify(ls => { 216 ls[i] = layout 217 return ls 218 }) 219 }, 220 [layouts], 221 ) 222 223 const onTextLayout = useCallback( 224 (i: number, layout: {width: number}) => { 225 'worklet' 226 textLayouts.modify(ls => { 227 ls[i] = layout 228 return ls 229 }) 230 }, 231 [textLayouts], 232 ) 233 234 const indicatorStyle = useAnimatedStyle(() => { 235 if (!_WORKLET) { 236 return {opacity: 0} 237 } 238 const layoutsValue = layouts.get() 239 const textLayoutsValue = textLayouts.get() 240 if ( 241 layoutsValue.length !== itemsLength || 242 textLayoutsValue.length !== itemsLength 243 ) { 244 return { 245 opacity: 0, 246 } 247 } 248 249 function getScaleX(index: number) { 250 const textWidth = textLayoutsValue[index].width 251 const itemWidth = layoutsValue[index].width 252 const minIndicatorWidth = 45 253 const maxIndicatorWidth = itemWidth - 2 * CONTENT_PADDING 254 const indicatorWidth = Math.min( 255 Math.max(minIndicatorWidth, textWidth), 256 maxIndicatorWidth, 257 ) 258 return indicatorWidth / contentSize.get() 259 } 260 261 if (textLayoutsValue.length === 1) { 262 return { 263 opacity: 1, 264 transform: [ 265 { 266 scaleX: getScaleX(0), 267 }, 268 ], 269 } 270 } 271 return { 272 opacity: 1, 273 transform: [ 274 { 275 translateX: interpolate( 276 dragProgress.get(), 277 layoutsValue.map((l, i) => { 278 'worklet' 279 return i 280 }), 281 layoutsValue.map(l => { 282 'worklet' 283 return l.x + l.width / 2 - contentSize.get() / 2 284 }), 285 ), 286 }, 287 { 288 scaleX: interpolate( 289 dragProgress.get(), 290 textLayoutsValue.map((l, i) => { 291 'worklet' 292 return i 293 }), 294 textLayoutsValue.map((l, i) => { 295 'worklet' 296 return getScaleX(i) 297 }), 298 ), 299 }, 300 ], 301 } 302 }) 303 304 const onPressItem = useCallback( 305 (index: number) => { 306 runOnUI(onPressUIThread)(index) 307 onSelect?.(index) 308 if (index === selectedPage) { 309 onPressSelected?.(index) 310 } 311 }, 312 [onSelect, selectedPage, onPressSelected, onPressUIThread], 313 ) 314 315 return ( 316 <View 317 testID={testID} 318 style={[!transparent && t.atoms.bg, a.flex_row]} 319 accessibilityRole="tablist"> 320 <BlockDrawerGesture> 321 <ScrollView 322 testID={`${testID}-selector`} 323 horizontal={true} 324 showsHorizontalScrollIndicator={false} 325 ref={scrollElRef} 326 contentContainerStyle={styles.contentContainer} 327 onLayout={e => { 328 containerSize.set(e.nativeEvent.layout.width) 329 }} 330 onScrollBeginDrag={() => { 331 // Remember that you've manually messed with the tabbar scroll. 332 // This will disable auto-adjustment until after next pager swipe or item tap. 333 syncScrollState.set('unsynced') 334 }} 335 onScroll={e => { 336 scrollX.value = Math.round(e.nativeEvent.contentOffset.x) 337 }}> 338 <Animated.View 339 onLayout={e => { 340 contentSize.set(e.nativeEvent.layout.width) 341 }} 342 style={{flexDirection: 'row', flexGrow: 1}}> 343 {items.map((item, i) => { 344 return ( 345 <TabBarItem 346 key={i} 347 index={i} 348 testID={testID} 349 dragProgress={dragProgress} 350 item={item} 351 onPressItem={onPressItem} 352 onItemLayout={onItemLayout} 353 onTextLayout={onTextLayout} 354 /> 355 ) 356 })} 357 <Animated.View 358 style={[ 359 indicatorStyle, 360 { 361 position: 'absolute', 362 left: 0, 363 bottom: 0, 364 right: 0, 365 borderBottomWidth: 2, 366 borderColor: t.palette.primary_500, 367 }, 368 ]} 369 /> 370 </Animated.View> 371 </ScrollView> 372 </BlockDrawerGesture> 373 <View style={[t.atoms.border_contrast_low, styles.outerBottomBorder]} /> 374 </View> 375 ) 376} 377 378function TabBarItem({ 379 index, 380 testID, 381 dragProgress, 382 item, 383 onPressItem, 384 onItemLayout, 385 onTextLayout, 386}: { 387 index: number 388 testID: string | undefined 389 dragProgress: SharedValue<number> 390 item: string 391 onPressItem: (index: number) => void 392 onItemLayout: (index: number, layout: {x: number; width: number}) => void 393 onTextLayout: (index: number, layout: {width: number}) => void 394}) { 395 const t = useTheme() 396 const style = useAnimatedStyle(() => { 397 if (!_WORKLET) { 398 return {opacity: 0.7} 399 } 400 return { 401 opacity: interpolate( 402 dragProgress.get(), 403 [index - 1, index, index + 1], 404 [0.7, 1, 0.7], 405 'clamp', 406 ), 407 } 408 }) 409 410 const handleLayout = useCallback( 411 (e: LayoutChangeEvent) => { 412 runOnUI(onItemLayout)(index, e.nativeEvent.layout) 413 }, 414 [index, onItemLayout], 415 ) 416 417 const handleTextLayout = useCallback( 418 (e: LayoutChangeEvent) => { 419 runOnUI(onTextLayout)(index, e.nativeEvent.layout) 420 }, 421 [index, onTextLayout], 422 ) 423 424 return ( 425 <View onLayout={handleLayout} style={{flexGrow: 1}}> 426 <PressableWithHover 427 testID={`${testID}-selector-${index}`} 428 style={styles.item} 429 hoverStyle={t.atoms.bg_contrast_25} 430 onPress={() => onPressItem(index)} 431 accessibilityRole="tab"> 432 <Animated.View style={[style, styles.itemInner]}> 433 <Text 434 emoji 435 testID={testID ? `${testID}-${item}` : undefined} 436 style={[styles.itemText, t.atoms.text, a.text_md, a.font_semi_bold]} 437 onLayout={handleTextLayout}> 438 {item} 439 </Text> 440 </Animated.View> 441 </PressableWithHover> 442 </View> 443 ) 444} 445 446const styles = StyleSheet.create({ 447 contentContainer: { 448 flexGrow: 1, 449 backgroundColor: 'transparent', 450 paddingHorizontal: CONTENT_PADDING, 451 }, 452 item: { 453 flexGrow: 1, 454 paddingTop: 10, 455 paddingHorizontal: ITEM_PADDING, 456 justifyContent: 'center', 457 }, 458 itemInner: { 459 alignItems: 'center', 460 flexGrow: 1, 461 paddingBottom: 10, 462 borderBottomWidth: 3, 463 borderBottomColor: 'transparent', 464 }, 465 itemText: { 466 lineHeight: 20, 467 textAlign: 'center', 468 }, 469 outerBottomBorder: { 470 position: 'absolute', 471 left: 0, 472 right: 0, 473 top: '100%', 474 borderBottomWidth: StyleSheet.hairlineWidth, 475 }, 476})