about summary refs log tree commit diff
path: root/src/view/com/pager
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/pager')
-rw-r--r--src/view/com/pager/FeedsTabBar.web.tsx110
-rw-r--r--src/view/com/pager/FeedsTabBarMobile.tsx120
-rw-r--r--src/view/com/pager/Pager.tsx11
-rw-r--r--src/view/com/pager/Pager.web.tsx13
-rw-r--r--src/view/com/pager/PagerWithHeader.tsx315
-rw-r--r--src/view/com/pager/TabBar.tsx2
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]}