about summary refs log tree commit diff
path: root/src/view/com/pager/TabBar.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/pager/TabBar.tsx')
-rw-r--r--src/view/com/pager/TabBar.tsx393
1 files changed, 332 insertions, 61 deletions
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: {