about summary refs log tree commit diff
path: root/src/view/com/pager/Pager.tsx
diff options
context:
space:
mode:
authordan <dan.abramov@gmail.com>2024-12-03 01:29:45 +0000
committerGitHub <noreply@github.com>2024-12-03 01:29:45 +0000
commitcd811114ef0fc1164b8909e3debda792cd2a659c (patch)
treec866398bb1fec935c3d61305a0530d220fcbad1d /src/view/com/pager/Pager.tsx
parent5a313c2d10b112458830b3bfc708031f6f8726a0 (diff)
downloadvoidsky-cd811114ef0fc1164b8909e3debda792cd2a659c.tar.zst
[Nicer Tabs] New native pager (#6868)
* Remove tab bar autoscroll

This will be replaced by a different mechanism.

* Track pager drag gesture in a worklet

* Track pager state change in a worklet

* Track offset relative to current page

* Sync scroll to swipe

* Extract TabBarItem

* Sync scroll to swipe properly

* Implement all interactions

* Clarify more hacks

* Simplify the implementation

I was trying to be too smart and this was causing the current page event to lag behind if you continuously drag. Better to let the library do its job.

* Interpolate the indicator

* Fix an infinite swipe loop

* Add TODO

* Animate header color

* Respect initial page

* Keep layouts in a shared value

* Fix profile and types

* Fast path for initial styles

* Scroll to initial

* Factor out a helper

* Fix positioning

* Scroll into view on tap if needed

* Divide free space proportionally

* Scroll into view more aggressively

* Fix corner case

* Ignore spurious event on iOS

* Simplify the condition

Due to RN onLayout event ordering, we know that by now we'll have container and content sizes already.

* Change boolean state to enum

* Better syncing heuristic

* Rm extra return
Diffstat (limited to 'src/view/com/pager/Pager.tsx')
-rw-r--r--src/view/com/pager/Pager.tsx115
1 files changed, 95 insertions, 20 deletions
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,
+  )
+}