From 996871d88ba3298ed53705a6c865cdfe5ede961a Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 3 Dec 2024 01:11:11 +0000 Subject: [PATCH 1/4] Prefetch adjacent Home pages (#6904) --- src/view/com/feeds/FeedPage.tsx | 6 ++++-- src/view/com/posts/Feed.tsx | 4 ++-- src/view/screens/Home.tsx | 5 ++++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index 1028d7e641..44e90a5519 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -32,6 +32,7 @@ const POLL_FREQ = 60e3 // 60sec export function FeedPage({ testID, isPageFocused, + isPageAdjacent, feed, feedParams, renderEmptyState, @@ -42,6 +43,7 @@ export function FeedPage({ feed: FeedDescriptor feedParams?: FeedParams isPageFocused: boolean + isPageAdjacent: boolean renderEmptyState: () => JSX.Element renderEndOfFeed?: () => JSX.Element savedFeedConfig?: AppBskyActorDefs.SavedFeed @@ -111,11 +113,11 @@ export function FeedPage({ { - if (enabled) { + if (enabled && !disablePoll) { const timeSinceFirstLoad = Date.now() - lastFetchRef.current // DISABLED need to check if this is causing random feed refreshes -prf /*if (timeSinceFirstLoad > REFRESH_AFTER) { @@ -231,7 +231,7 @@ let Feed = ({ checkForNewRef.current() } } - }, [enabled, feed, queryClient, scrollElRef]) + }, [enabled, disablePoll, feed, queryClient, scrollElRef]) React.useEffect(() => { let cleanup1: () => void | undefined, cleanup2: () => void | undefined const subscription = AppState.addEventListener('change', nextAppState => { diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index cadfb48903..91c9ae69ab 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -233,7 +233,7 @@ function HomeScreenReady({ onPageScrollStateChanged={onPageScrollStateChanged} renderTabBar={renderTabBar}> {pinnedFeedInfos.length ? ( - pinnedFeedInfos.map(feedInfo => { + pinnedFeedInfos.map((feedInfo, index) => { const feed = feedInfo.feedDescriptor if (feed === 'following') { return ( @@ -241,6 +241,7 @@ function HomeScreenReady({ key={feed} testID="followingFeedPage" isPageFocused={selectedFeed === feed} + isPageAdjacent={Math.abs(selectedIndex - index) === 1} feed={feed} feedParams={homeFeedParams} renderEmptyState={renderFollowingEmptyState} @@ -254,6 +255,7 @@ function HomeScreenReady({ key={feed} testID="customFeedPage" isPageFocused={selectedFeed === feed} + isPageAdjacent={Math.abs(selectedIndex - index) === 1} feed={feed} renderEmptyState={renderCustomFeedEmptyState} savedFeedConfig={savedFeedConfig} @@ -273,6 +275,7 @@ function HomeScreenReady({ From 5a313c2d10b112458830b3bfc708031f6f8726a0 Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 3 Dec 2024 01:12:58 +0000 Subject: [PATCH 2/4] [Nicer Tabs] Fork TabBar, simplify Pager (#6762) * Fork TabBar.web.tsx * Trim dead code from both forks * Remove onPageSelecting event It's difficult to tell what exactly it's supposed to represent, and in practice it's not really used aside from logging. Let's rip it out for now to keep other changes simpler. * Remove early onPageSelected call It was added to try to do some work eagerly when we're sure which way the scroll is snapping. This is not necessarily a good idea though. It schedules a potentially expensive re-render right during the deceleration animation, which is not great. Whatever we're optimizing there, we should optimize smarter (e.g. prewarm just the network call). The other thing it used to help with is triggering the pager header autoscroll earlier. But we're going to rewrite that part differently anyway so that's not relevant either. * Prune more dead code from the native version We'll have to revisit this when adding tablet support but for now I'd prefer to remove a codepath that is not being tested or ever run. * Use regular ScrollView on native The Draggable thing was needed for web-only behavior so we can drop it in the native fork. --- src/lib/statsig/events.ts | 6 - src/view/com/pager/Pager.tsx | 70 +------- src/view/com/pager/Pager.web.tsx | 20 +-- src/view/com/pager/PagerWithHeader.tsx | 5 - src/view/com/pager/PagerWithHeader.web.tsx | 5 - src/view/com/pager/TabBar.tsx | 102 ++--------- src/view/com/pager/TabBar.web.tsx | 192 +++++++++++++++++++++ src/view/screens/Home.tsx | 15 +- 8 files changed, 216 insertions(+), 199 deletions(-) create mode 100644 src/view/com/pager/TabBar.web.tsx 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/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index de04099917..f0e686b6ab 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -1,21 +1,16 @@ import React, {forwardRef} from 'react' import {View} from 'react-native' import PagerView, { - PagerViewOnPageScrollEvent, PagerViewOnPageSelectedEvent, PageScrollStateChangedNativeEvent, } from 'react-native-pager-view' -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 { @@ -29,10 +24,6 @@ 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 @@ -46,24 +37,16 @@ export const Pager = forwardRef>( renderTabBar, onPageScrollStateChanged, onPageSelected, - onPageSelecting, 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 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) }, })) @@ -75,60 +58,18 @@ export const Pager = forwardRef>( [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 - }, - [lastOffset, lastDirection, onPageSelecting], - ) - const handlePageScrollStateChanged = React.useCallback( (e: PageScrollStateChangedNativeEvent) => { - scrollState.current = e.nativeEvent.pageScrollState onPageScrollStateChanged?.(e.nativeEvent.pageScrollState) }, - [scrollState, onPageScrollStateChanged], + [onPageScrollStateChanged], ) const onTabBarSelect = React.useCallback( (index: number) => { pagerView.current?.setPage(index) - onPageSelecting?.(index, 'tabbar-click') }, - [pagerView, onPageSelecting], + [pagerView], ) return ( @@ -142,8 +83,7 @@ export const Pager = forwardRef>( style={[a.flex_1]} initialPage={initialPage} onPageScrollStateChanged={handlePageScrollStateChanged} - onPageSelected={onPageSelectedInner} - onPageScroll={onPageScroll}> + onPageSelected={onPageSelectedInner}> {children} 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..1aa45ffba7 100644 --- a/src/view/com/pager/PagerWithHeader.tsx +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -182,17 +182,12 @@ export const PagerWithHeader = React.forwardRef( [onPageSelected, setCurrentPage], ) - const onPageSelecting = React.useCallback((index: number) => { - setCurrentPage(index) - }, []) - return ( {toArray(children) .filter(Boolean) diff --git a/src/view/com/pager/PagerWithHeader.web.tsx b/src/view/com/pager/PagerWithHeader.web.tsx index e72c1f3cc9..dd00264050 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) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 4e8646c605..3f453971c8 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -2,11 +2,8 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {LayoutChangeEvent, ScrollView, StyleSheet, View} from 'react-native' 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 @@ -31,68 +28,20 @@ export function TabBar({ }: 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 {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 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, itemXs, selectedPage, styles]) + // 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 onPressItem = useCallback( (index: number) => { @@ -122,7 +71,7 @@ export function TabBar({ testID={testID} style={[pal.view, styles.outer]} accessibilityRole="tablist"> - (itemRefs.current[i] = node as any)} onLayout={e => onItemLayout(e, i)} style={styles.item} hoverStyle={pal.viewLight} @@ -143,7 +91,7 @@ export function TabBar({ ) })} - + ) } -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({ +const styles = StyleSheet.create({ outer: { flexDirection: 'row', }, 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 91c9ae69ab..7bd0b6e576 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]) @@ -158,21 +158,13 @@ function HomeScreenReady({ const feed = allFeeds[index] setSelectedFeed(feed) lastPagerReportedIndexRef.current = index - }, - [setDrawerSwipeDisabled, setSelectedFeed, setMinimalShellMode, allFeeds], - ) - - const onPageSelecting = React.useCallback( - (index: number, reason: LogEvents['home:feedDisplayed']['reason']) => { - const feed = allFeeds[index] logEvent('home:feedDisplayed', { index, feedType: feed.split('|')[0], feedUrl: feed, - reason, }) }, - [allFeeds], + [setDrawerSwipeDisabled, setSelectedFeed, setMinimalShellMode, allFeeds], ) const onPressSelected = React.useCallback(() => { @@ -228,7 +220,6 @@ function HomeScreenReady({ ref={pagerRef} testID="homeScreen" initialPage={selectedIndex} - onPageSelecting={onPageSelecting} onPageSelected={onPageSelected} onPageScrollStateChanged={onPageScrollStateChanged} renderTabBar={renderTabBar}> From cd811114ef0fc1164b8909e3debda792cd2a659c Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 3 Dec 2024 01:29:45 +0000 Subject: [PATCH 3/4] [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 --- src/view/com/home/HomeHeader.tsx | 5 +- src/view/com/pager/Pager.tsx | 115 ++++-- src/view/com/pager/PagerWithHeader.tsx | 8 + src/view/com/pager/PagerWithHeader.web.tsx | 2 + src/view/com/pager/TabBar.tsx | 393 +++++++++++++++++---- src/view/screens/Home.tsx | 5 +- 6 files changed, 443 insertions(+), 85 deletions(-) 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 f0e686b6ab..da7fd1e936 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) + const onPageSelectedJSThread = React.useCallback( + (nextPosition: number) => { + setSelectedPage(nextPosition) + parentOnPageSelected?.(nextPosition) }, - [setSelectedPage, onPageSelected], - ) - - const handlePageScrollStateChanged = React.useCallback( - (e: PageScrollStateChangedNativeEvent) => { - onPageScrollStateChanged?.(e.nativeEvent.pageScrollState) - }, - [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 1aa45ffba7..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} /> ) @@ -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 dd00264050..13c723f471 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 3f453971c8..c19b936640 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 + + const scrollToOffsetJS = useCallback( + (x: number) => { + scrollElRef.current?.scrollTo({ + x, + y: 0, + animated: true, + }) + }, + [scrollElRef], ) - 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 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 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') + } + }, + [ + syncScrollState, + 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, + } + } + 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 ( @@ -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 7bd0b6e576..1218a5ba00 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) } From 84f4afd3d75eb50e96e4cee758f41b186f8a510d Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 3 Dec 2024 01:35:36 +0000 Subject: [PATCH 4/4] Fix scroll gesture alignment (#6898) --- src/view/com/util/MainScrollProvider.tsx | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx index 42b2e12df6..c1293a83c0 100644 --- a/src/view/com/util/MainScrollProvider.tsx +++ b/src/view/com/util/MainScrollProvider.tsx @@ -60,15 +60,16 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { const snapToClosestState = useCallback( (e: NativeScrollEvent) => { 'worklet' + const offsetY = Math.max(0, e.contentOffset.y) if (isNative) { const startDragOffsetValue = startDragOffset.get() if (startDragOffsetValue === null) { return } - const didScrollDown = e.contentOffset.y > startDragOffsetValue + const didScrollDown = offsetY > startDragOffsetValue startDragOffset.set(null) startMode.set(null) - if (e.contentOffset.y < headerHeight.get()) { + if (offsetY < headerHeight.get()) { // If we're close to the top, show the shell. setMode(false) } else if (didScrollDown) { @@ -86,8 +87,9 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { const onBeginDrag = useCallback( (e: NativeScrollEvent) => { 'worklet' + const offsetY = Math.max(0, e.contentOffset.y) if (isNative) { - startDragOffset.set(e.contentOffset.y) + startDragOffset.set(offsetY) startMode.set(headerMode.get()) } }, @@ -121,14 +123,12 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { const onScroll = useCallback( (e: NativeScrollEvent) => { 'worklet' + const offsetY = Math.max(0, e.contentOffset.y) if (isNative) { const startDragOffsetValue = startDragOffset.get() const startModeValue = startMode.get() if (startDragOffsetValue === null || startModeValue === null) { - if ( - headerMode.get() !== 0 && - e.contentOffset.y < headerHeight.get() - ) { + if (headerMode.get() !== 0 && offsetY < headerHeight.get()) { // If we're close enough to the top, always show the shell. // Even if we're not dragging. setMode(false) @@ -138,7 +138,7 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { // The "mode" value is always between 0 and 1. // Figure out how much to move it based on the current dragged distance. - const dy = e.contentOffset.y - startDragOffsetValue + const dy = offsetY - startDragOffsetValue const dProgress = interpolate( dy, [-headerHeight.get(), headerHeight.get()], @@ -157,10 +157,10 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { } // On the web, we don't try to follow the drag because we don't know when it ends. // Instead, show/hide immediately based on whether we're scrolling up or down. - const dy = e.contentOffset.y - (startDragOffset.get() ?? 0) - startDragOffset.set(e.contentOffset.y) + const dy = offsetY - (startDragOffset.get() ?? 0) + startDragOffset.set(offsetY) - if (dy < 0 || e.contentOffset.y < WEB_HIDE_SHELL_THRESHOLD) { + if (dy < 0 || offsetY < WEB_HIDE_SHELL_THRESHOLD) { setMode(false) } else if (dy > 0) { setMode(true)