diff options
Diffstat (limited to 'src/view/com/pager')
-rw-r--r-- | src/view/com/pager/FeedsTabBar.web.tsx | 110 | ||||
-rw-r--r-- | src/view/com/pager/FeedsTabBarMobile.tsx | 120 | ||||
-rw-r--r-- | src/view/com/pager/Pager.tsx | 11 | ||||
-rw-r--r-- | src/view/com/pager/Pager.web.tsx | 13 | ||||
-rw-r--r-- | src/view/com/pager/PagerWithHeader.tsx | 315 | ||||
-rw-r--r-- | src/view/com/pager/TabBar.tsx | 2 |
6 files changed, 405 insertions, 166 deletions
diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx index 25755bafe..57c83f17c 100644 --- a/src/view/com/pager/FeedsTabBar.web.tsx +++ b/src/view/com/pager/FeedsTabBar.web.tsx @@ -1,50 +1,136 @@ import React from 'react' -import {StyleSheet} from 'react-native' +import {View, StyleSheet} from 'react-native' import Animated from 'react-native-reanimated' -import {observer} from 'mobx-react-lite' import {TabBar} from 'view/com/pager/TabBar' import {RenderTabBarFnProps} from 'view/com/pager/Pager' -import {useStores} from 'state/index' -import {useHomeTabs} from 'lib/hooks/useHomeTabs' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' +import {useShellLayout} from '#/state/shell/shell-layout' +import {usePinnedFeedsInfos} from '#/state/queries/feed' +import {useSession} from '#/state/session' +import {TextLink} from '#/view/com/util/Link' +import {CenteredView} from '../util/Views' +import {isWeb} from 'platform/detection' +import {useNavigation} from '@react-navigation/native' +import {NavigationProp} from 'lib/routes/types' -export const FeedsTabBar = observer(function FeedsTabBarImpl( +export function FeedsTabBar( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) { const {isMobile, isTablet} = useWebMediaQueries() + const {hasSession} = useSession() + if (isMobile) { return <FeedsTabBarMobile {...props} /> } else if (isTablet) { - return <FeedsTabBarTablet {...props} /> + if (hasSession) { + return <FeedsTabBarTablet {...props} /> + } else { + return <FeedsTabBarPublic /> + } } else { return null } -}) +} + +function FeedsTabBarPublic() { + const pal = usePalette('default') + const {isSandbox} = useSession() -const FeedsTabBarTablet = observer(function FeedsTabBarTabletImpl( + return ( + <CenteredView sideBorders> + <View + style={[ + pal.view, + { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 18, + paddingVertical: 12, + }, + ]}> + <TextLink + type="title-lg" + href="/" + style={[pal.text, {fontWeight: 'bold'}]} + text={ + <> + {isSandbox ? 'SANDBOX' : 'Bluesky'}{' '} + {/*hasNew && ( + <View + style={{ + top: -8, + backgroundColor: colors.blue3, + width: 8, + height: 8, + borderRadius: 4, + }} + /> + )*/} + </> + } + // onPress={emitSoftReset} + /> + </View> + </CenteredView> + ) +} + +function FeedsTabBarTablet( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) { - const store = useStores() - const items = useHomeTabs(store.preferences.pinnedFeeds) + const {feeds, hasPinnedCustom} = usePinnedFeedsInfos() const pal = usePalette('default') + const {hasSession} = useSession() + const navigation = useNavigation<NavigationProp>() const {headerMinimalShellTransform} = useMinimalShellMode() + const {headerHeight} = useShellLayout() + const pinnedDisplayNames = hasSession ? feeds.map(f => f.displayName) : [] + const showFeedsLinkInTabBar = hasSession && !hasPinnedCustom + const items = showFeedsLinkInTabBar + ? pinnedDisplayNames.concat('Feeds ✨') + : pinnedDisplayNames + + const onPressDiscoverFeeds = React.useCallback(() => { + if (isWeb) { + navigation.navigate('Feeds') + } else { + navigation.navigate('FeedsTab') + navigation.popToTop() + } + }, [navigation]) + + const onSelect = React.useCallback( + (index: number) => { + if (showFeedsLinkInTabBar && index === items.length - 1) { + onPressDiscoverFeeds() + } else if (props.onSelect) { + props.onSelect(index) + } + }, + [items.length, onPressDiscoverFeeds, props, showFeedsLinkInTabBar], + ) return ( // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf <Animated.View - style={[pal.view, styles.tabBar, headerMinimalShellTransform]}> + style={[pal.view, styles.tabBar, headerMinimalShellTransform]} + onLayout={e => { + headerHeight.value = e.nativeEvent.layout.height + }}> <TabBar key={items.join(',')} {...props} + onSelect={onSelect} items={items} indicatorColor={pal.colors.link} /> </Animated.View> ) -}) +} const styles = StyleSheet.create({ tabBar: { diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index 9848ce2d5..882b6cfc5 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -1,10 +1,7 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {TabBar} from 'view/com/pager/TabBar' import {RenderTabBarFnProps} from 'view/com/pager/Pager' -import {useStores} from 'state/index' -import {useHomeTabs} from 'lib/hooks/useHomeTabs' import {usePalette} from 'lib/hooks/usePalette' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {Link} from '../util/Link' @@ -14,18 +11,54 @@ import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' import {s} from 'lib/styles' import {HITSLOP_10} from 'lib/constants' import Animated from 'react-native-reanimated' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' import {useSetDrawerOpen} from '#/state/shell/drawer-open' +import {useShellLayout} from '#/state/shell/shell-layout' +import {useSession} from '#/state/session' +import {usePinnedFeedsInfos} from '#/state/queries/feed' +import {isWeb} from 'platform/detection' +import {useNavigation} from '@react-navigation/native' +import {NavigationProp} from 'lib/routes/types' -export const FeedsTabBar = observer(function FeedsTabBarImpl( +export function FeedsTabBar( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) { const pal = usePalette('default') - const store = useStores() + const {isSandbox, hasSession} = useSession() + const {_} = useLingui() const setDrawerOpen = useSetDrawerOpen() - const items = useHomeTabs(store.preferences.pinnedFeeds) + const navigation = useNavigation<NavigationProp>() + const {feeds, hasPinnedCustom} = usePinnedFeedsInfos() const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3) - const {minimalShellMode, headerMinimalShellTransform} = useMinimalShellMode() + const {headerHeight} = useShellLayout() + const {headerMinimalShellTransform} = useMinimalShellMode() + const pinnedDisplayNames = hasSession ? feeds.map(f => f.displayName) : [] + const showFeedsLinkInTabBar = hasSession && !hasPinnedCustom + const items = showFeedsLinkInTabBar + ? pinnedDisplayNames.concat('Feeds ✨') + : pinnedDisplayNames + + const onPressFeedsLink = React.useCallback(() => { + if (isWeb) { + navigation.navigate('Feeds') + } else { + navigation.navigate('FeedsTab') + navigation.popToTop() + } + }, [navigation]) + + const onSelect = React.useCallback( + (index: number) => { + if (showFeedsLinkInTabBar && index === items.length - 1) { + onPressFeedsLink() + } else if (props.onSelect) { + props.onSelect(index) + } + }, + [items.length, onPressFeedsLink, props, showFeedsLinkInTabBar], + ) const onPressAvi = React.useCallback(() => { setDrawerOpen(true) @@ -33,20 +66,17 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl( return ( <Animated.View - style={[ - pal.view, - pal.border, - styles.tabBar, - headerMinimalShellTransform, - minimalShellMode && styles.disabled, - ]}> + style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]} + onLayout={e => { + headerHeight.value = e.nativeEvent.layout.height + }}> <View style={[pal.view, styles.topBar]}> <View style={[pal.view]}> <TouchableOpacity testID="viewHeaderDrawerBtn" onPress={onPressAvi} accessibilityRole="button" - accessibilityLabel="Open navigation" + accessibilityLabel={_(msg`Open navigation`)} accessibilityHint="Access profile and other navigation links" hitSlop={HITSLOP_10}> <FontAwesomeIcon @@ -57,35 +87,40 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl( </TouchableOpacity> </View> <Text style={[brandBlue, s.bold, styles.title]}> - {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'} + {isSandbox ? 'SANDBOX' : 'Bluesky'} </Text> - <View style={[pal.view]}> - <Link - testID="viewHeaderHomeFeedPrefsBtn" - href="/settings/home-feed" - hitSlop={HITSLOP_10} - accessibilityRole="button" - accessibilityLabel="Home Feed Preferences" - accessibilityHint=""> - <FontAwesomeIcon - icon="sliders" - style={pal.textLight as FontAwesomeIconStyle} - /> - </Link> + <View style={[pal.view, {width: 18}]}> + {hasSession && ( + <Link + testID="viewHeaderHomeFeedPrefsBtn" + href="/settings/home-feed" + hitSlop={HITSLOP_10} + accessibilityRole="button" + accessibilityLabel={_(msg`Home Feed Preferences`)} + accessibilityHint=""> + <FontAwesomeIcon + icon="sliders" + style={pal.textLight as FontAwesomeIconStyle} + /> + </Link> + )} </View> </View> - <TabBar - key={items.join(',')} - onPressSelected={props.onPressSelected} - selectedPage={props.selectedPage} - onSelect={props.onSelect} - testID={props.testID} - items={items} - indicatorColor={pal.colors.link} - /> + + {items.length > 0 && ( + <TabBar + key={items.join(',')} + onPressSelected={props.onPressSelected} + selectedPage={props.selectedPage} + onSelect={onSelect} + testID={props.testID} + items={items} + indicatorColor={pal.colors.link} + /> + )} </Animated.View> ) -}) +} const styles = StyleSheet.create({ tabBar: { @@ -95,7 +130,6 @@ const styles = StyleSheet.create({ right: 0, top: 0, flexDirection: 'column', - alignItems: 'center', borderBottomWidth: 1, }, topBar: { @@ -103,14 +137,10 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 18, - paddingTop: 8, - paddingBottom: 2, + paddingVertical: 8, width: '100%', }, title: { fontSize: 21, }, - disabled: { - pointerEvents: 'none', - }, }) diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index 531a41ee2..d70087504 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -26,6 +26,9 @@ interface Props { renderTabBar: RenderTabBarFn onPageSelected?: (index: number) => void onPageSelecting?: (index: number) => void + onPageScrollStateChanged?: ( + scrollState: 'idle' | 'dragging' | 'settling', + ) => void testID?: string } export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( @@ -35,6 +38,7 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( tabBarPosition = 'top', initialPage = 0, renderTabBar, + onPageScrollStateChanged, onPageSelected, onPageSelecting, testID, @@ -97,11 +101,12 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( [lastOffset, lastDirection, onPageSelecting], ) - const onPageScrollStateChanged = React.useCallback( + const handlePageScrollStateChanged = React.useCallback( (e: PageScrollStateChangedNativeEvent) => { scrollState.current = e.nativeEvent.pageScrollState + onPageScrollStateChanged?.(e.nativeEvent.pageScrollState) }, - [scrollState], + [scrollState, onPageScrollStateChanged], ) const onTabBarSelect = React.useCallback( @@ -123,7 +128,7 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( ref={pagerView} style={s.flex1} initialPage={initialPage} - onPageScrollStateChanged={onPageScrollStateChanged} + onPageScrollStateChanged={handlePageScrollStateChanged} onPageSelected={onPageSelectedInner} onPageScroll={onPageScroll}> {children} diff --git a/src/view/com/pager/Pager.web.tsx b/src/view/com/pager/Pager.web.tsx index 7ec292667..3b5e9164a 100644 --- a/src/view/com/pager/Pager.web.tsx +++ b/src/view/com/pager/Pager.web.tsx @@ -49,7 +49,18 @@ export const Pager = React.forwardRef(function PagerImpl( onSelect: onTabBarSelect, })} {React.Children.map(children, (child, i) => ( - <View style={selectedPage === i ? s.flex1 : s.hidden} key={`page-${i}`}> + <View + style={ + selectedPage === i + ? s.flex1 + : { + position: 'absolute', + pointerEvents: 'none', + // @ts-ignore web-only + visibility: 'hidden', + } + } + key={`page-${i}`}> {child} </View> ))} diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx index 701b52871..2d3b0cece 100644 --- a/src/view/com/pager/PagerWithHeader.tsx +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -1,28 +1,36 @@ import * as React from 'react' import { LayoutChangeEvent, - NativeScrollEvent, + FlatList, + ScrollView, StyleSheet, View, + NativeScrollEvent, } from 'react-native' import Animated, { - Easing, - useAnimatedReaction, useAnimatedStyle, useSharedValue, - withTiming, runOnJS, + runOnUI, + scrollTo, + useAnimatedRef, + AnimatedRef, + SharedValue, } from 'react-native-reanimated' import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' import {TabBar} from './TabBar' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' const SCROLLED_DOWN_LIMIT = 200 -interface PagerWithHeaderChildParams { +export interface PagerWithHeaderChildParams { headerHeight: number - onScroll: (e: NativeScrollEvent) => void + isFocused: boolean + onScroll: OnScrollHandler isScrolledDown: boolean + scrollElRef: React.MutableRefObject<FlatList<any> | ScrollView | null> } export interface PagerWithHeaderProps { @@ -51,117 +59,120 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>( }: PagerWithHeaderProps, ref, ) { - const {isMobile} = useWebMediaQueries() const [currentPage, setCurrentPage] = React.useState(0) - const scrollYs = React.useRef<Record<number, number>>({}) - const scrollY = useSharedValue(scrollYs.current[currentPage] || 0) const [tabBarHeight, setTabBarHeight] = React.useState(0) const [headerOnlyHeight, setHeaderOnlyHeight] = React.useState(0) - const [isScrolledDown, setIsScrolledDown] = React.useState( - scrollYs.current[currentPage] > SCROLLED_DOWN_LIMIT, - ) - + const [isScrolledDown, setIsScrolledDown] = React.useState(false) + const scrollY = useSharedValue(0) const headerHeight = headerOnlyHeight + tabBarHeight - // react to scroll updates - function onScrollUpdate(v: number) { - // track each page's current scroll position - scrollYs.current[currentPage] = Math.min(v, headerOnlyHeight) - // update the 'is scrolled down' value - setIsScrolledDown(v > SCROLLED_DOWN_LIMIT) - } - useAnimatedReaction( - () => scrollY.value, - v => runOnJS(onScrollUpdate)(v), - ) - // capture the header bar sizing const onTabBarLayout = React.useCallback( (evt: LayoutChangeEvent) => { - setTabBarHeight(evt.nativeEvent.layout.height) + const height = evt.nativeEvent.layout.height + if (height > 0) { + setTabBarHeight(height) + } }, [setTabBarHeight], ) const onHeaderOnlyLayout = React.useCallback( (evt: LayoutChangeEvent) => { - setHeaderOnlyHeight(evt.nativeEvent.layout.height) + const height = evt.nativeEvent.layout.height + if (height > 0) { + setHeaderOnlyHeight(height) + } }, [setHeaderOnlyHeight], ) - // render the the header and tab bar - const headerTransform = useAnimatedStyle( - () => ({ - transform: [ - { - translateY: Math.min( - Math.min(scrollY.value, headerOnlyHeight) * -1, - 0, - ), - }, - ], - }), - [scrollY, headerHeight, tabBarHeight], - ) const renderTabBar = React.useCallback( (props: RenderTabBarFnProps) => { return ( - <Animated.View - style={[ - isMobile ? styles.tabBarMobile : styles.tabBarDesktop, - headerTransform, - ]}> - <View onLayout={onHeaderOnlyLayout}>{renderHeader?.()}</View> - <View - onLayout={onTabBarLayout} - style={{ - // Render it immediately to measure it early since its size doesn't depend on the content. - // However, keep it invisible until the header above stabilizes in order to prevent jumps. - opacity: isHeaderReady ? 1 : 0, - pointerEvents: isHeaderReady ? 'auto' : 'none', - }}> - <TabBar - items={items} - selectedPage={currentPage} - onSelect={props.onSelect} - onPressSelected={onCurrentPageSelected} - /> - </View> - </Animated.View> + <PagerTabBar + headerOnlyHeight={headerOnlyHeight} + items={items} + isHeaderReady={isHeaderReady} + renderHeader={renderHeader} + currentPage={currentPage} + onCurrentPageSelected={onCurrentPageSelected} + onTabBarLayout={onTabBarLayout} + onHeaderOnlyLayout={onHeaderOnlyLayout} + onSelect={props.onSelect} + scrollY={scrollY} + testID={testID} + /> ) }, [ + headerOnlyHeight, items, isHeaderReady, renderHeader, - headerTransform, currentPage, onCurrentPageSelected, - isMobile, onTabBarLayout, onHeaderOnlyLayout, + scrollY, + testID, ], ) - // Ideally we'd call useAnimatedScrollHandler here but we can't safely do that - // due to https://github.com/software-mansion/react-native-reanimated/issues/5345. - // So instead we pass down a worklet, and individual pages will have to call it. - const onScroll = React.useCallback( - (e: NativeScrollEvent) => { + const scrollRefs = useSharedValue<AnimatedRef<any>[]>([]) + const registerRef = (scrollRef: AnimatedRef<any>, index: number) => { + scrollRefs.modify(refs => { 'worklet' - scrollY.value = e.contentOffset.y - }, - [scrollY], + refs[index] = scrollRef + return refs + }) + } + + const lastForcedScrollY = useSharedValue(0) + const adjustScrollForOtherPages = () => { + 'worklet' + const currentScrollY = scrollY.value + const forcedScrollY = Math.min(currentScrollY, headerOnlyHeight) + if (lastForcedScrollY.value !== forcedScrollY) { + lastForcedScrollY.value = forcedScrollY + const refs = scrollRefs.value + for (let i = 0; i < refs.length; i++) { + if (i !== currentPage) { + // This needs to run on the UI thread. + scrollTo(refs[i], 0, forcedScrollY, false) + } + } + } + } + + const throttleTimeout = React.useRef<ReturnType<typeof setTimeout> | null>( + null, ) + const queueThrottledOnScroll = useNonReactiveCallback(() => { + if (!throttleTimeout.current) { + throttleTimeout.current = setTimeout(() => { + throttleTimeout.current = null - // props to pass into children render functions - const childProps = React.useMemo<PagerWithHeaderChildParams>(() => { - return { - headerHeight, - onScroll, - isScrolledDown, + runOnUI(adjustScrollForOtherPages)() + + const nextIsScrolledDown = scrollY.value > SCROLLED_DOWN_LIMIT + if (isScrolledDown !== nextIsScrolledDown) { + React.startTransition(() => { + setIsScrolledDown(nextIsScrolledDown) + }) + } + }, 80 /* Sync often enough you're unlikely to catch it unsynced */) } - }, [headerHeight, onScroll, isScrolledDown]) + }) + + const onScrollWorklet = React.useCallback( + (e: NativeScrollEvent) => { + 'worklet' + const nextScrollY = e.contentOffset.y + scrollY.value = nextScrollY + runOnJS(queueThrottledOnScroll)() + }, + [scrollY, queueThrottledOnScroll], + ) const onPageSelectedInner = React.useCallback( (index: number) => { @@ -171,19 +182,9 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>( [onPageSelected, setCurrentPage], ) - const onPageSelecting = React.useCallback( - (index: number) => { - setCurrentPage(index) - if (scrollY.value > headerHeight) { - scrollY.value = headerHeight - } - scrollY.value = withTiming(scrollYs.current[index] || 0, { - duration: 170, - easing: Easing.inOut(Easing.quad), - }) - }, - [scrollY, setCurrentPage, scrollYs, headerHeight], - ) + const onPageSelecting = React.useCallback((index: number) => { + setCurrentPage(index) + }, []) return ( <Pager @@ -197,20 +198,19 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>( {toArray(children) .filter(Boolean) .map((child, i) => { - let output = null - if ( - child != null && - // Defer showing content until we know it won't jump. - isHeaderReady && - headerOnlyHeight > 0 && - tabBarHeight > 0 - ) { - output = child(childProps) - } - // Pager children must be noncollapsible plain <View>s. + const isReady = + isHeaderReady && headerOnlyHeight > 0 && tabBarHeight > 0 return ( <View key={i} collapsable={false}> - {output} + <PagerItem + headerHeight={headerHeight} + isReady={isReady} + isFocused={i === currentPage} + isScrolledDown={isScrolledDown} + onScrollWorklet={i === currentPage ? onScrollWorklet : noop} + registerRef={(r: AnimatedRef<any>) => registerRef(r, i)} + renderTab={child} + /> </View> ) })} @@ -219,6 +219,107 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>( }, ) +let PagerTabBar = ({ + currentPage, + headerOnlyHeight, + isHeaderReady, + items, + scrollY, + testID, + renderHeader, + onHeaderOnlyLayout, + onTabBarLayout, + onCurrentPageSelected, + onSelect, +}: { + currentPage: number + headerOnlyHeight: number + isHeaderReady: boolean + items: string[] + testID?: string + scrollY: SharedValue<number> + renderHeader?: () => JSX.Element + onHeaderOnlyLayout: (e: LayoutChangeEvent) => void + onTabBarLayout: (e: LayoutChangeEvent) => void + onCurrentPageSelected?: (index: number) => void + onSelect?: (index: number) => void +}): React.ReactNode => { + const {isMobile} = useWebMediaQueries() + const headerTransform = useAnimatedStyle(() => ({ + transform: [ + { + translateY: Math.min(Math.min(scrollY.value, headerOnlyHeight) * -1, 0), + }, + ], + })) + return ( + <Animated.View + style={[ + isMobile ? styles.tabBarMobile : styles.tabBarDesktop, + headerTransform, + ]}> + <View onLayout={onHeaderOnlyLayout}>{renderHeader?.()}</View> + <View + onLayout={onTabBarLayout} + style={{ + // Render it immediately to measure it early since its size doesn't depend on the content. + // However, keep it invisible until the header above stabilizes in order to prevent jumps. + opacity: isHeaderReady ? 1 : 0, + pointerEvents: isHeaderReady ? 'auto' : 'none', + }}> + <TabBar + testID={testID} + items={items} + selectedPage={currentPage} + onSelect={onSelect} + onPressSelected={onCurrentPageSelected} + /> + </View> + </Animated.View> + ) +} +PagerTabBar = React.memo(PagerTabBar) + +function PagerItem({ + headerHeight, + isReady, + isFocused, + isScrolledDown, + onScrollWorklet, + renderTab, + registerRef, +}: { + headerHeight: number + isFocused: boolean + isReady: boolean + isScrolledDown: boolean + registerRef: (scrollRef: AnimatedRef<any>) => void + onScrollWorklet: (e: NativeScrollEvent) => void + renderTab: ((props: PagerWithHeaderChildParams) => JSX.Element) | null +}) { + const scrollElRef = useAnimatedRef() + registerRef(scrollElRef) + + const scrollHandler = React.useMemo( + () => ({onScroll: onScrollWorklet}), + [onScrollWorklet], + ) + + if (!isReady || renderTab == null) { + return null + } + + return renderTab({ + headerHeight, + isFocused, + isScrolledDown, + onScroll: scrollHandler, + scrollElRef: scrollElRef as React.MutableRefObject< + FlatList<any> | ScrollView | null + >, + }) +} + const styles = StyleSheet.create({ tabBarMobile: { position: 'absolute', @@ -237,6 +338,10 @@ const styles = StyleSheet.create({ }, }) +function noop() { + 'worklet' +} + function toArray<T>(v: T | T[]): T[] { if (Array.isArray(v)) { return v diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 0e08b22d8..c3a95c5c0 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -68,6 +68,7 @@ export function TabBar({ return ( <View testID={testID} style={[pal.view, styles.outer]}> <DraggableScrollView + testID={`${testID}-selector`} horizontal={true} showsHorizontalScrollIndicator={false} ref={scrollElRef} @@ -76,6 +77,7 @@ export function TabBar({ const selected = i === selectedPage return ( <PressableWithHover + testID={`${testID}-selector-${i}`} key={item} onLayout={e => onItemLayout(e, i)} style={[styles.item, selected && indicatorStyle]} |