diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index f8c6d181c4..674562f824 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -80,12 +80,6 @@ export type LogEvents = { feedUrl: string feedType: string index: number - reason: - | 'focus' - | 'tabbar-click' - | 'pager-swipe' - | 'desktop-sidebar-click' - | 'starter-pack-initial-feed' } 'feed:endReached': { feedUrl: string diff --git a/src/view/com/home/HomeHeader.tsx b/src/view/com/home/HomeHeader.tsx index 31c7135634..0ec9ac753e 100644 --- a/src/view/com/home/HomeHeader.tsx +++ b/src/view/com/home/HomeHeader.tsx @@ -1,7 +1,6 @@ import React from 'react' import {useNavigation} from '@react-navigation/native' -import {usePalette} from '#/lib/hooks/usePalette' import {NavigationProp} from '#/lib/routes/types' import {FeedSourceInfo} from '#/state/queries/feed' import {useSession} from '#/state/session' @@ -19,7 +18,6 @@ export function HomeHeader( const {feeds} = props const {hasSession} = useSession() const navigation = useNavigation() - const pal = usePalette('default') const hasPinnedCustom = React.useMemo(() => { if (!hasSession) return false @@ -61,7 +59,8 @@ export function HomeHeader( onSelect={onSelect} testID={props.testID} items={items} - indicatorColor={pal.colors.link} + dragProgress={props.dragProgress} + dragState={props.dragState} /> ) diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index de04099917..23a0cd05f4 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -1,27 +1,33 @@ import React, {forwardRef} from 'react' import {View} from 'react-native' import PagerView, { - PagerViewOnPageScrollEvent, + PagerViewOnPageScrollEventData, PagerViewOnPageSelectedEvent, - PageScrollStateChangedNativeEvent, + PagerViewOnPageSelectedEventData, + PageScrollStateChangedNativeEventData, } from 'react-native-pager-view' +import Animated, { + runOnJS, + SharedValue, + useEvent, + useHandler, + useSharedValue, +} from 'react-native-reanimated' -import {LogEvents} from '#/lib/statsig/events' import {atoms as a, native} from '#/alf' export type PageSelectedEvent = PagerViewOnPageSelectedEvent export interface PagerRef { - setPage: ( - index: number, - reason: LogEvents['home:feedDisplayed']['reason'], - ) => void + setPage: (index: number) => void } export interface RenderTabBarFnProps { selectedPage: number onSelect?: (index: number) => void tabBarAnchor?: JSX.Element | null | undefined // Ignored on native. + dragProgress: SharedValue // Ignored on web. + dragState: SharedValue<'idle' | 'dragging' | 'settling'> // Ignored on web. } export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element @@ -29,106 +35,74 @@ interface Props { initialPage?: number renderTabBar: RenderTabBarFn onPageSelected?: (index: number) => void - onPageSelecting?: ( - index: number, - reason: LogEvents['home:feedDisplayed']['reason'], - ) => void onPageScrollStateChanged?: ( scrollState: 'idle' | 'dragging' | 'settling', ) => void testID?: string } + +const AnimatedPagerView = Animated.createAnimatedComponent(PagerView) + export const Pager = forwardRef>( function PagerImpl( { children, initialPage = 0, renderTabBar, - onPageScrollStateChanged, - onPageSelected, - onPageSelecting, + onPageScrollStateChanged: parentOnPageScrollStateChanged, + onPageSelected: parentOnPageSelected, testID, }: React.PropsWithChildren, ref, ) { - const [selectedPage, setSelectedPage] = React.useState(0) - const lastOffset = React.useRef(0) - const lastDirection = React.useRef(0) - const scrollState = React.useRef('') + const [selectedPage, setSelectedPage] = React.useState(initialPage) const pagerView = React.useRef(null) React.useImperativeHandle(ref, () => ({ - setPage: ( - index: number, - reason: LogEvents['home:feedDisplayed']['reason'], - ) => { + setPage: (index: number) => { pagerView.current?.setPage(index) - onPageSelecting?.(index, reason) }, })) - const onPageSelectedInner = React.useCallback( - (e: PageSelectedEvent) => { - setSelectedPage(e.nativeEvent.position) - onPageSelected?.(e.nativeEvent.position) - }, - [setSelectedPage, onPageSelected], - ) - - const onPageScroll = React.useCallback( - (e: PagerViewOnPageScrollEvent) => { - const {position, offset} = e.nativeEvent - if (offset === 0) { - // offset hits 0 in some awkward spots so we ignore it - return - } - // NOTE - // we want to call `onPageSelecting` as soon as the scroll-gesture - // enters the "settling" phase, which means the user has released it - // we can't infer directionality from the scroll information, so we - // track the offset changes. if the offset delta is consistent with - // the existing direction during the settling phase, we can say for - // certain where it's going and can fire - // -prf - if (scrollState.current === 'settling') { - if (lastDirection.current === -1 && offset < lastOffset.current) { - onPageSelecting?.(position, 'pager-swipe') - setSelectedPage(position) - lastDirection.current = 0 - } else if ( - lastDirection.current === 1 && - offset > lastOffset.current - ) { - onPageSelecting?.(position + 1, 'pager-swipe') - setSelectedPage(position + 1) - lastDirection.current = 0 - } - } else { - if (offset < lastOffset.current) { - lastDirection.current = -1 - } else if (offset > lastOffset.current) { - lastDirection.current = 1 - } - } - lastOffset.current = offset + const onPageSelectedJSThread = React.useCallback( + (nextPosition: number) => { + setSelectedPage(nextPosition) + parentOnPageSelected?.(nextPosition) }, - [lastOffset, lastDirection, onPageSelecting], - ) - - const handlePageScrollStateChanged = React.useCallback( - (e: PageScrollStateChangedNativeEvent) => { - scrollState.current = e.nativeEvent.pageScrollState - onPageScrollStateChanged?.(e.nativeEvent.pageScrollState) - }, - [scrollState, onPageScrollStateChanged], + [setSelectedPage, parentOnPageSelected], ) const onTabBarSelect = React.useCallback( (index: number) => { pagerView.current?.setPage(index) - onPageSelecting?.(index, 'tabbar-click') }, - [pagerView, onPageSelecting], + [pagerView], + ) + + const dragState = useSharedValue<'idle' | 'settling' | 'dragging'>('idle') + const dragProgress = useSharedValue(selectedPage) + const handlePageScroll = usePagerHandlers( + { + onPageScroll(e: PagerViewOnPageScrollEventData) { + 'worklet' + dragProgress.set(e.offset + e.position) + }, + onPageScrollStateChanged(e: PageScrollStateChangedNativeEventData) { + 'worklet' + if (dragState.get() === 'idle' && e.pageScrollState === 'settling') { + // This is a programmatic scroll on Android. + // Stay "idle" to match iOS and avoid confusing downstream code. + return + } + dragState.set(e.pageScrollState) + parentOnPageScrollStateChanged?.(e.pageScrollState) + }, + onPageSelected(e: PagerViewOnPageSelectedEventData) { + 'worklet' + runOnJS(onPageSelectedJSThread)(e.position) + }, + }, + [parentOnPageScrollStateChanged], ) return ( @@ -136,17 +110,50 @@ export const Pager = forwardRef>( {renderTabBar({ selectedPage, onSelect: onTabBarSelect, + dragProgress, + dragState, })} - + onPageScroll={handlePageScroll}> {children} - + ) }, ) + +function usePagerHandlers( + handlers: { + onPageScroll: (e: PagerViewOnPageScrollEventData) => void + onPageScrollStateChanged: (e: PageScrollStateChangedNativeEventData) => void + onPageSelected: (e: PagerViewOnPageSelectedEventData) => void + }, + dependencies: unknown[], +) { + const {doDependenciesDiffer} = useHandler(handlers as any, dependencies) + const subscribeForEvents = [ + 'onPageScroll', + 'onPageScrollStateChanged', + 'onPageSelected', + ] + return useEvent( + event => { + 'worklet' + const {onPageScroll, onPageScrollStateChanged, onPageSelected} = handlers + if (event.eventName.endsWith('onPageScroll')) { + onPageScroll(event as any as PagerViewOnPageScrollEventData) + } else if (event.eventName.endsWith('onPageScrollStateChanged')) { + onPageScrollStateChanged( + event as any as PageScrollStateChangedNativeEventData, + ) + } else if (event.eventName.endsWith('onPageSelected')) { + onPageSelected(event as any as PagerViewOnPageSelectedEventData) + } + }, + subscribeForEvents, + doDependenciesDiffer, + ) +} diff --git a/src/view/com/pager/Pager.web.tsx b/src/view/com/pager/Pager.web.tsx index e6909fe10f..c620e73e33 100644 --- a/src/view/com/pager/Pager.web.tsx +++ b/src/view/com/pager/Pager.web.tsx @@ -2,7 +2,6 @@ import React from 'react' import {View} from 'react-native' import {flushSync} from 'react-dom' -import {LogEvents} from '#/lib/statsig/events' import {s} from '#/lib/styles' export interface RenderTabBarFnProps { @@ -16,10 +15,6 @@ interface Props { initialPage?: number renderTabBar: RenderTabBarFn onPageSelected?: (index: number) => void - onPageSelecting?: ( - index: number, - reason: LogEvents['home:feedDisplayed']['reason'], - ) => void } export const Pager = React.forwardRef(function PagerImpl( { @@ -27,7 +22,6 @@ export const Pager = React.forwardRef(function PagerImpl( initialPage = 0, renderTabBar, onPageSelected, - onPageSelecting, }: React.PropsWithChildren, ref, ) { @@ -36,16 +30,13 @@ export const Pager = React.forwardRef(function PagerImpl( const anchorRef = React.useRef(null) React.useImperativeHandle(ref, () => ({ - setPage: ( - index: number, - reason: LogEvents['home:feedDisplayed']['reason'], - ) => { - onTabBarSelect(index, reason) + setPage: (index: number) => { + onTabBarSelect(index) }, })) const onTabBarSelect = React.useCallback( - (index: number, reason: LogEvents['home:feedDisplayed']['reason']) => { + (index: number) => { const scrollY = window.scrollY // We want to determine if the tabbar is already "sticking" at the top (in which // case we should preserve and restore scroll), or if it is somewhere below in the @@ -64,7 +55,6 @@ export const Pager = React.forwardRef(function PagerImpl( flushSync(() => { setSelectedPage(index) onPageSelected?.(index) - onPageSelecting?.(index, reason) }) if (isSticking) { const restoredScrollY = scrollYs.current[index] @@ -75,7 +65,7 @@ export const Pager = React.forwardRef(function PagerImpl( } } }, - [selectedPage, setSelectedPage, onPageSelected, onPageSelecting], + [selectedPage, setSelectedPage, onPageSelected], ) return ( @@ -83,7 +73,7 @@ export const Pager = React.forwardRef(function PagerImpl( {renderTabBar({ selectedPage, tabBarAnchor: , - onSelect: e => onTabBarSelect(e, 'tabbar-click'), + onSelect: e => onTabBarSelect(e), })} {React.Children.map(children, (child, i) => ( diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx index 92b98dc2e6..6174459647 100644 --- a/src/view/com/pager/PagerWithHeader.tsx +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -97,6 +97,8 @@ export const PagerWithHeader = React.forwardRef( scrollY={scrollY} testID={testID} allowHeaderOverScroll={allowHeaderOverScroll} + dragProgress={props.dragProgress} + dragState={props.dragState} /> ) @@ -182,17 +184,12 @@ export const PagerWithHeader = React.forwardRef( [onPageSelected, setCurrentPage], ) - const onPageSelecting = React.useCallback((index: number) => { - setCurrentPage(index) - }, []) - return ( {toArray(children) .filter(Boolean) @@ -231,6 +228,8 @@ let PagerTabBar = ({ onCurrentPageSelected, onSelect, allowHeaderOverScroll, + dragProgress, + dragState, }: { currentPage: number headerOnlyHeight: number @@ -244,6 +243,8 @@ let PagerTabBar = ({ onCurrentPageSelected?: (index: number) => void onSelect?: (index: number) => void allowHeaderOverScroll?: boolean + dragProgress: SharedValue + dragState: SharedValue<'idle' | 'dragging' | 'settling'> }): React.ReactNode => { const headerTransform = useAnimatedStyle(() => { const translateY = Math.min(scrollY.get(), headerOnlyHeight) * -1 @@ -302,6 +303,8 @@ let PagerTabBar = ({ selectedPage={currentPage} onSelect={onSelect} onPressSelected={onCurrentPageSelected} + dragProgress={dragProgress} + dragState={dragState} /> diff --git a/src/view/com/pager/PagerWithHeader.web.tsx b/src/view/com/pager/PagerWithHeader.web.tsx index e72c1f3cc9..13c723f471 100644 --- a/src/view/com/pager/PagerWithHeader.web.tsx +++ b/src/view/com/pager/PagerWithHeader.web.tsx @@ -75,17 +75,12 @@ export const PagerWithHeader = React.forwardRef( [onPageSelected, setCurrentPage], ) - const onPageSelecting = React.useCallback((index: number) => { - setCurrentPage(index) - }, []) - return ( {toArray(children) .filter(Boolean) @@ -156,6 +151,8 @@ let PagerTabBar = ({ selectedPage={currentPage} onSelect={onSelect} onPressSelected={onCurrentPageSelected} + dragProgress={undefined as any /* native-only */} + dragState={undefined as any /* native-only */} /> diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 4e8646c605..e7e4d692b8 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -1,120 +1,264 @@ -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' +import {useCallback} from 'react' import {LayoutChangeEvent, ScrollView, StyleSheet, View} from 'react-native' +import Animated, { + interpolate, + runOnJS, + runOnUI, + scrollTo, + SharedValue, + useAnimatedReaction, + useAnimatedRef, + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated' import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {isNative} from '#/platform/detection' import {PressableWithHover} from '../util/PressableWithHover' import {Text} from '../util/text/Text' -import {DraggableScrollView} from './DraggableScrollView' export interface TabBarProps { testID?: string selectedPage: number items: string[] - indicatorColor?: string onSelect?: (index: number) => void onPressSelected?: (index: number) => void + dragProgress: SharedValue + dragState: SharedValue<'idle' | 'dragging' | 'settling'> } -// How much of the previous/next item we're showing -// to give the user a hint there's more to scroll. +const ITEM_PADDING = 10 +const CONTENT_PADDING = 6 +// How much of the previous/next item we're requiring +// when deciding whether to scroll into view on tap. const OFFSCREEN_ITEM_WIDTH = 20 export function TabBar({ testID, selectedPage, items, - indicatorColor, onSelect, onPressSelected, + dragProgress, + dragState, }: TabBarProps) { const pal = usePalette('default') - const scrollElRef = useRef(null) - const itemRefs = useRef>([]) - const [itemXs, setItemXs] = useState([]) - const indicatorStyle = useMemo( - () => ({borderBottomColor: indicatorColor || pal.colors.link}), - [indicatorColor, pal], + const scrollElRef = useAnimatedRef() + const isSyncingScroll = useSharedValue(true) + const didInitialScroll = useSharedValue(false) + const contentSize = useSharedValue(0) + const containerSize = useSharedValue(0) + const scrollX = useSharedValue(0) + const layouts = useSharedValue<{x: number; width: number}[]>([]) + const itemsLength = items.length + + const scrollToOffsetJS = useCallback( + (x: number) => { + scrollElRef.current?.scrollTo({ + x, + y: 0, + animated: true, + }) + }, + [scrollElRef], ) - const {isDesktop, isTablet} = useWebMediaQueries() - const styles = isDesktop || isTablet ? desktopStyles : mobileStyles - - useEffect(() => { - if (isNative) { - // On native, the primary interaction is swiping. - // We adjust the scroll little by little on every tab change. - // Scroll into view but keep the end of the previous item visible. - let x = itemXs[selectedPage] || 0 - x = Math.max(0, x - OFFSCREEN_ITEM_WIDTH) - scrollElRef.current?.scrollTo({x}) - } else { - // On the web, the primary interaction is tapping. - // Scrolling under tap feels disorienting so only adjust the scroll offset - // when tapping on an item out of view--and we adjust by almost an entire page. - const parent = scrollElRef?.current?.getScrollableNode?.() - if (!parent) { - return + + const indexToOffset = useCallback( + (index: number) => { + 'worklet' + const layout = layouts.get()[index] + const availableSize = containerSize.get() - 2 * CONTENT_PADDING + if (!layout) { + // Should not happen, but fall back to equal sizes. + const offsetPerPage = contentSize.get() - availableSize + return (index / (itemsLength - 1)) * offsetPerPage } - const parentRect = parent.getBoundingClientRect() - if (!parentRect) { - return + const freeSpace = availableSize - layout.width + const accumulatingOffset = interpolate( + index, + // Gradually shift every next item to the left so that the first item + // is positioned like "left: 0" but the last item is like "right: 0". + [0, itemsLength - 1], + [0, freeSpace], + 'clamp', + ) + return layout.x - accumulatingOffset + }, + [itemsLength, contentSize, containerSize, layouts], + ) + + const progressToOffset = useCallback( + (progress: number) => { + 'worklet' + return interpolate( + progress, + [Math.floor(progress), Math.ceil(progress)], + [ + indexToOffset(Math.floor(progress)), + indexToOffset(Math.ceil(progress)), + ], + 'clamp', + ) + }, + [indexToOffset], + ) + + // When we know the entire layout for the first time, scroll selection into view. + useAnimatedReaction( + () => { + return { + layoutsLength: layouts.get().length, + containerSizeValue: containerSize.get(), + contentSizeValue: contentSize.get(), + } + }, + (nextLayouts, prevLayouts) => { + if ( + nextLayouts.containerSizeValue !== prevLayouts?.containerSizeValue || + nextLayouts.contentSizeValue !== prevLayouts?.contentSizeValue || + nextLayouts.layoutsLength !== prevLayouts?.layoutsLength + ) { + if ( + nextLayouts.containerSizeValue !== 0 && + nextLayouts.contentSizeValue !== 0 && + nextLayouts.layoutsLength === itemsLength && + didInitialScroll.get() === false + ) { + didInitialScroll.set(true) + const progress = dragProgress.get() + const offset = progressToOffset(progress) + // It's unclear why we need to go back to JS here. It seems iOS-specific. + runOnJS(scrollToOffsetJS)(offset) + } } - const { - left: parentLeft, - right: parentRight, - width: parentWidth, - } = parentRect - const child = itemRefs.current[selectedPage] - if (!child) { + }, + ) + + // When you swipe the pager, the tabbar should scroll automatically + // as you're dragging the page and then even during deceleration. + useAnimatedReaction( + () => dragProgress.get(), + (nextProgress, prevProgress) => { + if ( + nextProgress !== prevProgress && + dragState.value !== 'idle' && + isSyncingScroll.get() === true + ) { + const offset = progressToOffset(nextProgress) + scrollTo(scrollElRef, offset, 0, false) return } - const childRect = child.getBoundingClientRect?.() - if (!childRect) { + }, + ) + + // If you manually scrolled the tabbar, we'll mark the scroll as unsynced. + // We'll re-sync it here (with an animation) if you interact with the pager again. + // From that point on, it'll remain synced again (unless you scroll the tabbar again). + useAnimatedReaction( + () => dragState.value, + (nextDragState, prevDragState) => { + if ( + nextDragState !== prevDragState && + nextDragState === 'idle' && + isSyncingScroll.get() === false + ) { + const progress = dragProgress.get() + const offset = progressToOffset(progress) + scrollTo(scrollElRef, offset, 0, true) + isSyncingScroll.set(true) + } + }, + ) + + // When you press on the item, we'll scroll into view -- unless you previously + // have scrolled the tabbar manually, in which case it'll re-sync on next press. + const onPressUIThread = useCallback( + (index: number) => { + 'worklet' + const itemLayout = layouts.get()[index] + if (!itemLayout) { + // Should not happen. return } - const {left: childLeft, right: childRight, width: childWidth} = childRect - let dx = 0 - if (childRight >= parentRight) { - dx += childRight - parentRight - dx += parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH - } else if (childLeft <= parentLeft) { - dx -= parentLeft - childLeft - dx -= parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH + const leftEdge = itemLayout.x - OFFSCREEN_ITEM_WIDTH + const rightEdge = itemLayout.x + itemLayout.width + OFFSCREEN_ITEM_WIDTH + const scrollLeft = scrollX.get() + const scrollRight = scrollLeft + containerSize.get() + const scrollIntoView = leftEdge < scrollLeft || rightEdge > scrollRight + if (isSyncingScroll.get() === true || scrollIntoView) { + const offset = progressToOffset(index) + scrollTo(scrollElRef, offset, 0, true) } - let x = parent.scrollLeft + dx - x = Math.max(0, x) - x = Math.min(x, parent.scrollWidth - parentWidth) - if (dx !== 0) { - parent.scroll({ - left: x, - behavior: 'smooth', - }) + isSyncingScroll.set(true) + }, + [ + isSyncingScroll, + scrollElRef, + scrollX, + progressToOffset, + containerSize, + layouts, + ], + ) + + const onItemLayout = useCallback( + (i: number, layout: {x: number; width: number}) => { + 'worklet' + layouts.modify(ls => { + ls[i] = layout + return ls + }) + }, + [layouts], + ) + + const indicatorStyle = useAnimatedStyle(() => { + if (!_WORKLET) { + return {opacity: 0} + } + const layoutsValue = layouts.get() + if ( + layoutsValue.length !== itemsLength || + layoutsValue.some(l => l === undefined) + ) { + return { + opacity: 0, } } - }, [scrollElRef, itemXs, selectedPage, styles]) + if (layoutsValue.length === 1) { + return {opacity: 1} + } + return { + opacity: 1, + transform: [ + { + translateX: interpolate( + dragProgress.get(), + layoutsValue.map((l, i) => i), + layoutsValue.map(l => l.x + l.width / 2 - contentSize.get() / 2), + ), + }, + { + scaleX: interpolate( + dragProgress.get(), + layoutsValue.map((l, i) => i), + layoutsValue.map( + l => (l.width - ITEM_PADDING * 2) / contentSize.get(), + ), + ), + }, + ], + } + }) const onPressItem = useCallback( (index: number) => { + runOnUI(onPressUIThread)(index) onSelect?.(index) if (index === selectedPage) { onPressSelected?.(index) } }, - [onSelect, selectedPage, onPressSelected], - ) - - // calculates the x position of each item on mount and on layout change - const onItemLayout = React.useCallback( - (e: LayoutChangeEvent, index: number) => { - const x = e.nativeEvent.layout.x - setItemXs(prev => { - const Xs = [...prev] - Xs[index] = x - return Xs - }) - }, - [], + [onSelect, selectedPage, onPressSelected, onPressUIThread], ) return ( @@ -122,84 +266,131 @@ export function TabBar({ testID={testID} style={[pal.view, styles.outer]} accessibilityRole="tablist"> - - {items.map((item, i) => { - const selected = i === selectedPage - return ( - (itemRefs.current[i] = node as any)} - onLayout={e => onItemLayout(e, i)} - style={styles.item} - hoverStyle={pal.viewLight} - onPress={() => onPressItem(i)} - accessibilityRole="tab"> - - - {item} - - - - ) - })} - + contentContainerStyle={styles.contentContainer} + onLayout={e => { + containerSize.set(e.nativeEvent.layout.width) + }} + onScrollBeginDrag={() => { + // Remember that you've manually messed with the tabbar scroll. + // This will disable auto-adjustment until after next pager swipe or item tap. + isSyncingScroll.set(false) + }} + onScroll={e => { + scrollX.value = Math.round(e.nativeEvent.contentOffset.x) + }}> + { + contentSize.set(e.nativeEvent.layout.width) + }} + style={{flexDirection: 'row'}}> + {items.map((item, i) => { + return ( + + ) + })} + + + ) } -const desktopStyles = StyleSheet.create({ - outer: { - flexDirection: 'row', - width: 598, - }, - contentContainer: { - paddingHorizontal: 0, - backgroundColor: 'transparent', - }, - item: { - paddingTop: 14, - paddingHorizontal: 14, - justifyContent: 'center', - }, - itemInner: { - paddingBottom: 12, - borderBottomWidth: 3, - borderBottomColor: 'transparent', - }, - outerBottomBorder: { - position: 'absolute', - left: 0, - right: 0, - top: '100%', - borderBottomWidth: StyleSheet.hairlineWidth, - }, -}) +function TabBarItem({ + index, + testID, + dragProgress, + item, + onPressItem, + onItemLayout, +}: { + index: number + testID: string | undefined + dragProgress: SharedValue + item: string + onPressItem: (index: number) => void + onItemLayout: (index: number, layout: {x: number; width: number}) => void +}) { + const pal = usePalette('default') + const style = useAnimatedStyle(() => { + if (!_WORKLET) { + return {opacity: 0.7} + } + return { + opacity: interpolate( + dragProgress.get(), + [index - 1, index, index + 1], + [0.7, 1, 0.7], + 'clamp', + ), + } + }) + + const handleLayout = useCallback( + (e: LayoutChangeEvent) => { + runOnUI(onItemLayout)(index, e.nativeEvent.layout) + }, + [index, onItemLayout], + ) + + return ( + + onPressItem(index)} + accessibilityRole="tab"> + + + {item} + + + + + ) +} -const mobileStyles = StyleSheet.create({ +const styles = StyleSheet.create({ outer: { flexDirection: 'row', }, contentContainer: { backgroundColor: 'transparent', - paddingHorizontal: 6, + paddingHorizontal: CONTENT_PADDING, }, item: { paddingTop: 10, - paddingHorizontal: 10, + paddingHorizontal: ITEM_PADDING, justifyContent: 'center', }, itemInner: { diff --git a/src/view/com/pager/TabBar.web.tsx b/src/view/com/pager/TabBar.web.tsx new file mode 100644 index 0000000000..4291a053b5 --- /dev/null +++ b/src/view/com/pager/TabBar.web.tsx @@ -0,0 +1,192 @@ +import {useCallback, useEffect, useMemo, useRef} from 'react' +import {ScrollView, StyleSheet, View} from 'react-native' + +import {usePalette} from '#/lib/hooks/usePalette' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {PressableWithHover} from '../util/PressableWithHover' +import {Text} from '../util/text/Text' +import {DraggableScrollView} from './DraggableScrollView' + +export interface TabBarProps { + testID?: string + selectedPage: number + items: string[] + indicatorColor?: string + onSelect?: (index: number) => void + onPressSelected?: (index: number) => void +} + +// How much of the previous/next item we're showing +// to give the user a hint there's more to scroll. +const OFFSCREEN_ITEM_WIDTH = 20 + +export function TabBar({ + testID, + selectedPage, + items, + indicatorColor, + onSelect, + onPressSelected, +}: TabBarProps) { + const pal = usePalette('default') + const scrollElRef = useRef(null) + const itemRefs = useRef>([]) + const indicatorStyle = useMemo( + () => ({borderBottomColor: indicatorColor || pal.colors.link}), + [indicatorColor, pal], + ) + const {isDesktop, isTablet} = useWebMediaQueries() + const styles = isDesktop || isTablet ? desktopStyles : mobileStyles + + useEffect(() => { + // On the web, the primary interaction is tapping. + // Scrolling under tap feels disorienting so only adjust the scroll offset + // when tapping on an item out of view--and we adjust by almost an entire page. + const parent = scrollElRef?.current?.getScrollableNode?.() + if (!parent) { + return + } + const parentRect = parent.getBoundingClientRect() + if (!parentRect) { + return + } + const { + left: parentLeft, + right: parentRight, + width: parentWidth, + } = parentRect + const child = itemRefs.current[selectedPage] + if (!child) { + return + } + const childRect = child.getBoundingClientRect?.() + if (!childRect) { + return + } + const {left: childLeft, right: childRight, width: childWidth} = childRect + let dx = 0 + if (childRight >= parentRight) { + dx += childRight - parentRight + dx += parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH + } else if (childLeft <= parentLeft) { + dx -= parentLeft - childLeft + dx -= parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH + } + let x = parent.scrollLeft + dx + x = Math.max(0, x) + x = Math.min(x, parent.scrollWidth - parentWidth) + if (dx !== 0) { + parent.scroll({ + left: x, + behavior: 'smooth', + }) + } + }, [scrollElRef, selectedPage, styles]) + + const onPressItem = useCallback( + (index: number) => { + onSelect?.(index) + if (index === selectedPage) { + onPressSelected?.(index) + } + }, + [onSelect, selectedPage, onPressSelected], + ) + + return ( + + + {items.map((item, i) => { + const selected = i === selectedPage + return ( + (itemRefs.current[i] = node as any)} + style={styles.item} + hoverStyle={pal.viewLight} + onPress={() => onPressItem(i)} + accessibilityRole="tab"> + + + {item} + + + + ) + })} + + + + ) +} + +const desktopStyles = StyleSheet.create({ + outer: { + flexDirection: 'row', + width: 598, + }, + contentContainer: { + paddingHorizontal: 0, + backgroundColor: 'transparent', + }, + item: { + paddingTop: 14, + paddingHorizontal: 14, + justifyContent: 'center', + }, + itemInner: { + paddingBottom: 12, + borderBottomWidth: 3, + borderBottomColor: 'transparent', + }, + outerBottomBorder: { + position: 'absolute', + left: 0, + right: 0, + top: '100%', + borderBottomWidth: StyleSheet.hairlineWidth, + }, +}) + +const mobileStyles = StyleSheet.create({ + outer: { + flexDirection: 'row', + }, + contentContainer: { + backgroundColor: 'transparent', + paddingHorizontal: 6, + }, + item: { + paddingTop: 10, + paddingHorizontal: 10, + justifyContent: 'center', + }, + itemInner: { + paddingBottom: 10, + borderBottomWidth: 3, + borderBottomColor: 'transparent', + }, + outerBottomBorder: { + position: 'absolute', + left: 0, + right: 0, + top: '100%', + borderBottomWidth: StyleSheet.hairlineWidth, + }, +}) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index cadfb48903..823f339362 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -11,7 +11,7 @@ import { HomeTabNavigatorParams, NativeStackScreenProps, } from '#/lib/routes/types' -import {logEvent, LogEvents} from '#/lib/statsig/statsig' +import {logEvent} from '#/lib/statsig/statsig' import {isWeb} from '#/platform/detection' import {emitSoftReset} from '#/state/events' import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed' @@ -121,7 +121,7 @@ function HomeScreenReady({ // This is supposed to only happen on the web when you use the right nav. if (selectedIndex !== lastPagerReportedIndexRef.current) { lastPagerReportedIndexRef.current = selectedIndex - pagerRef.current?.setPage(selectedIndex, 'desktop-sidebar-click') + pagerRef.current?.setPage(selectedIndex) } }, [selectedIndex]) @@ -156,23 +156,17 @@ function HomeScreenReady({ setMinimalShellMode(false) setDrawerSwipeDisabled(index > 0) const feed = allFeeds[index] - setSelectedFeed(feed) + // Mutate the ref before setting state to avoid the imperative syncing effect + // above from starting a loop on Android when swiping back and forth. lastPagerReportedIndexRef.current = index - }, - [setDrawerSwipeDisabled, setSelectedFeed, setMinimalShellMode, allFeeds], - ) - - const onPageSelecting = React.useCallback( - (index: number, reason: LogEvents['home:feedDisplayed']['reason']) => { - const feed = allFeeds[index] + setSelectedFeed(feed) logEvent('home:feedDisplayed', { index, feedType: feed.split('|')[0], feedUrl: feed, - reason, }) }, - [allFeeds], + [setDrawerSwipeDisabled, setSelectedFeed, setMinimalShellMode, allFeeds], ) const onPressSelected = React.useCallback(() => { @@ -181,6 +175,7 @@ function HomeScreenReady({ const onPageScrollStateChanged = React.useCallback( (state: 'idle' | 'dragging' | 'settling') => { + 'worklet' if (state === 'dragging') { setMinimalShellMode(false) } @@ -228,7 +223,6 @@ function HomeScreenReady({ ref={pagerRef} testID="homeScreen" initialPage={selectedIndex} - onPageSelecting={onPageSelecting} onPageSelected={onPageSelected} onPageScrollStateChanged={onPageScrollStateChanged} renderTabBar={renderTabBar}>