diff --git a/src/view/com/home/HomeHeader.tsx b/src/view/com/home/HomeHeader.tsx index 31c7135634d..0ec9ac753e7 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 f0e686b6abc..da7fd1e936b 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -1,9 +1,18 @@ import React, {forwardRef} from 'react' import {View} from 'react-native' import PagerView, { + PagerViewOnPageScrollEventData, PagerViewOnPageSelectedEvent, - PageScrollStateChangedNativeEvent, + PagerViewOnPageSelectedEventData, + PageScrollStateChangedNativeEventData, } from 'react-native-pager-view' +import Animated, { + runOnJS, + SharedValue, + useEvent, + useHandler, + useSharedValue, +} from 'react-native-reanimated' import {atoms as a, native} from '#/alf' @@ -17,6 +26,8 @@ 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,19 +40,22 @@ interface Props { ) => void testID?: string } + +const AnimatedPagerView = Animated.createAnimatedComponent(PagerView) + export const Pager = forwardRef>( function PagerImpl( { children, initialPage = 0, renderTabBar, - onPageScrollStateChanged, - onPageSelected, + onPageScrollStateChanged: parentOnPageScrollStateChanged, + onPageSelected: parentOnPageSelected, testID, }: React.PropsWithChildren, ref, ) { - const [selectedPage, setSelectedPage] = React.useState(0) + const [selectedPage, setSelectedPage] = React.useState(initialPage) const pagerView = React.useRef(null) React.useImperativeHandle(ref, () => ({ @@ -50,19 +64,12 @@ export const Pager = forwardRef>( }, })) - const onPageSelectedInner = React.useCallback( - (e: PageSelectedEvent) => { - setSelectedPage(e.nativeEvent.position) - onPageSelected?.(e.nativeEvent.position) - }, - [setSelectedPage, onPageSelected], - ) - - const handlePageScrollStateChanged = React.useCallback( - (e: PageScrollStateChangedNativeEvent) => { - onPageScrollStateChanged?.(e.nativeEvent.pageScrollState) + const onPageSelectedJSThread = React.useCallback( + (nextPosition: number) => { + setSelectedPage(nextPosition) + parentOnPageSelected?.(nextPosition) }, - [onPageScrollStateChanged], + [setSelectedPage, parentOnPageSelected], ) const onTabBarSelect = React.useCallback( @@ -72,21 +79,89 @@ export const Pager = forwardRef>( [pagerView], ) + const dragState = useSharedValue<'idle' | 'settling' | 'dragging'>('idle') + const dragProgress = useSharedValue(selectedPage) + const didInit = useSharedValue(false) + const handlePageScroll = usePagerHandlers( + { + onPageScroll(e: PagerViewOnPageScrollEventData) { + 'worklet' + if (didInit.get() === false) { + // On iOS, there's a spurious scroll event with 0 position + // even if a different page was supplied as the initial page. + // Ignore it and wait for the first confirmed selection instead. + return + } + 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' + didInit.set(true) + runOnJS(onPageSelectedJSThread)(e.position) + }, + }, + [parentOnPageScrollStateChanged], + ) + return ( {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/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx index 1aa45ffba7e..6174459647d 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} /> ) @@ -226,6 +228,8 @@ let PagerTabBar = ({ onCurrentPageSelected, onSelect, allowHeaderOverScroll, + dragProgress, + dragState, }: { currentPage: number headerOnlyHeight: number @@ -239,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 @@ -297,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 dd00264050c..13c723f4715 100644 --- a/src/view/com/pager/PagerWithHeader.web.tsx +++ b/src/view/com/pager/PagerWithHeader.web.tsx @@ -151,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 3f453971c85..c19b9366408 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -1,5 +1,16 @@ -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 {PressableWithHover} from '../util/PressableWithHover' @@ -9,61 +20,245 @@ 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 [itemXs, setItemXs] = useState([]) - const indicatorStyle = useMemo( - () => ({borderBottomColor: indicatorColor || pal.colors.link}), - [indicatorColor, pal], + const scrollElRef = useAnimatedRef() + const syncScrollState = useSharedValue<'synced' | 'unsynced' | 'needs-sync'>( + 'synced', ) + 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 - useEffect(() => { - // 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}) - }, [scrollElRef, itemXs, selectedPage]) + const scrollToOffsetJS = useCallback( + (x: number) => { + scrollElRef.current?.scrollTo({ + x, + y: 0, + animated: true, + }) + }, + [scrollElRef], + ) - const onPressItem = useCallback( + const indexToOffset = useCallback( (index: number) => { - onSelect?.(index) - if (index === selectedPage) { - onPressSelected?.(index) + '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 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( + () => layouts.get().length, + (nextLayoutsLength, prevLayoutsLength) => { + if (nextLayoutsLength !== prevLayoutsLength) { + if ( + nextLayoutsLength === 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) + } + } + }, + ) + + // 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' && + // This is only OK to do when we're 100% sure we're synced. + // Otherwise, there would be a jump at the beginning of the swipe. + syncScrollState.get() === 'synced' + ) { + const offset = progressToOffset(nextProgress) + scrollTo(scrollElRef, offset, 0, false) + } + }, + ) + + // If the syncing is currently off but you've just finished swiping, + // it's an opportunity to resync. It won't feel disruptive because + // you're not directly interacting with the tabbar at the moment. + useAnimatedReaction( + () => dragState.value, + (nextDragState, prevDragState) => { + if ( + nextDragState !== prevDragState && + nextDragState === 'idle' && + (syncScrollState.get() === 'unsynced' || + syncScrollState.get() === 'needs-sync') + ) { + const progress = dragProgress.get() + const offset = progressToOffset(progress) + scrollTo(scrollElRef, offset, 0, true) + syncScrollState.set('synced') + } + }, + ) + + // 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 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 ( + syncScrollState.get() === 'synced' || + syncScrollState.get() === 'needs-sync' || + scrollIntoView + ) { + const offset = progressToOffset(index) + scrollTo(scrollElRef, offset, 0, true) + syncScrollState.set('synced') + } else { + // The item is already in view so it's disruptive to + // scroll right now. Do it on the next opportunity. + syncScrollState.set('needs-sync') } }, - [onSelect, selectedPage, onPressSelected], + [ + syncScrollState, + scrollElRef, + scrollX, + progressToOffset, + containerSize, + layouts, + ], ) - // 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 + 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, + } + } + 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, onPressUIThread], ) return ( @@ -76,50 +271,126 @@ export function TabBar({ horizontal={true} showsHorizontalScrollIndicator={false} ref={scrollElRef} - contentContainerStyle={styles.contentContainer}> - {items.map((item, i) => { - const selected = i === selectedPage - return ( - 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. + syncScrollState.set('unsynced') + }} + onScroll={e => { + scrollX.value = Math.round(e.nativeEvent.contentOffset.x) + }}> + { + contentSize.set(e.nativeEvent.layout.width) + }} + style={{flexDirection: 'row'}}> + {items.map((item, i) => { + return ( + + ) + })} + + ) } +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 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/screens/Home.tsx b/src/view/screens/Home.tsx index 7bd0b6e576f..1218a5ba00d 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -156,8 +156,10 @@ 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 + setSelectedFeed(feed) logEvent('home:feedDisplayed', { index, feedType: feed.split('|')[0], @@ -173,6 +175,7 @@ function HomeScreenReady({ const onPageScrollStateChanged = React.useCallback( (state: 'idle' | 'dragging' | 'settling') => { + 'worklet' if (state === 'dragging') { setMinimalShellMode(false) }