From ffd063151da78eaecfb6e12a12e2f8249666efab Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Tue, 26 Nov 2024 01:37:45 +0000 Subject: [PATCH 01/31] Fork TabBar.web.tsx --- src/view/com/pager/TabBar.web.tsx | 217 ++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 src/view/com/pager/TabBar.web.tsx diff --git a/src/view/com/pager/TabBar.web.tsx b/src/view/com/pager/TabBar.web.tsx new file mode 100644 index 0000000000..4e8646c605 --- /dev/null +++ b/src/view/com/pager/TabBar.web.tsx @@ -0,0 +1,217 @@ +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 + 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 [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]) + + const onPressItem = useCallback( + (index: number) => { + 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 + }) + }, + [], + ) + + return ( + + + {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} + + + + ) + })} + + + + ) +} + +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, + }, +}) From 972fb887ec8e6ba8c1964e7aa9a596da98c343a1 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Tue, 26 Nov 2024 01:42:31 +0000 Subject: [PATCH 02/31] Trim dead code from both forks --- src/view/com/pager/TabBar.tsx | 60 ++-------------- src/view/com/pager/TabBar.web.tsx | 113 ++++++++++++------------------ 2 files changed, 50 insertions(+), 123 deletions(-) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 4e8646c605..e895eb73b8 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -3,7 +3,6 @@ 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' @@ -31,7 +30,6 @@ 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}), @@ -41,57 +39,12 @@ export function TabBar({ 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', - }) - } - } + // 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, styles]) const onPressItem = useCallback( @@ -134,7 +87,6 @@ export function TabBar({ (itemRefs.current[i] = node as any)} onLayout={e => onItemLayout(e, i)} style={styles.item} hoverStyle={pal.viewLight} diff --git a/src/view/com/pager/TabBar.web.tsx b/src/view/com/pager/TabBar.web.tsx index 4e8646c605..4291a053b5 100644 --- a/src/view/com/pager/TabBar.web.tsx +++ b/src/view/com/pager/TabBar.web.tsx @@ -1,9 +1,8 @@ -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' -import {LayoutChangeEvent, ScrollView, StyleSheet, View} from 'react-native' +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 {isNative} from '#/platform/detection' import {PressableWithHover} from '../util/PressableWithHover' import {Text} from '../util/text/Text' import {DraggableScrollView} from './DraggableScrollView' @@ -32,7 +31,6 @@ export function TabBar({ 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], @@ -41,58 +39,49 @@ export function TabBar({ 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', - }) - } + // 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 } - }, [scrollElRef, itemXs, selectedPage, styles]) + 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) => { @@ -104,19 +93,6 @@ export function TabBar({ [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 - }) - }, - [], - ) - return ( (itemRefs.current[i] = node as any)} - onLayout={e => onItemLayout(e, i)} style={styles.item} hoverStyle={pal.viewLight} onPress={() => onPressItem(i)} From 06d03fff947741ebb167d09f53d793de3eb1ef9d Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Tue, 26 Nov 2024 20:01:53 +0000 Subject: [PATCH 03/31] 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. --- src/lib/statsig/events.ts | 6 ------ src/view/com/pager/Pager.tsx | 24 ++++------------------ 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/screens/Home.tsx | 15 +++----------- 6 files changed, 12 insertions(+), 63 deletions(-) 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..6acabfecaa 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -6,16 +6,12 @@ import PagerView, { 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 +25,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,7 +38,6 @@ export const Pager = forwardRef>( renderTabBar, onPageScrollStateChanged, onPageSelected, - onPageSelecting, testID, }: React.PropsWithChildren, ref, @@ -58,12 +49,8 @@ export const Pager = forwardRef>( 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) }, })) @@ -92,14 +79,12 @@ export const Pager = forwardRef>( // -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 } @@ -112,7 +97,7 @@ export const Pager = forwardRef>( } lastOffset.current = offset }, - [lastOffset, lastDirection, onPageSelecting], + [lastOffset, lastDirection], ) const handlePageScrollStateChanged = React.useCallback( @@ -126,9 +111,8 @@ export const Pager = forwardRef>( const onTabBarSelect = React.useCallback( (index: number) => { pagerView.current?.setPage(index) - onPageSelecting?.(index, 'tabbar-click') }, - [pagerView, onPageSelecting], + [pagerView], ) return ( 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/screens/Home.tsx b/src/view/screens/Home.tsx index cadfb48903..f9e3b5cb51 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 c2fdbbc86f299230650c47fd8e5ca30c692fd66b Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Tue, 26 Nov 2024 20:04:30 +0000 Subject: [PATCH 04/31] 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. --- src/view/com/pager/Pager.tsx | 48 ++---------------------------------- 1 file changed, 2 insertions(+), 46 deletions(-) diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index 6acabfecaa..f0e686b6ab 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -1,7 +1,6 @@ import React, {forwardRef} from 'react' import {View} from 'react-native' import PagerView, { - PagerViewOnPageScrollEvent, PagerViewOnPageSelectedEvent, PageScrollStateChangedNativeEvent, } from 'react-native-pager-view' @@ -43,9 +42,6 @@ export const Pager = forwardRef>( 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, () => ({ @@ -62,50 +58,11 @@ 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) { - setSelectedPage(position) - lastDirection.current = 0 - } else if ( - lastDirection.current === 1 && - offset > lastOffset.current - ) { - 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], - ) - const handlePageScrollStateChanged = React.useCallback( (e: PageScrollStateChangedNativeEvent) => { - scrollState.current = e.nativeEvent.pageScrollState onPageScrollStateChanged?.(e.nativeEvent.pageScrollState) }, - [scrollState, onPageScrollStateChanged], + [onPageScrollStateChanged], ) const onTabBarSelect = React.useCallback( @@ -126,8 +83,7 @@ export const Pager = forwardRef>( style={[a.flex_1]} initialPage={initialPage} onPageScrollStateChanged={handlePageScrollStateChanged} - onPageSelected={onPageSelectedInner} - onPageScroll={onPageScroll}> + onPageSelected={onPageSelectedInner}> {children} From 247c6f452d628e54c96c2cb664898947d70074ef Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Tue, 26 Nov 2024 20:09:04 +0000 Subject: [PATCH 05/31] 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. --- src/view/com/pager/TabBar.tsx | 37 +++-------------------------------- 1 file changed, 3 insertions(+), 34 deletions(-) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index e895eb73b8..16138e384c 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -2,7 +2,6 @@ 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 {PressableWithHover} from '../util/PressableWithHover' import {Text} from '../util/text/Text' import {DraggableScrollView} from './DraggableScrollView' @@ -35,8 +34,6 @@ export function TabBar({ () => ({borderBottomColor: indicatorColor || pal.colors.link}), [indicatorColor, pal], ) - const {isDesktop, isTablet} = useWebMediaQueries() - const styles = isDesktop || isTablet ? desktopStyles : mobileStyles useEffect(() => { // On native, the primary interaction is swiping. @@ -45,7 +42,7 @@ export function TabBar({ let x = itemXs[selectedPage] || 0 x = Math.max(0, x - OFFSCREEN_ITEM_WIDTH) scrollElRef.current?.scrollTo({x}) - }, [scrollElRef, itemXs, selectedPage, styles]) + }, [scrollElRef, itemXs, selectedPage]) const onPressItem = useCallback( (index: number) => { @@ -95,7 +92,7 @@ export function TabBar({ Date: Tue, 26 Nov 2024 22:13:35 +0000 Subject: [PATCH 06/31] Use regular ScrollView on native The Draggable thing was needed for web-only behavior so we can drop it in the native fork. --- src/view/com/pager/TabBar.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 16138e384c..3f453971c8 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -4,7 +4,6 @@ import {LayoutChangeEvent, ScrollView, StyleSheet, View} from 'react-native' import {usePalette} from '#/lib/hooks/usePalette' import {PressableWithHover} from '../util/PressableWithHover' import {Text} from '../util/text/Text' -import {DraggableScrollView} from './DraggableScrollView' export interface TabBarProps { testID?: string @@ -72,7 +71,7 @@ export function TabBar({ testID={testID} style={[pal.view, styles.outer]} accessibilityRole="tablist"> - ) })} - + ) From 6acbe15f540d3335839fb848cf9876fbdd443669 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Tue, 26 Nov 2024 20:36:16 +0000 Subject: [PATCH 07/31] Remove tab bar autoscroll This will be replaced by a different mechanism. --- src/view/com/pager/TabBar.tsx | 32 ++------------------------------ 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 3f453971c8..1582bde685 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -1,5 +1,5 @@ -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' -import {LayoutChangeEvent, ScrollView, StyleSheet, View} from 'react-native' +import {useCallback, useMemo, useRef} from 'react' +import {ScrollView, StyleSheet, View} from 'react-native' import {usePalette} from '#/lib/hooks/usePalette' import {PressableWithHover} from '../util/PressableWithHover' @@ -14,10 +14,6 @@ export interface TabBarProps { 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, @@ -28,21 +24,11 @@ export function TabBar({ }: TabBarProps) { const pal = usePalette('default') const scrollElRef = useRef(null) - const [itemXs, setItemXs] = useState([]) const indicatorStyle = useMemo( () => ({borderBottomColor: indicatorColor || pal.colors.link}), [indicatorColor, pal], ) - 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 onPressItem = useCallback( (index: number) => { onSelect?.(index) @@ -53,19 +39,6 @@ export function TabBar({ [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 - }) - }, - [], - ) - return ( onItemLayout(e, i)} style={styles.item} hoverStyle={pal.viewLight} onPress={() => onPressItem(i)} From dc89c75e1824cb9c299b376684ef6e6449bbdcdd Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Tue, 26 Nov 2024 22:04:31 +0000 Subject: [PATCH 08/31] Track pager drag gesture in a worklet --- src/view/com/pager/Pager.tsx | 43 +++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index f0e686b6ab..11f3d2baa8 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -4,6 +4,11 @@ import PagerView, { PagerViewOnPageSelectedEvent, PageScrollStateChangedNativeEvent, } from 'react-native-pager-view' +import Animated, { + useEvent, + useHandler, + useSharedValue, +} from 'react-native-reanimated' import {atoms as a, native} from '#/alf' @@ -29,6 +34,9 @@ interface Props { ) => void testID?: string } + +const AnimatedPagerView = Animated.createAnimatedComponent(PagerView) + export const Pager = forwardRef>( function PagerImpl( { @@ -43,6 +51,7 @@ export const Pager = forwardRef>( ) { const [selectedPage, setSelectedPage] = React.useState(0) const pagerView = React.useRef(null) + const dragProgress = useSharedValue(selectedPage) React.useImperativeHandle(ref, () => ({ setPage: (index: number) => { @@ -72,21 +81,49 @@ export const Pager = forwardRef>( [pagerView], ) + const handlePageScroll = usePageScrollHandler( + { + onPageScroll(e: any) { + 'worklet' + const progress = e.offset + e.position + dragProgress.value = progress + }, + }, + [], + ) + return ( {renderTabBar({ selectedPage, onSelect: onTabBarSelect, })} - + onPageSelected={onPageSelectedInner} + onPageScroll={handlePageScroll}> {children} - + ) }, ) + +function usePageScrollHandler(handlers: any, dependencies: any): any { + const {context, doDependenciesDiffer} = useHandler(handlers, dependencies) + const subscribeForEvents = ['onPageScroll'] + const {onPageScroll} = handlers + return useEvent( + event => { + 'worklet' + if (event.eventName.endsWith('onPageScroll')) { + onPageScroll?.(event, context) + } + }, + subscribeForEvents, + doDependenciesDiffer, + ) +} From 7732a7b8f372e6035a1b7f5c9b037726c265116b Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Wed, 27 Nov 2024 03:40:21 +0000 Subject: [PATCH 09/31] Track pager state change in a worklet --- src/view/com/pager/Pager.tsx | 43 +++++++++++++++++++++--------------- src/view/screens/Home.tsx | 1 + 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index 11f3d2baa8..d0a6f403e8 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -1,8 +1,9 @@ import React, {forwardRef} from 'react' import {View} from 'react-native' import PagerView, { + PagerViewOnPageScrollEventData, PagerViewOnPageSelectedEvent, - PageScrollStateChangedNativeEvent, + PageScrollStateChangedNativeEventData, } from 'react-native-pager-view' import Animated, { useEvent, @@ -43,7 +44,7 @@ export const Pager = forwardRef>( children, initialPage = 0, renderTabBar, - onPageScrollStateChanged, + onPageScrollStateChanged: parentOnPageScrollStateChanged, onPageSelected, testID, }: React.PropsWithChildren, @@ -67,13 +68,6 @@ export const Pager = forwardRef>( [setSelectedPage, onPageSelected], ) - const handlePageScrollStateChanged = React.useCallback( - (e: PageScrollStateChangedNativeEvent) => { - onPageScrollStateChanged?.(e.nativeEvent.pageScrollState) - }, - [onPageScrollStateChanged], - ) - const onTabBarSelect = React.useCallback( (index: number) => { pagerView.current?.setPage(index) @@ -81,15 +75,19 @@ export const Pager = forwardRef>( [pagerView], ) - const handlePageScroll = usePageScrollHandler( + const handlePageScroll = usePagerHandlers( { - onPageScroll(e: any) { + onPageScroll(e: PagerViewOnPageScrollEventData) { 'worklet' const progress = e.offset + e.position dragProgress.value = progress }, + onPageScrollStateChanged(e: PageScrollStateChangedNativeEventData) { + 'worklet' + parentOnPageScrollStateChanged?.(e.pageScrollState) + }, }, - [], + [parentOnPageScrollStateChanged], ) return ( @@ -102,7 +100,6 @@ export const Pager = forwardRef>( ref={pagerView} style={[a.flex_1]} initialPage={initialPage} - onPageScrollStateChanged={handlePageScrollStateChanged} onPageSelected={onPageSelectedInner} onPageScroll={handlePageScroll}> {children} @@ -112,15 +109,25 @@ export const Pager = forwardRef>( }, ) -function usePageScrollHandler(handlers: any, dependencies: any): any { - const {context, doDependenciesDiffer} = useHandler(handlers, dependencies) - const subscribeForEvents = ['onPageScroll'] - const {onPageScroll} = handlers +function usePagerHandlers( + handlers: { + onPageScroll: (e: PagerViewOnPageScrollEventData) => void + onPageScrollStateChanged: (e: PageScrollStateChangedNativeEventData) => void + }, + dependencies: unknown[], +) { + const {doDependenciesDiffer} = useHandler(handlers as any, dependencies) + const subscribeForEvents = ['onPageScroll', 'onPageScrollStateChanged'] return useEvent( event => { 'worklet' + const {onPageScroll, onPageScrollStateChanged} = handlers if (event.eventName.endsWith('onPageScroll')) { - onPageScroll?.(event, context) + onPageScroll(event as any as PagerViewOnPageScrollEventData) + } else if (event.eventName.endsWith('onPageScrollStateChanged')) { + onPageScrollStateChanged( + event as any as PageScrollStateChangedNativeEventData, + ) } }, subscribeForEvents, diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index f9e3b5cb51..67d6fbbc52 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -173,6 +173,7 @@ function HomeScreenReady({ const onPageScrollStateChanged = React.useCallback( (state: 'idle' | 'dragging' | 'settling') => { + 'worklet' if (state === 'dragging') { setMinimalShellMode(false) } From a26a84eb602b438bb3f3079c157e8092441e16b5 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Fri, 29 Nov 2024 19:27:20 +0000 Subject: [PATCH 10/31] Track offset relative to current page --- src/view/com/pager/Pager.tsx | 50 +++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index d0a6f403e8..c8a4d9487a 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -3,9 +3,11 @@ import {View} from 'react-native' import PagerView, { PagerViewOnPageScrollEventData, PagerViewOnPageSelectedEvent, + PagerViewOnPageSelectedEventData, PageScrollStateChangedNativeEventData, } from 'react-native-pager-view' import Animated, { + runOnJS, useEvent, useHandler, useSharedValue, @@ -45,14 +47,13 @@ export const Pager = forwardRef>( initialPage = 0, renderTabBar, onPageScrollStateChanged: parentOnPageScrollStateChanged, - onPageSelected, + onPageSelected: parentOnPageSelected, testID, }: React.PropsWithChildren, ref, ) { const [selectedPage, setSelectedPage] = React.useState(0) const pagerView = React.useRef(null) - const dragProgress = useSharedValue(selectedPage) React.useImperativeHandle(ref, () => ({ setPage: (index: number) => { @@ -60,12 +61,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], + [setSelectedPage, parentOnPageSelected], ) const onTabBarSelect = React.useCallback( @@ -75,17 +76,31 @@ export const Pager = forwardRef>( [pagerView], ) + const dragPage = useSharedValue(selectedPage) + const dragProgress = useSharedValue(0) + const dragState = useSharedValue<'idle' | 'settling' | 'dragging'>('idle') const handlePageScroll = usePagerHandlers( { onPageScroll(e: PagerViewOnPageScrollEventData) { 'worklet' - const progress = e.offset + e.position - dragProgress.value = progress + let {position, offset} = e + if (position === dragPage.value) { + dragProgress.set(offset) + } else { + const offsetFromPage = position + offset - dragPage.value + dragProgress.set(offsetFromPage) + } }, onPageScrollStateChanged(e: PageScrollStateChangedNativeEventData) { 'worklet' + dragState.set(e.pageScrollState) parentOnPageScrollStateChanged?.(e.pageScrollState) }, + onPageSelected(e: PagerViewOnPageSelectedEventData) { + 'worklet' + dragPage.set(e.position) + runOnJS(onPageSelectedJSThread)(e.position) + }, }, [parentOnPageScrollStateChanged], ) @@ -95,12 +110,16 @@ export const Pager = forwardRef>( {renderTabBar({ selectedPage, onSelect: onTabBarSelect, + dragGesture: { + dragPage, + dragProgress, + dragState, + }, })} {children} @@ -113,21 +132,28 @@ 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'] + const subscribeForEvents = [ + 'onPageScroll', + 'onPageScrollStateChanged', + 'onPageSelected', + ] return useEvent( event => { 'worklet' - const {onPageScroll, onPageScrollStateChanged} = handlers + 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, From 020d71a741295fff193c7320fd1169b47f64e2ef Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Fri, 29 Nov 2024 21:17:47 +0000 Subject: [PATCH 11/31] Sync scroll to swipe --- src/view/com/home/HomeHeader.tsx | 1 + src/view/com/pager/Pager.tsx | 1 + src/view/com/pager/TabBar.tsx | 102 ++++++++++++++++++++++--------- 3 files changed, 76 insertions(+), 28 deletions(-) diff --git a/src/view/com/home/HomeHeader.tsx b/src/view/com/home/HomeHeader.tsx index 31c7135634..a9647f8419 100644 --- a/src/view/com/home/HomeHeader.tsx +++ b/src/view/com/home/HomeHeader.tsx @@ -62,6 +62,7 @@ export function HomeHeader( testID={props.testID} items={items} indicatorColor={pal.colors.link} + dragGesture={props.dragGesture} /> ) diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index c8a4d9487a..0a22806d2e 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -99,6 +99,7 @@ export const Pager = forwardRef>( onPageSelected(e: PagerViewOnPageSelectedEventData) { 'worklet' dragPage.set(e.position) + dragProgress.set(0) runOnJS(onPageSelectedJSThread)(e.position) }, }, diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 1582bde685..1c5b4e2fa8 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -1,5 +1,12 @@ -import {useCallback, useMemo, useRef} from 'react' +import {useCallback, useMemo} from 'react' import {ScrollView, StyleSheet, View} from 'react-native' +import Animated, { + scrollTo, + useAnimatedReaction, + useAnimatedRef, + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated' import {usePalette} from '#/lib/hooks/usePalette' import {PressableWithHover} from '../util/PressableWithHover' @@ -21,9 +28,10 @@ export function TabBar({ indicatorColor, onSelect, onPressSelected, + dragGesture, }: TabBarProps) { const pal = usePalette('default') - const scrollElRef = useRef(null) + const scrollElRef = useAnimatedRef() const indicatorStyle = useMemo( () => ({borderBottomColor: indicatorColor || pal.colors.link}), [indicatorColor, pal], @@ -39,6 +47,30 @@ export function TabBar({ [onSelect, selectedPage, onPressSelected], ) + const contentSize = useSharedValue(0) + const containerSize = useSharedValue(0) + const itemsLength = items.length + const {dragPage, dragProgress} = dragGesture + + useAnimatedReaction( + () => dragPage.get(), + (page, prevPage) => { + if (page !== prevPage) { + const offsetPerPage = contentSize.get() - containerSize.get() + const offset = (dragPage.get() / (itemsLength - 1)) * offsetPerPage + scrollTo(scrollElRef, offset, 0, false) + } + }, + ) + + const contentStyle = useAnimatedStyle(() => { + const offsetPerPage = contentSize.get() - containerSize.get() + const offset = (dragProgress.get() / (itemsLength - 1)) * offsetPerPage + return { + transform: [{translateX: -offset}], + } + }) + return ( - {items.map((item, i) => { - const selected = i === selectedPage - return ( - onPressItem(i)} - accessibilityRole="tab"> - - - {item} - - - - ) - })} + contentContainerStyle={styles.contentContainer} + onLayout={e => { + containerSize.set(e.nativeEvent.layout.width) + }}> + { + contentSize.set(e.nativeEvent.layout.width) + }} + style={[ + contentStyle, + { + flexDirection: 'row', + }, + ]}> + {items.map((item, i) => { + const selected = i === selectedPage + return ( + onPressItem(i)} + accessibilityRole="tab"> + + + {item} + + + + ) + })} + From 6d134229d6a828e8936d9b07a80405c096ef9b30 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Fri, 29 Nov 2024 21:46:17 +0000 Subject: [PATCH 12/31] Extract TabBarItem --- src/view/com/home/HomeHeader.tsx | 3 -- src/view/com/pager/TabBar.tsx | 73 ++++++++++++++++++++------------ 2 files changed, 46 insertions(+), 30 deletions(-) diff --git a/src/view/com/home/HomeHeader.tsx b/src/view/com/home/HomeHeader.tsx index a9647f8419..e40cd232c2 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,6 @@ export function HomeHeader( onSelect={onSelect} testID={props.testID} items={items} - indicatorColor={pal.colors.link} dragGesture={props.dragGesture} /> diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 1c5b4e2fa8..f633c658f8 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -16,7 +16,6 @@ export interface TabBarProps { testID?: string selectedPage: number items: string[] - indicatorColor?: string onSelect?: (index: number) => void onPressSelected?: (index: number) => void } @@ -25,17 +24,12 @@ export function TabBar({ testID, selectedPage, items, - indicatorColor, onSelect, onPressSelected, dragGesture, }: TabBarProps) { const pal = usePalette('default') const scrollElRef = useAnimatedRef() - const indicatorStyle = useMemo( - () => ({borderBottomColor: indicatorColor || pal.colors.link}), - [indicatorColor, pal], - ) const onPressItem = useCallback( (index: number) => { @@ -96,28 +90,15 @@ export function TabBar({ }, ]}> {items.map((item, i) => { - const selected = i === selectedPage return ( - onPressItem(i)} - accessibilityRole="tab"> - - - {item} - - - + ) })} @@ -127,6 +108,44 @@ export function TabBar({ ) } +function TabBarItem({ + index, + testID, + selected, + item, + onPressItem, +}: { + index: number + testID: string | undefined + selected: boolean + item: string + onPressItem: (index: number) => void +}) { + const pal = usePalette('default') + const indicatorStyle = useMemo( + () => ({borderBottomColor: pal.colors.link}), + [pal], + ) + return ( + onPressItem(index)} + accessibilityRole="tab"> + + + {item} + + + + ) +} + const styles = StyleSheet.create({ outer: { flexDirection: 'row', From 505e3adc771c790cf790a34dee30d9425ca1662d Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Fri, 29 Nov 2024 23:44:46 +0000 Subject: [PATCH 13/31] Sync scroll to swipe properly --- src/view/com/pager/Pager.tsx | 25 ++++++++++++++++++------- src/view/com/pager/TabBar.tsx | 25 ++++++------------------- 2 files changed, 24 insertions(+), 26 deletions(-) diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index 0a22806d2e..f71bb3c1c4 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -76,18 +76,19 @@ export const Pager = forwardRef>( [pagerView], ) + const pendingPage = useSharedValue(selectedPage) const dragPage = useSharedValue(selectedPage) const dragProgress = useSharedValue(0) const dragState = useSharedValue<'idle' | 'settling' | 'dragging'>('idle') const handlePageScroll = usePagerHandlers( + // These events don't fire exactly the same way on Android and iOS. + // In these handlers we normalize the behavior to have consistent output values. { onPageScroll(e: PagerViewOnPageScrollEventData) { 'worklet' let {position, offset} = e - if (position === dragPage.value) { - dragProgress.set(offset) - } else { - const offsetFromPage = position + offset - dragPage.value + if (offset !== 0) { + const offsetFromPage = offset + (position - dragPage.value) dragProgress.set(offsetFromPage) } }, @@ -95,12 +96,22 @@ export const Pager = forwardRef>( 'worklet' dragState.set(e.pageScrollState) parentOnPageScrollStateChanged?.(e.pageScrollState) + if (e.pageScrollState === 'idle') { + const page = pendingPage.get() + dragPage.set(page) + dragProgress.set(0) + runOnJS(onPageSelectedJSThread)(page) + } }, onPageSelected(e: PagerViewOnPageSelectedEventData) { 'worklet' - dragPage.set(e.position) - dragProgress.set(0) - runOnJS(onPageSelectedJSThread)(e.position) + pendingPage.set(e.position) + if (dragState.value === 'idle') { + const page = e.position + dragPage.set(page) + dragProgress.set(0) + runOnJS(onPageSelectedJSThread)(e.position) + } }, }, [parentOnPageScrollStateChanged], diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index f633c658f8..a9f6a50814 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -4,7 +4,6 @@ import Animated, { scrollTo, useAnimatedReaction, useAnimatedRef, - useAnimatedStyle, useSharedValue, } from 'react-native-reanimated' @@ -47,24 +46,17 @@ export function TabBar({ const {dragPage, dragProgress} = dragGesture useAnimatedReaction( - () => dragPage.get(), - (page, prevPage) => { - if (page !== prevPage) { + () => dragPage.get() + dragProgress.get(), + (next, prev) => { + if (next !== prev) { const offsetPerPage = contentSize.get() - containerSize.get() - const offset = (dragPage.get() / (itemsLength - 1)) * offsetPerPage + const offset = (next / (itemsLength - 1)) * offsetPerPage scrollTo(scrollElRef, offset, 0, false) + return } }, ) - const contentStyle = useAnimatedStyle(() => { - const offsetPerPage = contentSize.get() - containerSize.get() - const offset = (dragProgress.get() / (itemsLength - 1)) * offsetPerPage - return { - transform: [{translateX: -offset}], - } - }) - return ( { contentSize.set(e.nativeEvent.layout.width) }} - style={[ - contentStyle, - { - flexDirection: 'row', - }, - ]}> + style={{flexDirection: 'row'}}> {items.map((item, i) => { return ( Date: Sat, 30 Nov 2024 00:50:30 +0000 Subject: [PATCH 14/31] Implement all interactions --- src/view/com/pager/TabBar.tsx | 94 +++++++++++++++++++++++++++++------ 1 file changed, 79 insertions(+), 15 deletions(-) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index a9f6a50814..9e596697f0 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -1,6 +1,7 @@ import {useCallback, useMemo} from 'react' import {ScrollView, StyleSheet, View} from 'react-native' import Animated, { + runOnUI, scrollTo, useAnimatedReaction, useAnimatedRef, @@ -29,34 +30,89 @@ export function TabBar({ }: TabBarProps) { const pal = usePalette('default') const scrollElRef = useAnimatedRef() - - const onPressItem = useCallback( - (index: number) => { - onSelect?.(index) - if (index === selectedPage) { - onPressSelected?.(index) - } - }, - [onSelect, selectedPage, onPressSelected], - ) - + const isSyncingScroll = useSharedValue(true) const contentSize = useSharedValue(0) const containerSize = useSharedValue(0) + const scrollX = useSharedValue(0) const itemsLength = items.length - const {dragPage, dragProgress} = dragGesture + const {dragPage, dragProgress, dragState} = dragGesture + // When you swipe the pager, the tabbar should scroll automatically + // as you're dragging the page and then even during deceleration. useAnimatedReaction( () => dragPage.get() + dragProgress.get(), - (next, prev) => { - if (next !== prev) { + (nextValue, prevValue) => { + if ( + nextValue !== prevValue && + dragState.value !== 'idle' && + isSyncingScroll.get() === true + ) { const offsetPerPage = contentSize.get() - containerSize.get() - const offset = (next / (itemsLength - 1)) * offsetPerPage + const offset = (nextValue / (itemsLength - 1)) * offsetPerPage scrollTo(scrollElRef, offset, 0, false) return } }, ) + // 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( + () => { + return dragState.value + }, + (nextDragState, prevDragState) => { + if ( + nextDragState !== prevDragState && + nextDragState === 'idle' && + isSyncingScroll.get() === false + ) { + const offsetPerPage = contentSize.get() - containerSize.get() + const value = dragPage.get() + dragProgress.get() + const offset = (value / (itemsLength - 1)) * offsetPerPage + 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' + if (isSyncingScroll.get() === true) { + const offsetPerPage = contentSize.get() - containerSize.get() + const valueDiff = index - dragPage.get() + const offsetDiff = (valueDiff / (itemsLength - 1)) * offsetPerPage + const offset = scrollX.get() + offsetDiff + scrollTo(scrollElRef, offset, 0, true) + } + isSyncingScroll.set(true) + }, + [ + contentSize, + containerSize, + isSyncingScroll, + itemsLength, + scrollElRef, + scrollX, + dragPage, + ], + ) + + const onPressItem = useCallback( + (index: number) => { + runOnUI(onPressUIThread)(index) + onSelect?.(index) + if (index === selectedPage) { + onPressSelected?.(index) + } + }, + [onSelect, selectedPage, onPressSelected, onPressUIThread], + ) + return ( { 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) }}> { From 5e098fa609e2198a36535a36b873508d0b4cba7c Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 30 Nov 2024 01:04:54 +0000 Subject: [PATCH 15/31] Clarify more hacks --- src/view/com/pager/Pager.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index f71bb3c1c4..b39bb51371 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -88,15 +88,23 @@ export const Pager = forwardRef>( 'worklet' let {position, offset} = e if (offset !== 0) { + // Normalize so that we always track the offset according to the + // last settled page from the application point of view. const offsetFromPage = offset + (position - dragPage.value) dragProgress.set(offsetFromPage) } }, 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) if (e.pageScrollState === 'idle') { + // This is a good time to update the last known settled page. const page = pendingPage.get() dragPage.set(page) dragProgress.set(0) @@ -105,8 +113,12 @@ export const Pager = forwardRef>( }, onPageSelected(e: PagerViewOnPageSelectedEventData) { 'worklet' + // We don't usually emit the "page selected" event here because it fires + // prematurely on Android. We'll emit it in the idle state handler above. pendingPage.set(e.position) if (dragState.value === 'idle') { + // However, if we already *are* idle, this event is caused by the initial state. + // Let's fire it here since there will be no scroll state change event later. const page = e.position dragPage.set(page) dragProgress.set(0) From 9557eee808c1dfaa1073cff543e4208e7ad757b8 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 30 Nov 2024 02:52:44 +0000 Subject: [PATCH 16/31] 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. --- src/view/com/pager/Pager.tsx | 34 +++------------------------------- src/view/com/pager/TabBar.tsx | 20 ++++++++++---------- 2 files changed, 13 insertions(+), 41 deletions(-) diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index b39bb51371..913e581191 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -76,23 +76,13 @@ export const Pager = forwardRef>( [pagerView], ) - const pendingPage = useSharedValue(selectedPage) - const dragPage = useSharedValue(selectedPage) - const dragProgress = useSharedValue(0) const dragState = useSharedValue<'idle' | 'settling' | 'dragging'>('idle') + const dragProgress = useSharedValue(selectedPage) const handlePageScroll = usePagerHandlers( - // These events don't fire exactly the same way on Android and iOS. - // In these handlers we normalize the behavior to have consistent output values. { onPageScroll(e: PagerViewOnPageScrollEventData) { 'worklet' - let {position, offset} = e - if (offset !== 0) { - // Normalize so that we always track the offset according to the - // last settled page from the application point of view. - const offsetFromPage = offset + (position - dragPage.value) - dragProgress.set(offsetFromPage) - } + dragProgress.set(e.offset + e.position) }, onPageScrollStateChanged(e: PageScrollStateChangedNativeEventData) { 'worklet' @@ -103,27 +93,10 @@ export const Pager = forwardRef>( } dragState.set(e.pageScrollState) parentOnPageScrollStateChanged?.(e.pageScrollState) - if (e.pageScrollState === 'idle') { - // This is a good time to update the last known settled page. - const page = pendingPage.get() - dragPage.set(page) - dragProgress.set(0) - runOnJS(onPageSelectedJSThread)(page) - } }, onPageSelected(e: PagerViewOnPageSelectedEventData) { 'worklet' - // We don't usually emit the "page selected" event here because it fires - // prematurely on Android. We'll emit it in the idle state handler above. - pendingPage.set(e.position) - if (dragState.value === 'idle') { - // However, if we already *are* idle, this event is caused by the initial state. - // Let's fire it here since there will be no scroll state change event later. - const page = e.position - dragPage.set(page) - dragProgress.set(0) - runOnJS(onPageSelectedJSThread)(e.position) - } + runOnJS(onPageSelectedJSThread)(e.position) }, }, [parentOnPageScrollStateChanged], @@ -135,7 +108,6 @@ export const Pager = forwardRef>( selectedPage, onSelect: onTabBarSelect, dragGesture: { - dragPage, dragProgress, dragState, }, diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 9e596697f0..929582c7c2 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -35,20 +35,20 @@ export function TabBar({ const containerSize = useSharedValue(0) const scrollX = useSharedValue(0) const itemsLength = items.length - const {dragPage, dragProgress, dragState} = dragGesture + const {dragProgress, dragState} = dragGesture // When you swipe the pager, the tabbar should scroll automatically // as you're dragging the page and then even during deceleration. useAnimatedReaction( - () => dragPage.get() + dragProgress.get(), - (nextValue, prevValue) => { + () => dragProgress.get(), + (nextProgress, prevProgress) => { if ( - nextValue !== prevValue && + nextProgress !== prevProgress && dragState.value !== 'idle' && isSyncingScroll.get() === true ) { const offsetPerPage = contentSize.get() - containerSize.get() - const offset = (nextValue / (itemsLength - 1)) * offsetPerPage + const offset = (nextProgress / (itemsLength - 1)) * offsetPerPage scrollTo(scrollElRef, offset, 0, false) return } @@ -69,8 +69,8 @@ export function TabBar({ isSyncingScroll.get() === false ) { const offsetPerPage = contentSize.get() - containerSize.get() - const value = dragPage.get() + dragProgress.get() - const offset = (value / (itemsLength - 1)) * offsetPerPage + const progress = dragProgress.get() + const offset = (progress / (itemsLength - 1)) * offsetPerPage scrollTo(scrollElRef, offset, 0, true) isSyncingScroll.set(true) } @@ -84,8 +84,8 @@ export function TabBar({ 'worklet' if (isSyncingScroll.get() === true) { const offsetPerPage = contentSize.get() - containerSize.get() - const valueDiff = index - dragPage.get() - const offsetDiff = (valueDiff / (itemsLength - 1)) * offsetPerPage + const progressDiff = index - dragProgress.get() + const offsetDiff = (progressDiff / (itemsLength - 1)) * offsetPerPage const offset = scrollX.get() + offsetDiff scrollTo(scrollElRef, offset, 0, true) } @@ -98,7 +98,7 @@ export function TabBar({ itemsLength, scrollElRef, scrollX, - dragPage, + dragProgress, ], ) From 24a24753bc93d5853f9130963f35e95864a6e7e7 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 30 Nov 2024 03:21:27 +0000 Subject: [PATCH 17/31] Interpolate the indicator --- src/view/com/pager/TabBar.tsx | 82 +++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 14 deletions(-) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 929582c7c2..d4fd87bfc1 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -1,10 +1,12 @@ -import {useCallback, useMemo} from 'react' +import {useCallback, useState} from 'react' import {ScrollView, StyleSheet, View} from 'react-native' import Animated, { + interpolate, runOnUI, scrollTo, useAnimatedReaction, useAnimatedRef, + useAnimatedStyle, useSharedValue, } from 'react-native-reanimated' @@ -113,6 +115,46 @@ export function TabBar({ [onSelect, selectedPage, onPressSelected, onPressUIThread], ) + const [layouts, setLayouts] = useState([]) + const didLayout = + layouts.length === items.length && layouts.every(l => l !== undefined) + const indicatorStyle = useAnimatedStyle(() => { + if (!didLayout) { + return {} + } + return { + transform: [ + { + translateX: interpolate( + dragProgress.get(), + layouts.map((l, i) => i), + layouts.map(l => l.x + l.width / 2 - contentSize.get() / 2), + ), + }, + { + scaleX: interpolate( + dragProgress.get(), + layouts.map((l, i) => i), + layouts.map(l => (l.width - 12) / contentSize.get()), + ), + }, + ], + } + }) + + const onItemLayout = (e: LayoutChangeEvent, index: number) => { + const l = e.nativeEvent.layout + setLayouts(ls => + items.map((item, i) => { + if (i === index) { + return l + } else { + return ls[i] + } + }), + ) + } + return ( {items.map((item, i) => { return ( - + onItemLayout(e, i)}> + + ) })} + {didLayout && ( + + )} @@ -173,10 +231,6 @@ function TabBarItem({ onPressItem: (index: number) => void }) { const pal = usePalette('default') - const indicatorStyle = useMemo( - () => ({borderBottomColor: pal.colors.link}), - [pal], - ) return ( onPressItem(index)} accessibilityRole="tab"> - + Date: Sat, 30 Nov 2024 13:31:08 +0000 Subject: [PATCH 18/31] Fix an infinite swipe loop --- src/view/screens/Home.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 67d6fbbc52..823f339362 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], From 97273be3a607ab3719c0ca5cd3812157be4ce014 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 30 Nov 2024 13:38:55 +0000 Subject: [PATCH 19/31] Add TODO --- src/view/com/pager/TabBar.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index d4fd87bfc1..a8a02e57fe 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -88,6 +88,7 @@ export function TabBar({ const offsetPerPage = contentSize.get() - containerSize.get() const progressDiff = index - dragProgress.get() const offsetDiff = (progressDiff / (itemsLength - 1)) * offsetPerPage + // TODO: Get into view if obscured const offset = scrollX.get() + offsetDiff scrollTo(scrollElRef, offset, 0, true) } From f2522aafdd8013402de2f086324b53378478f2c8 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 30 Nov 2024 13:51:50 +0000 Subject: [PATCH 20/31] Animate header color --- src/view/com/pager/TabBar.tsx | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index a8a02e57fe..73a2a4c1c0 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -4,6 +4,7 @@ import Animated, { interpolate, runOnUI, scrollTo, + SharedValue, useAnimatedReaction, useAnimatedRef, useAnimatedStyle, @@ -189,7 +190,7 @@ export function TabBar({ @@ -221,17 +222,25 @@ export function TabBar({ function TabBarItem({ index, testID, - selected, + dragProgress, item, onPressItem, }: { index: number testID: string | undefined - selected: boolean + dragProgress: SharedValue item: string onPressItem: (index: number) => void }) { const pal = usePalette('default') + const style = useAnimatedStyle(() => ({ + opacity: interpolate( + dragProgress.get(), + [index - 1, index, index + 1], + [0.7, 1, 0.7], + 'clamp', + ), + })) return ( onPressItem(index)} accessibilityRole="tab"> - + + style={[pal.text, {lineHeight: 20}]}> {item} - + ) } From c06eb2b2b1480e540f0a87545f2ae55a1fa05040 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 30 Nov 2024 18:15:15 +0000 Subject: [PATCH 21/31] Respect initial page --- src/view/com/pager/Pager.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index 913e581191..4dd2e4abe6 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -52,7 +52,7 @@ export const Pager = forwardRef>( }: React.PropsWithChildren, ref, ) { - const [selectedPage, setSelectedPage] = React.useState(0) + const [selectedPage, setSelectedPage] = React.useState(initialPage) const pagerView = React.useRef(null) React.useImperativeHandle(ref, () => ({ From 7a6d4ff91c31c7168b85fabe5ec6de404353a0ea Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 30 Nov 2024 18:47:08 +0000 Subject: [PATCH 22/31] Keep layouts in a shared value --- src/view/com/pager/TabBar.tsx | 155 ++++++++++++++++++---------------- 1 file changed, 84 insertions(+), 71 deletions(-) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 73a2a4c1c0..80798539f9 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -1,5 +1,5 @@ -import {useCallback, useState} from 'react' -import {ScrollView, StyleSheet, View} from 'react-native' +import {useCallback} from 'react' +import {LayoutChangeEvent, ScrollView, StyleSheet, View} from 'react-native' import Animated, { interpolate, runOnUI, @@ -37,6 +37,7 @@ export function TabBar({ const contentSize = useSharedValue(0) const containerSize = useSharedValue(0) const scrollX = useSharedValue(0) + const layouts = useSharedValue<{x: number; width: number}[]>([]) const itemsLength = items.length const {dragProgress, dragState} = dragGesture @@ -106,56 +107,58 @@ export function TabBar({ ], ) - const onPressItem = useCallback( - (index: number) => { - runOnUI(onPressUIThread)(index) - onSelect?.(index) - if (index === selectedPage) { - onPressSelected?.(index) - } + const onItemLayout = useCallback( + (i: number, layout: {x: number; width: number}) => { + 'worklet' + layouts.modify(ls => { + ls[i] = layout + return ls + }) }, - [onSelect, selectedPage, onPressSelected, onPressUIThread], + [layouts], ) - const [layouts, setLayouts] = useState([]) - const didLayout = - layouts.length === items.length && layouts.every(l => l !== undefined) const indicatorStyle = useAnimatedStyle(() => { - if (!didLayout) { - return {} + const layoutsValue = layouts.get() + if ( + layoutsValue.length !== itemsLength || + layoutsValue.some(l => l === undefined) + ) { + return { + opacity: 0, + } } return { + opacity: 1, transform: [ { translateX: interpolate( dragProgress.get(), - layouts.map((l, i) => i), - layouts.map(l => l.x + l.width / 2 - contentSize.get() / 2), + layoutsValue.map((l, i) => i), + layoutsValue.map(l => l.x + l.width / 2 - contentSize.get() / 2), ), }, { scaleX: interpolate( dragProgress.get(), - layouts.map((l, i) => i), - layouts.map(l => (l.width - 12) / contentSize.get()), + layoutsValue.map((l, i) => i), + layoutsValue.map(l => (l.width - 12) / contentSize.get()), ), }, ], } }) - const onItemLayout = (e: LayoutChangeEvent, index: number) => { - const l = e.nativeEvent.layout - setLayouts(ls => - items.map((item, i) => { - if (i === index) { - return l - } else { - return ls[i] - } - }), - ) - } + const onPressItem = useCallback( + (index: number) => { + runOnUI(onPressUIThread)(index) + onSelect?.(index) + if (index === selectedPage) { + onPressSelected?.(index) + } + }, + [onSelect, selectedPage, onPressSelected, onPressUIThread], + ) return ( {items.map((item, i) => { return ( - onItemLayout(e, i)}> - - + ) })} - {didLayout && ( - - )} + @@ -225,12 +226,14 @@ function TabBarItem({ 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(() => ({ @@ -241,23 +244,33 @@ function TabBarItem({ 'clamp', ), })) + + const handleLayout = useCallback( + (e: LayoutChangeEvent) => { + runOnUI(onItemLayout)(index, e.nativeEvent.layout) + }, + [index, onItemLayout], + ) + return ( - onPressItem(index)} - accessibilityRole="tab"> - - - {item} - - - + + onPressItem(index)} + accessibilityRole="tab"> + + + {item} + + + + ) } From b38ea9d6cc0b460181ea4cc7d225f87c55e49d4e Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 30 Nov 2024 18:59:57 +0000 Subject: [PATCH 23/31] Fix profile and types --- src/view/com/home/HomeHeader.tsx | 3 ++- src/view/com/pager/Pager.tsx | 9 +++++---- src/view/com/pager/PagerWithHeader.tsx | 8 ++++++++ src/view/com/pager/PagerWithHeader.web.tsx | 2 ++ src/view/com/pager/TabBar.tsx | 8 +++++--- 5 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/view/com/home/HomeHeader.tsx b/src/view/com/home/HomeHeader.tsx index e40cd232c2..0ec9ac753e 100644 --- a/src/view/com/home/HomeHeader.tsx +++ b/src/view/com/home/HomeHeader.tsx @@ -59,7 +59,8 @@ export function HomeHeader( onSelect={onSelect} testID={props.testID} items={items} - dragGesture={props.dragGesture} + dragProgress={props.dragProgress} + dragState={props.dragState} /> ) diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index 4dd2e4abe6..23a0cd05f4 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -8,6 +8,7 @@ import PagerView, { } from 'react-native-pager-view' import Animated, { runOnJS, + SharedValue, useEvent, useHandler, useSharedValue, @@ -25,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 @@ -107,10 +110,8 @@ export const Pager = forwardRef>( {renderTabBar({ selectedPage, onSelect: onTabBarSelect, - dragGesture: { - dragProgress, - dragState, - }, + dragProgress, + dragState, })} ( 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 80798539f9..b2264767f4 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -21,6 +21,8 @@ export interface TabBarProps { items: string[] onSelect?: (index: number) => void onPressSelected?: (index: number) => void + dragProgress: SharedValue + dragState: SharedValue<'idle' | 'dragging' | 'settling'> } export function TabBar({ @@ -29,17 +31,17 @@ export function TabBar({ items, onSelect, onPressSelected, - dragGesture, + dragProgress, + dragState, }: TabBarProps) { const pal = usePalette('default') - const scrollElRef = useAnimatedRef() + const scrollElRef = useAnimatedRef() const isSyncingScroll = useSharedValue(true) const contentSize = useSharedValue(0) const containerSize = useSharedValue(0) const scrollX = useSharedValue(0) const layouts = useSharedValue<{x: number; width: number}[]>([]) const itemsLength = items.length - const {dragProgress, dragState} = dragGesture // When you swipe the pager, the tabbar should scroll automatically // as you're dragging the page and then even during deceleration. From 11d14be6902d6694a921d43101deed69bee3b2f8 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 30 Nov 2024 19:10:06 +0000 Subject: [PATCH 24/31] Fast path for initial styles --- src/view/com/pager/TabBar.tsx | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index b2264767f4..f9ac7a0024 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -65,9 +65,7 @@ export function TabBar({ // 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( - () => { - return dragState.value - }, + () => dragState.value, (nextDragState, prevDragState) => { if ( nextDragState !== prevDragState && @@ -121,6 +119,9 @@ export function TabBar({ ) const indicatorStyle = useAnimatedStyle(() => { + if (!_WORKLET) { + return {opacity: 0} + } const layoutsValue = layouts.get() if ( layoutsValue.length !== itemsLength || @@ -238,14 +239,19 @@ function TabBarItem({ onItemLayout: (index: number, layout: {x: number; width: number}) => void }) { const pal = usePalette('default') - const style = useAnimatedStyle(() => ({ - opacity: interpolate( - dragProgress.get(), - [index - 1, index, index + 1], - [0.7, 1, 0.7], - 'clamp', - ), - })) + 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) => { From 6b01b9559608c226495b590676da9c1944ff7b3e Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 30 Nov 2024 19:46:21 +0000 Subject: [PATCH 25/31] Scroll to initial --- src/view/com/pager/TabBar.tsx | 45 +++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index f9ac7a0024..8173fc46dc 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -2,6 +2,7 @@ import {useCallback} from 'react' import {LayoutChangeEvent, ScrollView, StyleSheet, View} from 'react-native' import Animated, { interpolate, + runOnJS, runOnUI, scrollTo, SharedValue, @@ -37,12 +38,56 @@ export function TabBar({ const pal = usePalette('default') 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], + ) + + // 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 offsetPerPage = contentSize.get() - containerSize.get() + const progress = dragProgress.get() + const offset = (progress / (itemsLength - 1)) * offsetPerPage + // 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( From 5a020a8e30b398432d26218617fc9264bc839f4a Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 30 Nov 2024 19:50:19 +0000 Subject: [PATCH 26/31] Factor out a helper --- src/view/com/pager/TabBar.tsx | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 8173fc46dc..a44015616b 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -56,6 +56,15 @@ export function TabBar({ [scrollElRef], ) + const progressToOffset = useCallback( + (progress: number) => { + 'worklet' + const offsetPerPage = contentSize.get() - containerSize.get() + return (progress / (itemsLength - 1)) * offsetPerPage + }, + [itemsLength, contentSize, containerSize], + ) + // When we know the entire layout for the first time, scroll selection into view. useAnimatedReaction( () => { @@ -78,9 +87,8 @@ export function TabBar({ didInitialScroll.get() === false ) { didInitialScroll.set(true) - const offsetPerPage = contentSize.get() - containerSize.get() const progress = dragProgress.get() - const offset = (progress / (itemsLength - 1)) * offsetPerPage + const offset = progressToOffset(progress) // It's unclear why we need to go back to JS here. It seems iOS-specific. runOnJS(scrollToOffsetJS)(offset) } @@ -98,8 +106,7 @@ export function TabBar({ dragState.value !== 'idle' && isSyncingScroll.get() === true ) { - const offsetPerPage = contentSize.get() - containerSize.get() - const offset = (nextProgress / (itemsLength - 1)) * offsetPerPage + const offset = progressToOffset(nextProgress) scrollTo(scrollElRef, offset, 0, false) return } @@ -117,9 +124,8 @@ export function TabBar({ nextDragState === 'idle' && isSyncingScroll.get() === false ) { - const offsetPerPage = contentSize.get() - containerSize.get() const progress = dragProgress.get() - const offset = (progress / (itemsLength - 1)) * offsetPerPage + const offset = progressToOffset(progress) scrollTo(scrollElRef, offset, 0, true) isSyncingScroll.set(true) } @@ -132,24 +138,15 @@ export function TabBar({ (index: number) => { 'worklet' if (isSyncingScroll.get() === true) { - const offsetPerPage = contentSize.get() - containerSize.get() const progressDiff = index - dragProgress.get() - const offsetDiff = (progressDiff / (itemsLength - 1)) * offsetPerPage + const offsetDiff = progressToOffset(progressDiff) // TODO: Get into view if obscured const offset = scrollX.get() + offsetDiff scrollTo(scrollElRef, offset, 0, true) } isSyncingScroll.set(true) }, - [ - contentSize, - containerSize, - isSyncingScroll, - itemsLength, - scrollElRef, - scrollX, - dragProgress, - ], + [isSyncingScroll, scrollElRef, scrollX, dragProgress, progressToOffset], ) const onItemLayout = useCallback( From acc52c14d3968687a16019547f3c72babe0c28e1 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 30 Nov 2024 19:56:38 +0000 Subject: [PATCH 27/31] Fix positioning --- src/view/com/pager/TabBar.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index a44015616b..0ad3ad8b1a 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -26,6 +26,9 @@ export interface TabBarProps { dragState: SharedValue<'idle' | 'dragging' | 'settling'> } +const ITEM_PADDING = 10 +const CONTENT_PADDING = 6 + export function TabBar({ testID, selectedPage, @@ -59,7 +62,8 @@ export function TabBar({ const progressToOffset = useCallback( (progress: number) => { 'worklet' - const offsetPerPage = contentSize.get() - containerSize.get() + const offsetPerPage = + contentSize.get() + 2 * CONTENT_PADDING - containerSize.get() return (progress / (itemsLength - 1)) * offsetPerPage }, [itemsLength, contentSize, containerSize], @@ -187,7 +191,9 @@ export function TabBar({ scaleX: interpolate( dragProgress.get(), layoutsValue.map((l, i) => i), - layoutsValue.map(l => (l.width - 12) / contentSize.get()), + layoutsValue.map( + l => (l.width - ITEM_PADDING * 2) / contentSize.get(), + ), ), }, ], @@ -330,11 +336,11 @@ const styles = StyleSheet.create({ }, contentContainer: { backgroundColor: 'transparent', - paddingHorizontal: 6, + paddingHorizontal: CONTENT_PADDING, }, item: { paddingTop: 10, - paddingHorizontal: 10, + paddingHorizontal: ITEM_PADDING, justifyContent: 'center', }, itemInner: { From e291ae008e560175b2d3644ab1ec5b8b7d28beaa Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 30 Nov 2024 20:20:14 +0000 Subject: [PATCH 28/31] Scroll into view on tap if needed --- src/view/com/pager/TabBar.tsx | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 0ad3ad8b1a..171a0c2217 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -144,13 +144,31 @@ export function TabBar({ if (isSyncingScroll.get() === true) { const progressDiff = index - dragProgress.get() const offsetDiff = progressToOffset(progressDiff) - // TODO: Get into view if obscured - const offset = scrollX.get() + offsetDiff + let offset = scrollX.get() + offsetDiff + const itemLayout = layouts.get()[index] + if (itemLayout) { + if ( + itemLayout.x < offset || + itemLayout.x + itemLayout.width > offset + containerSize.get() + ) { + // If the proposed offset is still out of view, don't bother with + // proportional scroll and ensure the target is scrolled into view. + offset = progressToOffset(index) + } + } scrollTo(scrollElRef, offset, 0, true) } isSyncingScroll.set(true) }, - [isSyncingScroll, scrollElRef, scrollX, dragProgress, progressToOffset], + [ + isSyncingScroll, + scrollElRef, + scrollX, + dragProgress, + progressToOffset, + containerSize, + layouts, + ], ) const onItemLayout = useCallback( From e4aa00e1405aa28d173d21d7c19e22a1a519e404 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sun, 1 Dec 2024 18:53:44 +0000 Subject: [PATCH 29/31] Divide free space proportionally --- src/view/com/pager/TabBar.tsx | 37 +++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 171a0c2217..b0f8744627 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -59,14 +59,43 @@ export function TabBar({ [scrollElRef], ) + const indexToOffset = useCallback( + (index: number) => { + 'worklet' + const layout = layouts.get()[index] + const availableSize = containerSize.get() - 2 * CONTENT_PADDING + if (!layout) { + 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' - const offsetPerPage = - contentSize.get() + 2 * CONTENT_PADDING - containerSize.get() - return (progress / (itemsLength - 1)) * offsetPerPage + return interpolate( + progress, + [Math.floor(progress), Math.ceil(progress)], + [ + indexToOffset(Math.floor(progress)), + indexToOffset(Math.ceil(progress)), + ], + 'clamp', + ) }, - [itemsLength, contentSize, containerSize], + [indexToOffset], ) // When we know the entire layout for the first time, scroll selection into view. From f3ae13407d85e0e4b15bdd28a25bcf290dcf9381 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sun, 1 Dec 2024 19:16:24 +0000 Subject: [PATCH 30/31] Scroll into view more aggressively --- src/view/com/pager/TabBar.tsx | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index b0f8744627..fe756a75cd 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -28,6 +28,9 @@ export interface TabBarProps { 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, @@ -65,6 +68,7 @@ export function TabBar({ 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 } @@ -170,21 +174,18 @@ export function TabBar({ const onPressUIThread = useCallback( (index: number) => { 'worklet' - if (isSyncingScroll.get() === true) { - const progressDiff = index - dragProgress.get() - const offsetDiff = progressToOffset(progressDiff) - let offset = scrollX.get() + offsetDiff - const itemLayout = layouts.get()[index] - if (itemLayout) { - if ( - itemLayout.x < offset || - itemLayout.x + itemLayout.width > offset + containerSize.get() - ) { - // If the proposed offset is still out of view, don't bother with - // proportional scroll and ensure the target is scrolled into view. - offset = progressToOffset(index) - } - } + 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 (isSyncingScroll.get() === true || scrollIntoView) { + const offset = progressToOffset(index) scrollTo(scrollElRef, offset, 0, true) } isSyncingScroll.set(true) @@ -193,7 +194,6 @@ export function TabBar({ isSyncingScroll, scrollElRef, scrollX, - dragProgress, progressToOffset, containerSize, layouts, From fc3ddd97132a0c4db2781f51c6bd53306ff9b865 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sun, 1 Dec 2024 20:45:55 +0000 Subject: [PATCH 31/31] Fix corner case --- src/view/com/pager/TabBar.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index fe756a75cd..e7e4d692b8 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -224,6 +224,9 @@ export function TabBar({ opacity: 0, } } + if (layoutsValue.length === 1) { + return {opacity: 1} + } return { opacity: 1, transform: [