about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/view/com/home/HomeHeader.tsx5
-rw-r--r--src/view/com/pager/Pager.tsx115
-rw-r--r--src/view/com/pager/PagerWithHeader.tsx8
-rw-r--r--src/view/com/pager/PagerWithHeader.web.tsx2
-rw-r--r--src/view/com/pager/TabBar.tsx393
-rw-r--r--src/view/screens/Home.tsx5
6 files changed, 443 insertions, 85 deletions
diff --git a/src/view/com/home/HomeHeader.tsx b/src/view/com/home/HomeHeader.tsx
index 31c713563..0ec9ac753 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<NavigationProp>()
-  const pal = usePalette('default')
 
   const hasPinnedCustom = React.useMemo<boolean>(() => {
     if (!hasSession) return false
@@ -61,7 +59,8 @@ export function HomeHeader(
         onSelect={onSelect}
         testID={props.testID}
         items={items}
-        indicatorColor={pal.colors.link}
+        dragProgress={props.dragProgress}
+        dragState={props.dragState}
       />
     </HomeHeaderLayout>
   )
diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx
index f0e686b6a..da7fd1e93 100644
--- a/src/view/com/pager/Pager.tsx
+++ b/src/view/com/pager/Pager.tsx
@@ -1,9 +1,18 @@
 import React, {forwardRef} from 'react'
 import {View} from 'react-native'
 import PagerView, {
+  PagerViewOnPageScrollEventData,
   PagerViewOnPageSelectedEvent,
-  PageScrollStateChangedNativeEvent,
+  PagerViewOnPageSelectedEventData,
+  PageScrollStateChangedNativeEventData,
 } from 'react-native-pager-view'
+import Animated, {
+  runOnJS,
+  SharedValue,
+  useEvent,
+  useHandler,
+  useSharedValue,
+} from 'react-native-reanimated'
 
 import {atoms as a, native} from '#/alf'
 
@@ -17,6 +26,8 @@ export interface RenderTabBarFnProps {
   selectedPage: number
   onSelect?: (index: number) => void
   tabBarAnchor?: JSX.Element | null | undefined // Ignored on native.
+  dragProgress: SharedValue<number> // Ignored on web.
+  dragState: SharedValue<'idle' | 'dragging' | 'settling'> // Ignored on web.
 }
 export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element
 
@@ -29,19 +40,22 @@ interface Props {
   ) => void
   testID?: string
 }
+
+const AnimatedPagerView = Animated.createAnimatedComponent(PagerView)
+
 export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
   function PagerImpl(
     {
       children,
       initialPage = 0,
       renderTabBar,
-      onPageScrollStateChanged,
-      onPageSelected,
+      onPageScrollStateChanged: parentOnPageScrollStateChanged,
+      onPageSelected: parentOnPageSelected,
       testID,
     }: React.PropsWithChildren<Props>,
     ref,
   ) {
-    const [selectedPage, setSelectedPage] = React.useState(0)
+    const [selectedPage, setSelectedPage] = React.useState(initialPage)
     const pagerView = React.useRef<PagerView>(null)
 
     React.useImperativeHandle(ref, () => ({
@@ -50,19 +64,12 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
       },
     }))
 
-    const onPageSelectedInner = React.useCallback(
-      (e: PageSelectedEvent) => {
-        setSelectedPage(e.nativeEvent.position)
-        onPageSelected?.(e.nativeEvent.position)
-      },
-      [setSelectedPage, onPageSelected],
-    )
-
-    const handlePageScrollStateChanged = React.useCallback(
-      (e: PageScrollStateChangedNativeEvent) => {
-        onPageScrollStateChanged?.(e.nativeEvent.pageScrollState)
+    const onPageSelectedJSThread = React.useCallback(
+      (nextPosition: number) => {
+        setSelectedPage(nextPosition)
+        parentOnPageSelected?.(nextPosition)
       },
-      [onPageScrollStateChanged],
+      [setSelectedPage, parentOnPageSelected],
     )
 
     const onTabBarSelect = React.useCallback(
@@ -72,21 +79,89 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
       [pagerView],
     )
 
+    const dragState = useSharedValue<'idle' | 'settling' | 'dragging'>('idle')
+    const dragProgress = useSharedValue(selectedPage)
+    const didInit = useSharedValue(false)
+    const handlePageScroll = usePagerHandlers(
+      {
+        onPageScroll(e: PagerViewOnPageScrollEventData) {
+          'worklet'
+          if (didInit.get() === false) {
+            // On iOS, there's a spurious scroll event with 0 position
+            // even if a different page was supplied as the initial page.
+            // Ignore it and wait for the first confirmed selection instead.
+            return
+          }
+          dragProgress.set(e.offset + e.position)
+        },
+        onPageScrollStateChanged(e: PageScrollStateChangedNativeEventData) {
+          'worklet'
+          if (dragState.get() === 'idle' && e.pageScrollState === 'settling') {
+            // This is a programmatic scroll on Android.
+            // Stay "idle" to match iOS and avoid confusing downstream code.
+            return
+          }
+          dragState.set(e.pageScrollState)
+          parentOnPageScrollStateChanged?.(e.pageScrollState)
+        },
+        onPageSelected(e: PagerViewOnPageSelectedEventData) {
+          'worklet'
+          didInit.set(true)
+          runOnJS(onPageSelectedJSThread)(e.position)
+        },
+      },
+      [parentOnPageScrollStateChanged],
+    )
+
     return (
       <View testID={testID} style={[a.flex_1, native(a.overflow_hidden)]}>
         {renderTabBar({
           selectedPage,
           onSelect: onTabBarSelect,
+          dragProgress,
+          dragState,
         })}
-        <PagerView
+        <AnimatedPagerView
           ref={pagerView}
           style={[a.flex_1]}
           initialPage={initialPage}
-          onPageScrollStateChanged={handlePageScrollStateChanged}
-          onPageSelected={onPageSelectedInner}>
+          onPageScroll={handlePageScroll}>
           {children}
-        </PagerView>
+        </AnimatedPagerView>
       </View>
     )
   },
 )
+
+function usePagerHandlers(
+  handlers: {
+    onPageScroll: (e: PagerViewOnPageScrollEventData) => void
+    onPageScrollStateChanged: (e: PageScrollStateChangedNativeEventData) => void
+    onPageSelected: (e: PagerViewOnPageSelectedEventData) => void
+  },
+  dependencies: unknown[],
+) {
+  const {doDependenciesDiffer} = useHandler(handlers as any, dependencies)
+  const subscribeForEvents = [
+    'onPageScroll',
+    'onPageScrollStateChanged',
+    'onPageSelected',
+  ]
+  return useEvent(
+    event => {
+      'worklet'
+      const {onPageScroll, onPageScrollStateChanged, onPageSelected} = handlers
+      if (event.eventName.endsWith('onPageScroll')) {
+        onPageScroll(event as any as PagerViewOnPageScrollEventData)
+      } else if (event.eventName.endsWith('onPageScrollStateChanged')) {
+        onPageScrollStateChanged(
+          event as any as PageScrollStateChangedNativeEventData,
+        )
+      } else if (event.eventName.endsWith('onPageSelected')) {
+        onPageSelected(event as any as PagerViewOnPageSelectedEventData)
+      }
+    },
+    subscribeForEvents,
+    doDependenciesDiffer,
+  )
+}
diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx
index 1aa45ffba..617445964 100644
--- a/src/view/com/pager/PagerWithHeader.tsx
+++ b/src/view/com/pager/PagerWithHeader.tsx
@@ -97,6 +97,8 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
               scrollY={scrollY}
               testID={testID}
               allowHeaderOverScroll={allowHeaderOverScroll}
+              dragProgress={props.dragProgress}
+              dragState={props.dragState}
             />
           </PagerHeaderProvider>
         )
@@ -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<number>
+  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}
         />
       </View>
     </Animated.View>
diff --git a/src/view/com/pager/PagerWithHeader.web.tsx b/src/view/com/pager/PagerWithHeader.web.tsx
index dd0026405..13c723f47 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 */}
         />
       </View>
     </>
diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx
index 3f453971c..c19b93664 100644
--- a/src/view/com/pager/TabBar.tsx
+++ b/src/view/com/pager/TabBar.tsx
@@ -1,5 +1,16 @@
-import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
+import {useCallback} from 'react'
 import {LayoutChangeEvent, ScrollView, StyleSheet, View} from 'react-native'
+import Animated, {
+  interpolate,
+  runOnJS,
+  runOnUI,
+  scrollTo,
+  SharedValue,
+  useAnimatedReaction,
+  useAnimatedRef,
+  useAnimatedStyle,
+  useSharedValue,
+} from 'react-native-reanimated'
 
 import {usePalette} from '#/lib/hooks/usePalette'
 import {PressableWithHover} from '../util/PressableWithHover'
@@ -9,61 +20,245 @@ export interface TabBarProps {
   testID?: string
   selectedPage: number
   items: string[]
-  indicatorColor?: string
   onSelect?: (index: number) => void
   onPressSelected?: (index: number) => void
+  dragProgress: SharedValue<number>
+  dragState: SharedValue<'idle' | 'dragging' | 'settling'>
 }
 
-// How much of the previous/next item we're showing
-// to give the user a hint there's more to scroll.
+const ITEM_PADDING = 10
+const CONTENT_PADDING = 6
+// How much of the previous/next item we're requiring
+// when deciding whether to scroll into view on tap.
 const OFFSCREEN_ITEM_WIDTH = 20
 
 export function TabBar({
   testID,
   selectedPage,
   items,
-  indicatorColor,
   onSelect,
   onPressSelected,
+  dragProgress,
+  dragState,
 }: TabBarProps) {
   const pal = usePalette('default')
-  const scrollElRef = useRef<ScrollView>(null)
-  const [itemXs, setItemXs] = useState<number[]>([])
-  const indicatorStyle = useMemo(
-    () => ({borderBottomColor: indicatorColor || pal.colors.link}),
-    [indicatorColor, pal],
+  const scrollElRef = useAnimatedRef<ScrollView>()
+  const syncScrollState = useSharedValue<'synced' | 'unsynced' | 'needs-sync'>(
+    'synced',
   )
+  const didInitialScroll = useSharedValue(false)
+  const contentSize = useSharedValue(0)
+  const containerSize = useSharedValue(0)
+  const scrollX = useSharedValue(0)
+  const layouts = useSharedValue<{x: number; width: number}[]>([])
+  const itemsLength = items.length
 
-  useEffect(() => {
-    // On native, the primary interaction is swiping.
-    // We adjust the scroll little by little on every tab change.
-    // Scroll into view but keep the end of the previous item visible.
-    let x = itemXs[selectedPage] || 0
-    x = Math.max(0, x - OFFSCREEN_ITEM_WIDTH)
-    scrollElRef.current?.scrollTo({x})
-  }, [scrollElRef, itemXs, selectedPage])
+  const scrollToOffsetJS = useCallback(
+    (x: number) => {
+      scrollElRef.current?.scrollTo({
+        x,
+        y: 0,
+        animated: true,
+      })
+    },
+    [scrollElRef],
+  )
 
-  const onPressItem = useCallback(
+  const indexToOffset = useCallback(
     (index: number) => {
-      onSelect?.(index)
-      if (index === selectedPage) {
-        onPressSelected?.(index)
+      'worklet'
+      const layout = layouts.get()[index]
+      const availableSize = containerSize.get() - 2 * CONTENT_PADDING
+      if (!layout) {
+        // Should not happen, but fall back to equal sizes.
+        const offsetPerPage = contentSize.get() - availableSize
+        return (index / (itemsLength - 1)) * offsetPerPage
+      }
+      const freeSpace = availableSize - layout.width
+      const accumulatingOffset = interpolate(
+        index,
+        // Gradually shift every next item to the left so that the first item
+        // is positioned like "left: 0" but the last item is like "right: 0".
+        [0, itemsLength - 1],
+        [0, freeSpace],
+        'clamp',
+      )
+      return layout.x - accumulatingOffset
+    },
+    [itemsLength, contentSize, containerSize, layouts],
+  )
+
+  const progressToOffset = useCallback(
+    (progress: number) => {
+      'worklet'
+      return interpolate(
+        progress,
+        [Math.floor(progress), Math.ceil(progress)],
+        [
+          indexToOffset(Math.floor(progress)),
+          indexToOffset(Math.ceil(progress)),
+        ],
+        'clamp',
+      )
+    },
+    [indexToOffset],
+  )
+
+  // When we know the entire layout for the first time, scroll selection into view.
+  useAnimatedReaction(
+    () => layouts.get().length,
+    (nextLayoutsLength, prevLayoutsLength) => {
+      if (nextLayoutsLength !== prevLayoutsLength) {
+        if (
+          nextLayoutsLength === itemsLength &&
+          didInitialScroll.get() === false
+        ) {
+          didInitialScroll.set(true)
+          const progress = dragProgress.get()
+          const offset = progressToOffset(progress)
+          // It's unclear why we need to go back to JS here. It seems iOS-specific.
+          runOnJS(scrollToOffsetJS)(offset)
+        }
+      }
+    },
+  )
+
+  // When you swipe the pager, the tabbar should scroll automatically
+  // as you're dragging the page and then even during deceleration.
+  useAnimatedReaction(
+    () => dragProgress.get(),
+    (nextProgress, prevProgress) => {
+      if (
+        nextProgress !== prevProgress &&
+        dragState.value !== 'idle' &&
+        // This is only OK to do when we're 100% sure we're synced.
+        // Otherwise, there would be a jump at the beginning of the swipe.
+        syncScrollState.get() === 'synced'
+      ) {
+        const offset = progressToOffset(nextProgress)
+        scrollTo(scrollElRef, offset, 0, false)
+      }
+    },
+  )
+
+  // If the syncing is currently off but you've just finished swiping,
+  // it's an opportunity to resync. It won't feel disruptive because
+  // you're not directly interacting with the tabbar at the moment.
+  useAnimatedReaction(
+    () => dragState.value,
+    (nextDragState, prevDragState) => {
+      if (
+        nextDragState !== prevDragState &&
+        nextDragState === 'idle' &&
+        (syncScrollState.get() === 'unsynced' ||
+          syncScrollState.get() === 'needs-sync')
+      ) {
+        const progress = dragProgress.get()
+        const offset = progressToOffset(progress)
+        scrollTo(scrollElRef, offset, 0, true)
+        syncScrollState.set('synced')
+      }
+    },
+  )
+
+  // When you press on the item, we'll scroll into view -- unless you previously
+  // have scrolled the tabbar manually, in which case it'll re-sync on next press.
+  const onPressUIThread = useCallback(
+    (index: number) => {
+      'worklet'
+      const itemLayout = layouts.get()[index]
+      if (!itemLayout) {
+        // Should not happen.
+        return
+      }
+      const leftEdge = itemLayout.x - OFFSCREEN_ITEM_WIDTH
+      const rightEdge = itemLayout.x + itemLayout.width + OFFSCREEN_ITEM_WIDTH
+      const scrollLeft = scrollX.get()
+      const scrollRight = scrollLeft + containerSize.get()
+      const scrollIntoView = leftEdge < scrollLeft || rightEdge > scrollRight
+      if (
+        syncScrollState.get() === 'synced' ||
+        syncScrollState.get() === 'needs-sync' ||
+        scrollIntoView
+      ) {
+        const offset = progressToOffset(index)
+        scrollTo(scrollElRef, offset, 0, true)
+        syncScrollState.set('synced')
+      } else {
+        // The item is already in view so it's disruptive to
+        // scroll right now. Do it on the next opportunity.
+        syncScrollState.set('needs-sync')
       }
     },
-    [onSelect, selectedPage, onPressSelected],
+    [
+      syncScrollState,
+      scrollElRef,
+      scrollX,
+      progressToOffset,
+      containerSize,
+      layouts,
+    ],
   )
 
-  // calculates the x position of each item on mount and on layout change
-  const onItemLayout = React.useCallback(
-    (e: LayoutChangeEvent, index: number) => {
-      const x = e.nativeEvent.layout.x
-      setItemXs(prev => {
-        const Xs = [...prev]
-        Xs[index] = x
-        return Xs
+  const onItemLayout = useCallback(
+    (i: number, layout: {x: number; width: number}) => {
+      'worklet'
+      layouts.modify(ls => {
+        ls[i] = layout
+        return ls
       })
     },
-    [],
+    [layouts],
+  )
+
+  const indicatorStyle = useAnimatedStyle(() => {
+    if (!_WORKLET) {
+      return {opacity: 0}
+    }
+    const layoutsValue = layouts.get()
+    if (
+      layoutsValue.length !== itemsLength ||
+      layoutsValue.some(l => l === undefined)
+    ) {
+      return {
+        opacity: 0,
+      }
+    }
+    if (layoutsValue.length === 1) {
+      return {opacity: 1}
+    }
+    return {
+      opacity: 1,
+      transform: [
+        {
+          translateX: interpolate(
+            dragProgress.get(),
+            layoutsValue.map((l, i) => i),
+            layoutsValue.map(l => l.x + l.width / 2 - contentSize.get() / 2),
+          ),
+        },
+        {
+          scaleX: interpolate(
+            dragProgress.get(),
+            layoutsValue.map((l, i) => i),
+            layoutsValue.map(
+              l => (l.width - ITEM_PADDING * 2) / contentSize.get(),
+            ),
+          ),
+        },
+      ],
+    }
+  })
+
+  const onPressItem = useCallback(
+    (index: number) => {
+      runOnUI(onPressUIThread)(index)
+      onSelect?.(index)
+      if (index === selectedPage) {
+        onPressSelected?.(index)
+      }
+    },
+    [onSelect, selectedPage, onPressSelected, onPressUIThread],
   )
 
   return (
@@ -76,50 +271,126 @@ export function TabBar({
         horizontal={true}
         showsHorizontalScrollIndicator={false}
         ref={scrollElRef}
-        contentContainerStyle={styles.contentContainer}>
-        {items.map((item, i) => {
-          const selected = i === selectedPage
-          return (
-            <PressableWithHover
-              testID={`${testID}-selector-${i}`}
-              key={`${item}-${i}`}
-              onLayout={e => onItemLayout(e, i)}
-              style={styles.item}
-              hoverStyle={pal.viewLight}
-              onPress={() => onPressItem(i)}
-              accessibilityRole="tab">
-              <View style={[styles.itemInner, selected && indicatorStyle]}>
-                <Text
-                  emoji
-                  type="lg-bold"
-                  testID={testID ? `${testID}-${item}` : undefined}
-                  style={[
-                    selected ? pal.text : pal.textLight,
-                    {lineHeight: 20},
-                  ]}>
-                  {item}
-                </Text>
-              </View>
-            </PressableWithHover>
-          )
-        })}
+        contentContainerStyle={styles.contentContainer}
+        onLayout={e => {
+          containerSize.set(e.nativeEvent.layout.width)
+        }}
+        onScrollBeginDrag={() => {
+          // Remember that you've manually messed with the tabbar scroll.
+          // This will disable auto-adjustment until after next pager swipe or item tap.
+          syncScrollState.set('unsynced')
+        }}
+        onScroll={e => {
+          scrollX.value = Math.round(e.nativeEvent.contentOffset.x)
+        }}>
+        <Animated.View
+          onLayout={e => {
+            contentSize.set(e.nativeEvent.layout.width)
+          }}
+          style={{flexDirection: 'row'}}>
+          {items.map((item, i) => {
+            return (
+              <TabBarItem
+                key={i}
+                index={i}
+                testID={testID}
+                dragProgress={dragProgress}
+                item={item}
+                onPressItem={onPressItem}
+                onItemLayout={onItemLayout}
+              />
+            )
+          })}
+          <Animated.View
+            style={[
+              indicatorStyle,
+              {
+                position: 'absolute',
+                left: 0,
+                bottom: 0,
+                right: 0,
+                borderBottomWidth: 3,
+                borderColor: pal.link.color,
+              },
+            ]}
+          />
+        </Animated.View>
       </ScrollView>
       <View style={[pal.border, styles.outerBottomBorder]} />
     </View>
   )
 }
 
+function TabBarItem({
+  index,
+  testID,
+  dragProgress,
+  item,
+  onPressItem,
+  onItemLayout,
+}: {
+  index: number
+  testID: string | undefined
+  dragProgress: SharedValue<number>
+  item: string
+  onPressItem: (index: number) => void
+  onItemLayout: (index: number, layout: {x: number; width: number}) => void
+}) {
+  const pal = usePalette('default')
+  const style = useAnimatedStyle(() => {
+    if (!_WORKLET) {
+      return {opacity: 0.7}
+    }
+    return {
+      opacity: interpolate(
+        dragProgress.get(),
+        [index - 1, index, index + 1],
+        [0.7, 1, 0.7],
+        'clamp',
+      ),
+    }
+  })
+
+  const handleLayout = useCallback(
+    (e: LayoutChangeEvent) => {
+      runOnUI(onItemLayout)(index, e.nativeEvent.layout)
+    },
+    [index, onItemLayout],
+  )
+
+  return (
+    <View onLayout={handleLayout}>
+      <PressableWithHover
+        testID={`${testID}-selector-${index}`}
+        style={styles.item}
+        hoverStyle={pal.viewLight}
+        onPress={() => onPressItem(index)}
+        accessibilityRole="tab">
+        <Animated.View style={[style, styles.itemInner]}>
+          <Text
+            emoji
+            type="lg-bold"
+            testID={testID ? `${testID}-${item}` : undefined}
+            style={[pal.text, {lineHeight: 20}]}>
+            {item}
+          </Text>
+        </Animated.View>
+      </PressableWithHover>
+    </View>
+  )
+}
+
 const styles = StyleSheet.create({
   outer: {
     flexDirection: 'row',
   },
   contentContainer: {
     backgroundColor: 'transparent',
-    paddingHorizontal: 6,
+    paddingHorizontal: CONTENT_PADDING,
   },
   item: {
     paddingTop: 10,
-    paddingHorizontal: 10,
+    paddingHorizontal: ITEM_PADDING,
     justifyContent: 'center',
   },
   itemInner: {
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 7bd0b6e57..1218a5ba0 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -156,8 +156,10 @@ function HomeScreenReady({
       setMinimalShellMode(false)
       setDrawerSwipeDisabled(index > 0)
       const feed = allFeeds[index]
-      setSelectedFeed(feed)
+      // Mutate the ref before setting state to avoid the imperative syncing effect
+      // above from starting a loop on Android when swiping back and forth.
       lastPagerReportedIndexRef.current = index
+      setSelectedFeed(feed)
       logEvent('home:feedDisplayed', {
         index,
         feedType: feed.split('|')[0],
@@ -173,6 +175,7 @@ function HomeScreenReady({
 
   const onPageScrollStateChanged = React.useCallback(
     (state: 'idle' | 'dragging' | 'settling') => {
+      'worklet'
       if (state === 'dragging') {
         setMinimalShellMode(false)
       }