Bluesky app fork with some witchin' additions 💫

[Nicer Tabs] New native pager (#6868)

* Remove tab bar autoscroll

This will be replaced by a different mechanism.

* Track pager drag gesture in a worklet

* Track pager state change in a worklet

* Track offset relative to current page

* Sync scroll to swipe

* Extract TabBarItem

* Sync scroll to swipe properly

* Implement all interactions

* Clarify more hacks

* Simplify the implementation

I was trying to be too smart and this was causing the current page event to lag behind if you continuously drag. Better to let the library do its job.

* Interpolate the indicator

* Fix an infinite swipe loop

* Add TODO

* Animate header color

* Respect initial page

* Keep layouts in a shared value

* Fix profile and types

* Fast path for initial styles

* Scroll to initial

* Factor out a helper

* Fix positioning

* Scroll into view on tap if needed

* Divide free space proportionally

* Scroll into view more aggressively

* Fix corner case

* Ignore spurious event on iOS

* Simplify the condition

Due to RN onLayout event ordering, we know that by now we'll have container and content sizes already.

* Change boolean state to enum

* Better syncing heuristic

* Rm extra return

authored by danabra.mov and committed by

GitHub cd811114 5a313c2d

+443 -85
+2 -3
src/view/com/home/HomeHeader.tsx
··· 1 1 import React from 'react' 2 2 import {useNavigation} from '@react-navigation/native' 3 3 4 - import {usePalette} from '#/lib/hooks/usePalette' 5 4 import {NavigationProp} from '#/lib/routes/types' 6 5 import {FeedSourceInfo} from '#/state/queries/feed' 7 6 import {useSession} from '#/state/session' ··· 19 18 const {feeds} = props 20 19 const {hasSession} = useSession() 21 20 const navigation = useNavigation<NavigationProp>() 22 - const pal = usePalette('default') 23 21 24 22 const hasPinnedCustom = React.useMemo<boolean>(() => { 25 23 if (!hasSession) return false ··· 61 59 onSelect={onSelect} 62 60 testID={props.testID} 63 61 items={items} 64 - indicatorColor={pal.colors.link} 62 + dragProgress={props.dragProgress} 63 + dragState={props.dragState} 65 64 /> 66 65 </HomeHeaderLayout> 67 66 )
+95 -20
src/view/com/pager/Pager.tsx
··· 1 1 import React, {forwardRef} from 'react' 2 2 import {View} from 'react-native' 3 3 import PagerView, { 4 + PagerViewOnPageScrollEventData, 4 5 PagerViewOnPageSelectedEvent, 5 - PageScrollStateChangedNativeEvent, 6 + PagerViewOnPageSelectedEventData, 7 + PageScrollStateChangedNativeEventData, 6 8 } from 'react-native-pager-view' 9 + import Animated, { 10 + runOnJS, 11 + SharedValue, 12 + useEvent, 13 + useHandler, 14 + useSharedValue, 15 + } from 'react-native-reanimated' 7 16 8 17 import {atoms as a, native} from '#/alf' 9 18 ··· 17 26 selectedPage: number 18 27 onSelect?: (index: number) => void 19 28 tabBarAnchor?: JSX.Element | null | undefined // Ignored on native. 29 + dragProgress: SharedValue<number> // Ignored on web. 30 + dragState: SharedValue<'idle' | 'dragging' | 'settling'> // Ignored on web. 20 31 } 21 32 export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element 22 33 ··· 29 40 ) => void 30 41 testID?: string 31 42 } 43 + 44 + const AnimatedPagerView = Animated.createAnimatedComponent(PagerView) 45 + 32 46 export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( 33 47 function PagerImpl( 34 48 { 35 49 children, 36 50 initialPage = 0, 37 51 renderTabBar, 38 - onPageScrollStateChanged, 39 - onPageSelected, 52 + onPageScrollStateChanged: parentOnPageScrollStateChanged, 53 + onPageSelected: parentOnPageSelected, 40 54 testID, 41 55 }: React.PropsWithChildren<Props>, 42 56 ref, 43 57 ) { 44 - const [selectedPage, setSelectedPage] = React.useState(0) 58 + const [selectedPage, setSelectedPage] = React.useState(initialPage) 45 59 const pagerView = React.useRef<PagerView>(null) 46 60 47 61 React.useImperativeHandle(ref, () => ({ ··· 50 64 }, 51 65 })) 52 66 53 - const onPageSelectedInner = React.useCallback( 54 - (e: PageSelectedEvent) => { 55 - setSelectedPage(e.nativeEvent.position) 56 - onPageSelected?.(e.nativeEvent.position) 57 - }, 58 - [setSelectedPage, onPageSelected], 59 - ) 60 - 61 - const handlePageScrollStateChanged = React.useCallback( 62 - (e: PageScrollStateChangedNativeEvent) => { 63 - onPageScrollStateChanged?.(e.nativeEvent.pageScrollState) 67 + const onPageSelectedJSThread = React.useCallback( 68 + (nextPosition: number) => { 69 + setSelectedPage(nextPosition) 70 + parentOnPageSelected?.(nextPosition) 64 71 }, 65 - [onPageScrollStateChanged], 72 + [setSelectedPage, parentOnPageSelected], 66 73 ) 67 74 68 75 const onTabBarSelect = React.useCallback( ··· 72 79 [pagerView], 73 80 ) 74 81 82 + const dragState = useSharedValue<'idle' | 'settling' | 'dragging'>('idle') 83 + const dragProgress = useSharedValue(selectedPage) 84 + const didInit = useSharedValue(false) 85 + const handlePageScroll = usePagerHandlers( 86 + { 87 + onPageScroll(e: PagerViewOnPageScrollEventData) { 88 + 'worklet' 89 + if (didInit.get() === false) { 90 + // On iOS, there's a spurious scroll event with 0 position 91 + // even if a different page was supplied as the initial page. 92 + // Ignore it and wait for the first confirmed selection instead. 93 + return 94 + } 95 + dragProgress.set(e.offset + e.position) 96 + }, 97 + onPageScrollStateChanged(e: PageScrollStateChangedNativeEventData) { 98 + 'worklet' 99 + if (dragState.get() === 'idle' && e.pageScrollState === 'settling') { 100 + // This is a programmatic scroll on Android. 101 + // Stay "idle" to match iOS and avoid confusing downstream code. 102 + return 103 + } 104 + dragState.set(e.pageScrollState) 105 + parentOnPageScrollStateChanged?.(e.pageScrollState) 106 + }, 107 + onPageSelected(e: PagerViewOnPageSelectedEventData) { 108 + 'worklet' 109 + didInit.set(true) 110 + runOnJS(onPageSelectedJSThread)(e.position) 111 + }, 112 + }, 113 + [parentOnPageScrollStateChanged], 114 + ) 115 + 75 116 return ( 76 117 <View testID={testID} style={[a.flex_1, native(a.overflow_hidden)]}> 77 118 {renderTabBar({ 78 119 selectedPage, 79 120 onSelect: onTabBarSelect, 121 + dragProgress, 122 + dragState, 80 123 })} 81 - <PagerView 124 + <AnimatedPagerView 82 125 ref={pagerView} 83 126 style={[a.flex_1]} 84 127 initialPage={initialPage} 85 - onPageScrollStateChanged={handlePageScrollStateChanged} 86 - onPageSelected={onPageSelectedInner}> 128 + onPageScroll={handlePageScroll}> 87 129 {children} 88 - </PagerView> 130 + </AnimatedPagerView> 89 131 </View> 90 132 ) 91 133 }, 92 134 ) 135 + 136 + function usePagerHandlers( 137 + handlers: { 138 + onPageScroll: (e: PagerViewOnPageScrollEventData) => void 139 + onPageScrollStateChanged: (e: PageScrollStateChangedNativeEventData) => void 140 + onPageSelected: (e: PagerViewOnPageSelectedEventData) => void 141 + }, 142 + dependencies: unknown[], 143 + ) { 144 + const {doDependenciesDiffer} = useHandler(handlers as any, dependencies) 145 + const subscribeForEvents = [ 146 + 'onPageScroll', 147 + 'onPageScrollStateChanged', 148 + 'onPageSelected', 149 + ] 150 + return useEvent( 151 + event => { 152 + 'worklet' 153 + const {onPageScroll, onPageScrollStateChanged, onPageSelected} = handlers 154 + if (event.eventName.endsWith('onPageScroll')) { 155 + onPageScroll(event as any as PagerViewOnPageScrollEventData) 156 + } else if (event.eventName.endsWith('onPageScrollStateChanged')) { 157 + onPageScrollStateChanged( 158 + event as any as PageScrollStateChangedNativeEventData, 159 + ) 160 + } else if (event.eventName.endsWith('onPageSelected')) { 161 + onPageSelected(event as any as PagerViewOnPageSelectedEventData) 162 + } 163 + }, 164 + subscribeForEvents, 165 + doDependenciesDiffer, 166 + ) 167 + }
+8
src/view/com/pager/PagerWithHeader.tsx
··· 97 97 scrollY={scrollY} 98 98 testID={testID} 99 99 allowHeaderOverScroll={allowHeaderOverScroll} 100 + dragProgress={props.dragProgress} 101 + dragState={props.dragState} 100 102 /> 101 103 </PagerHeaderProvider> 102 104 ) ··· 226 228 onCurrentPageSelected, 227 229 onSelect, 228 230 allowHeaderOverScroll, 231 + dragProgress, 232 + dragState, 229 233 }: { 230 234 currentPage: number 231 235 headerOnlyHeight: number ··· 239 243 onCurrentPageSelected?: (index: number) => void 240 244 onSelect?: (index: number) => void 241 245 allowHeaderOverScroll?: boolean 246 + dragProgress: SharedValue<number> 247 + dragState: SharedValue<'idle' | 'dragging' | 'settling'> 242 248 }): React.ReactNode => { 243 249 const headerTransform = useAnimatedStyle(() => { 244 250 const translateY = Math.min(scrollY.get(), headerOnlyHeight) * -1 ··· 297 303 selectedPage={currentPage} 298 304 onSelect={onSelect} 299 305 onPressSelected={onCurrentPageSelected} 306 + dragProgress={dragProgress} 307 + dragState={dragState} 300 308 /> 301 309 </View> 302 310 </Animated.View>
+2
src/view/com/pager/PagerWithHeader.web.tsx
··· 151 151 selectedPage={currentPage} 152 152 onSelect={onSelect} 153 153 onPressSelected={onCurrentPageSelected} 154 + dragProgress={undefined as any /* native-only */} 155 + dragState={undefined as any /* native-only */} 154 156 /> 155 157 </View> 156 158 </>
+332 -61
src/view/com/pager/TabBar.tsx
··· 1 - import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' 1 + import {useCallback} from 'react' 2 2 import {LayoutChangeEvent, ScrollView, StyleSheet, View} from 'react-native' 3 + import Animated, { 4 + interpolate, 5 + runOnJS, 6 + runOnUI, 7 + scrollTo, 8 + SharedValue, 9 + useAnimatedReaction, 10 + useAnimatedRef, 11 + useAnimatedStyle, 12 + useSharedValue, 13 + } from 'react-native-reanimated' 3 14 4 15 import {usePalette} from '#/lib/hooks/usePalette' 5 16 import {PressableWithHover} from '../util/PressableWithHover' ··· 9 20 testID?: string 10 21 selectedPage: number 11 22 items: string[] 12 - indicatorColor?: string 13 23 onSelect?: (index: number) => void 14 24 onPressSelected?: (index: number) => void 25 + dragProgress: SharedValue<number> 26 + dragState: SharedValue<'idle' | 'dragging' | 'settling'> 15 27 } 16 28 17 - // How much of the previous/next item we're showing 18 - // to give the user a hint there's more to scroll. 29 + const ITEM_PADDING = 10 30 + const CONTENT_PADDING = 6 31 + // How much of the previous/next item we're requiring 32 + // when deciding whether to scroll into view on tap. 19 33 const OFFSCREEN_ITEM_WIDTH = 20 20 34 21 35 export function TabBar({ 22 36 testID, 23 37 selectedPage, 24 38 items, 25 - indicatorColor, 26 39 onSelect, 27 40 onPressSelected, 41 + dragProgress, 42 + dragState, 28 43 }: TabBarProps) { 29 44 const pal = usePalette('default') 30 - const scrollElRef = useRef<ScrollView>(null) 31 - const [itemXs, setItemXs] = useState<number[]>([]) 32 - const indicatorStyle = useMemo( 33 - () => ({borderBottomColor: indicatorColor || pal.colors.link}), 34 - [indicatorColor, pal], 45 + const scrollElRef = useAnimatedRef<ScrollView>() 46 + const syncScrollState = useSharedValue<'synced' | 'unsynced' | 'needs-sync'>( 47 + 'synced', 48 + ) 49 + const didInitialScroll = useSharedValue(false) 50 + const contentSize = useSharedValue(0) 51 + const containerSize = useSharedValue(0) 52 + const scrollX = useSharedValue(0) 53 + const layouts = useSharedValue<{x: number; width: number}[]>([]) 54 + const itemsLength = items.length 55 + 56 + const scrollToOffsetJS = useCallback( 57 + (x: number) => { 58 + scrollElRef.current?.scrollTo({ 59 + x, 60 + y: 0, 61 + animated: true, 62 + }) 63 + }, 64 + [scrollElRef], 65 + ) 66 + 67 + const indexToOffset = useCallback( 68 + (index: number) => { 69 + 'worklet' 70 + const layout = layouts.get()[index] 71 + const availableSize = containerSize.get() - 2 * CONTENT_PADDING 72 + if (!layout) { 73 + // Should not happen, but fall back to equal sizes. 74 + const offsetPerPage = contentSize.get() - availableSize 75 + return (index / (itemsLength - 1)) * offsetPerPage 76 + } 77 + const freeSpace = availableSize - layout.width 78 + const accumulatingOffset = interpolate( 79 + index, 80 + // Gradually shift every next item to the left so that the first item 81 + // is positioned like "left: 0" but the last item is like "right: 0". 82 + [0, itemsLength - 1], 83 + [0, freeSpace], 84 + 'clamp', 85 + ) 86 + return layout.x - accumulatingOffset 87 + }, 88 + [itemsLength, contentSize, containerSize, layouts], 89 + ) 90 + 91 + const progressToOffset = useCallback( 92 + (progress: number) => { 93 + 'worklet' 94 + return interpolate( 95 + progress, 96 + [Math.floor(progress), Math.ceil(progress)], 97 + [ 98 + indexToOffset(Math.floor(progress)), 99 + indexToOffset(Math.ceil(progress)), 100 + ], 101 + 'clamp', 102 + ) 103 + }, 104 + [indexToOffset], 105 + ) 106 + 107 + // When we know the entire layout for the first time, scroll selection into view. 108 + useAnimatedReaction( 109 + () => layouts.get().length, 110 + (nextLayoutsLength, prevLayoutsLength) => { 111 + if (nextLayoutsLength !== prevLayoutsLength) { 112 + if ( 113 + nextLayoutsLength === itemsLength && 114 + didInitialScroll.get() === false 115 + ) { 116 + didInitialScroll.set(true) 117 + const progress = dragProgress.get() 118 + const offset = progressToOffset(progress) 119 + // It's unclear why we need to go back to JS here. It seems iOS-specific. 120 + runOnJS(scrollToOffsetJS)(offset) 121 + } 122 + } 123 + }, 124 + ) 125 + 126 + // When you swipe the pager, the tabbar should scroll automatically 127 + // as you're dragging the page and then even during deceleration. 128 + useAnimatedReaction( 129 + () => dragProgress.get(), 130 + (nextProgress, prevProgress) => { 131 + if ( 132 + nextProgress !== prevProgress && 133 + dragState.value !== 'idle' && 134 + // This is only OK to do when we're 100% sure we're synced. 135 + // Otherwise, there would be a jump at the beginning of the swipe. 136 + syncScrollState.get() === 'synced' 137 + ) { 138 + const offset = progressToOffset(nextProgress) 139 + scrollTo(scrollElRef, offset, 0, false) 140 + } 141 + }, 142 + ) 143 + 144 + // If the syncing is currently off but you've just finished swiping, 145 + // it's an opportunity to resync. It won't feel disruptive because 146 + // you're not directly interacting with the tabbar at the moment. 147 + useAnimatedReaction( 148 + () => dragState.value, 149 + (nextDragState, prevDragState) => { 150 + if ( 151 + nextDragState !== prevDragState && 152 + nextDragState === 'idle' && 153 + (syncScrollState.get() === 'unsynced' || 154 + syncScrollState.get() === 'needs-sync') 155 + ) { 156 + const progress = dragProgress.get() 157 + const offset = progressToOffset(progress) 158 + scrollTo(scrollElRef, offset, 0, true) 159 + syncScrollState.set('synced') 160 + } 161 + }, 162 + ) 163 + 164 + // When you press on the item, we'll scroll into view -- unless you previously 165 + // have scrolled the tabbar manually, in which case it'll re-sync on next press. 166 + const onPressUIThread = useCallback( 167 + (index: number) => { 168 + 'worklet' 169 + const itemLayout = layouts.get()[index] 170 + if (!itemLayout) { 171 + // Should not happen. 172 + return 173 + } 174 + const leftEdge = itemLayout.x - OFFSCREEN_ITEM_WIDTH 175 + const rightEdge = itemLayout.x + itemLayout.width + OFFSCREEN_ITEM_WIDTH 176 + const scrollLeft = scrollX.get() 177 + const scrollRight = scrollLeft + containerSize.get() 178 + const scrollIntoView = leftEdge < scrollLeft || rightEdge > scrollRight 179 + if ( 180 + syncScrollState.get() === 'synced' || 181 + syncScrollState.get() === 'needs-sync' || 182 + scrollIntoView 183 + ) { 184 + const offset = progressToOffset(index) 185 + scrollTo(scrollElRef, offset, 0, true) 186 + syncScrollState.set('synced') 187 + } else { 188 + // The item is already in view so it's disruptive to 189 + // scroll right now. Do it on the next opportunity. 190 + syncScrollState.set('needs-sync') 191 + } 192 + }, 193 + [ 194 + syncScrollState, 195 + scrollElRef, 196 + scrollX, 197 + progressToOffset, 198 + containerSize, 199 + layouts, 200 + ], 201 + ) 202 + 203 + const onItemLayout = useCallback( 204 + (i: number, layout: {x: number; width: number}) => { 205 + 'worklet' 206 + layouts.modify(ls => { 207 + ls[i] = layout 208 + return ls 209 + }) 210 + }, 211 + [layouts], 35 212 ) 36 213 37 - useEffect(() => { 38 - // On native, the primary interaction is swiping. 39 - // We adjust the scroll little by little on every tab change. 40 - // Scroll into view but keep the end of the previous item visible. 41 - let x = itemXs[selectedPage] || 0 42 - x = Math.max(0, x - OFFSCREEN_ITEM_WIDTH) 43 - scrollElRef.current?.scrollTo({x}) 44 - }, [scrollElRef, itemXs, selectedPage]) 214 + const indicatorStyle = useAnimatedStyle(() => { 215 + if (!_WORKLET) { 216 + return {opacity: 0} 217 + } 218 + const layoutsValue = layouts.get() 219 + if ( 220 + layoutsValue.length !== itemsLength || 221 + layoutsValue.some(l => l === undefined) 222 + ) { 223 + return { 224 + opacity: 0, 225 + } 226 + } 227 + if (layoutsValue.length === 1) { 228 + return {opacity: 1} 229 + } 230 + return { 231 + opacity: 1, 232 + transform: [ 233 + { 234 + translateX: interpolate( 235 + dragProgress.get(), 236 + layoutsValue.map((l, i) => i), 237 + layoutsValue.map(l => l.x + l.width / 2 - contentSize.get() / 2), 238 + ), 239 + }, 240 + { 241 + scaleX: interpolate( 242 + dragProgress.get(), 243 + layoutsValue.map((l, i) => i), 244 + layoutsValue.map( 245 + l => (l.width - ITEM_PADDING * 2) / contentSize.get(), 246 + ), 247 + ), 248 + }, 249 + ], 250 + } 251 + }) 45 252 46 253 const onPressItem = useCallback( 47 254 (index: number) => { 255 + runOnUI(onPressUIThread)(index) 48 256 onSelect?.(index) 49 257 if (index === selectedPage) { 50 258 onPressSelected?.(index) 51 259 } 52 260 }, 53 - [onSelect, selectedPage, onPressSelected], 54 - ) 55 - 56 - // calculates the x position of each item on mount and on layout change 57 - const onItemLayout = React.useCallback( 58 - (e: LayoutChangeEvent, index: number) => { 59 - const x = e.nativeEvent.layout.x 60 - setItemXs(prev => { 61 - const Xs = [...prev] 62 - Xs[index] = x 63 - return Xs 64 - }) 65 - }, 66 - [], 261 + [onSelect, selectedPage, onPressSelected, onPressUIThread], 67 262 ) 68 263 69 264 return ( ··· 76 271 horizontal={true} 77 272 showsHorizontalScrollIndicator={false} 78 273 ref={scrollElRef} 79 - contentContainerStyle={styles.contentContainer}> 80 - {items.map((item, i) => { 81 - const selected = i === selectedPage 82 - return ( 83 - <PressableWithHover 84 - testID={`${testID}-selector-${i}`} 85 - key={`${item}-${i}`} 86 - onLayout={e => onItemLayout(e, i)} 87 - style={styles.item} 88 - hoverStyle={pal.viewLight} 89 - onPress={() => onPressItem(i)} 90 - accessibilityRole="tab"> 91 - <View style={[styles.itemInner, selected && indicatorStyle]}> 92 - <Text 93 - emoji 94 - type="lg-bold" 95 - testID={testID ? `${testID}-${item}` : undefined} 96 - style={[ 97 - selected ? pal.text : pal.textLight, 98 - {lineHeight: 20}, 99 - ]}> 100 - {item} 101 - </Text> 102 - </View> 103 - </PressableWithHover> 104 - ) 105 - })} 274 + contentContainerStyle={styles.contentContainer} 275 + onLayout={e => { 276 + containerSize.set(e.nativeEvent.layout.width) 277 + }} 278 + onScrollBeginDrag={() => { 279 + // Remember that you've manually messed with the tabbar scroll. 280 + // This will disable auto-adjustment until after next pager swipe or item tap. 281 + syncScrollState.set('unsynced') 282 + }} 283 + onScroll={e => { 284 + scrollX.value = Math.round(e.nativeEvent.contentOffset.x) 285 + }}> 286 + <Animated.View 287 + onLayout={e => { 288 + contentSize.set(e.nativeEvent.layout.width) 289 + }} 290 + style={{flexDirection: 'row'}}> 291 + {items.map((item, i) => { 292 + return ( 293 + <TabBarItem 294 + key={i} 295 + index={i} 296 + testID={testID} 297 + dragProgress={dragProgress} 298 + item={item} 299 + onPressItem={onPressItem} 300 + onItemLayout={onItemLayout} 301 + /> 302 + ) 303 + })} 304 + <Animated.View 305 + style={[ 306 + indicatorStyle, 307 + { 308 + position: 'absolute', 309 + left: 0, 310 + bottom: 0, 311 + right: 0, 312 + borderBottomWidth: 3, 313 + borderColor: pal.link.color, 314 + }, 315 + ]} 316 + /> 317 + </Animated.View> 106 318 </ScrollView> 107 319 <View style={[pal.border, styles.outerBottomBorder]} /> 108 320 </View> 109 321 ) 110 322 } 111 323 324 + function TabBarItem({ 325 + index, 326 + testID, 327 + dragProgress, 328 + item, 329 + onPressItem, 330 + onItemLayout, 331 + }: { 332 + index: number 333 + testID: string | undefined 334 + dragProgress: SharedValue<number> 335 + item: string 336 + onPressItem: (index: number) => void 337 + onItemLayout: (index: number, layout: {x: number; width: number}) => void 338 + }) { 339 + const pal = usePalette('default') 340 + const style = useAnimatedStyle(() => { 341 + if (!_WORKLET) { 342 + return {opacity: 0.7} 343 + } 344 + return { 345 + opacity: interpolate( 346 + dragProgress.get(), 347 + [index - 1, index, index + 1], 348 + [0.7, 1, 0.7], 349 + 'clamp', 350 + ), 351 + } 352 + }) 353 + 354 + const handleLayout = useCallback( 355 + (e: LayoutChangeEvent) => { 356 + runOnUI(onItemLayout)(index, e.nativeEvent.layout) 357 + }, 358 + [index, onItemLayout], 359 + ) 360 + 361 + return ( 362 + <View onLayout={handleLayout}> 363 + <PressableWithHover 364 + testID={`${testID}-selector-${index}`} 365 + style={styles.item} 366 + hoverStyle={pal.viewLight} 367 + onPress={() => onPressItem(index)} 368 + accessibilityRole="tab"> 369 + <Animated.View style={[style, styles.itemInner]}> 370 + <Text 371 + emoji 372 + type="lg-bold" 373 + testID={testID ? `${testID}-${item}` : undefined} 374 + style={[pal.text, {lineHeight: 20}]}> 375 + {item} 376 + </Text> 377 + </Animated.View> 378 + </PressableWithHover> 379 + </View> 380 + ) 381 + } 382 + 112 383 const styles = StyleSheet.create({ 113 384 outer: { 114 385 flexDirection: 'row', 115 386 }, 116 387 contentContainer: { 117 388 backgroundColor: 'transparent', 118 - paddingHorizontal: 6, 389 + paddingHorizontal: CONTENT_PADDING, 119 390 }, 120 391 item: { 121 392 paddingTop: 10, 122 - paddingHorizontal: 10, 393 + paddingHorizontal: ITEM_PADDING, 123 394 justifyContent: 'center', 124 395 }, 125 396 itemInner: {
+4 -1
src/view/screens/Home.tsx
··· 156 156 setMinimalShellMode(false) 157 157 setDrawerSwipeDisabled(index > 0) 158 158 const feed = allFeeds[index] 159 - setSelectedFeed(feed) 159 + // Mutate the ref before setting state to avoid the imperative syncing effect 160 + // above from starting a loop on Android when swiping back and forth. 160 161 lastPagerReportedIndexRef.current = index 162 + setSelectedFeed(feed) 161 163 logEvent('home:feedDisplayed', { 162 164 index, 163 165 feedType: feed.split('|')[0], ··· 173 175 174 176 const onPageScrollStateChanged = React.useCallback( 175 177 (state: 'idle' | 'dragging' | 'settling') => { 178 + 'worklet' 176 179 if (state === 'dragging') { 177 180 setMinimalShellMode(false) 178 181 }