about summary refs log tree commit diff
path: root/src/view/com
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com')
-rw-r--r--src/view/com/util/Selector.tsx111
-rw-r--r--src/view/com/util/ViewSelector.tsx131
2 files changed, 218 insertions, 24 deletions
diff --git a/src/view/com/util/Selector.tsx b/src/view/com/util/Selector.tsx
index adc393d89..95e4c66d4 100644
--- a/src/view/com/util/Selector.tsx
+++ b/src/view/com/util/Selector.tsx
@@ -1,37 +1,98 @@
-import React, {useState} from 'react'
-import {
-  StyleProp,
-  StyleSheet,
-  Text,
-  TouchableWithoutFeedback,
-  View,
-  ViewStyle,
-} from 'react-native'
+import React, {createRef, useState, useMemo} from 'react'
+import {StyleSheet, Text, TouchableWithoutFeedback, View} from 'react-native'
+import Animated, {
+  SharedValue,
+  useAnimatedStyle,
+  interpolate,
+} from 'react-native-reanimated'
 import {colors} from '../../lib/styles'
 
+interface Layout {
+  x: number
+  width: number
+}
+
 export function Selector({
-  style,
+  selectedIndex,
   items,
+  swipeGestureInterp,
   onSelect,
 }: {
-  style?: StyleProp<ViewStyle>
+  selectedIndex: number
   items: string[]
+  swipeGestureInterp: SharedValue<number>
   onSelect?: (index: number) => void
 }) {
-  const [selectedIndex, setSelectedIndex] = useState<number>(0)
+  const [itemLayouts, setItemLayouts] = useState<undefined | Layout[]>(
+    undefined,
+  )
+  const itemRefs = useMemo(
+    () => Array.from({length: items.length}).map(() => createRef<View>()),
+    [items.length],
+  )
+
+  const currentLayouts = useMemo(() => {
+    const left = itemLayouts?.[selectedIndex - 1] || {x: 0, width: 0}
+    const middle = itemLayouts?.[selectedIndex] || {x: 0, width: 0}
+    const right = itemLayouts?.[selectedIndex + 1] || {
+      x: middle.x + 20,
+      width: middle.width,
+    }
+    return [left, middle, right]
+  }, [selectedIndex, itemLayouts])
+
+  const underlinePos = useAnimatedStyle(() => {
+    const other =
+      swipeGestureInterp.value === 0
+        ? currentLayouts[1]
+        : swipeGestureInterp.value < 0
+        ? currentLayouts[0]
+        : currentLayouts[2]
+    return {
+      left: interpolate(
+        Math.abs(swipeGestureInterp.value),
+        [0, 1],
+        [currentLayouts[1].x, other.x],
+      ),
+      width: interpolate(
+        Math.abs(swipeGestureInterp.value),
+        [0, 1],
+        [currentLayouts[1].width, other.width],
+      ),
+    }
+  }, [currentLayouts, swipeGestureInterp])
+
+  const onLayout = () => {
+    const promises = []
+    for (let i = 0; i < items.length; i++) {
+      promises.push(
+        new Promise<Layout>(resolve => {
+          itemRefs[i].current?.measure(
+            (x: number, _y: number, width: number) => {
+              resolve({x, width})
+            },
+          )
+        }),
+      )
+    }
+    Promise.all(promises).then((layouts: Layout[]) => {
+      setItemLayouts(layouts)
+    })
+  }
+
   const onPressItem = (index: number) => {
-    setSelectedIndex(index)
     onSelect?.(index)
   }
 
   return (
-    <View style={[styles.outer, style]}>
+    <View style={[styles.outer]} onLayout={onLayout}>
+      <Animated.View style={[styles.underline, underlinePos]} />
       {items.map((item, i) => {
         const selected = i === selectedIndex
         return (
           <TouchableWithoutFeedback key={i} onPress={() => onPressItem(i)}>
-            <View style={selected ? styles.itemSelected : styles.item}>
-              <Text style={selected ? styles.labelSelected : styles.label}>
+            <View style={styles.item} ref={itemRefs[i]}>
+              <Text style={selected ? styles.labelSelected : styles.itemLabel}>
                 {item}
               </Text>
             </View>
@@ -45,25 +106,27 @@ export function Selector({
 const styles = StyleSheet.create({
   outer: {
     flexDirection: 'row',
+    paddingTop: 8,
+    paddingBottom: 12,
     paddingHorizontal: 14,
+    backgroundColor: colors.white,
   },
   item: {
-    paddingBottom: 12,
     marginRight: 20,
   },
-  label: {
+  itemLabel: {
     fontWeight: '600',
     fontSize: 16,
     color: colors.gray5,
   },
-  itemSelected: {
-    paddingBottom: 8,
-    marginRight: 20,
-    borderBottomWidth: 4,
-    borderBottomColor: colors.purple3,
-  },
   labelSelected: {
     fontWeight: '600',
     fontSize: 16,
   },
+  underline: {
+    position: 'absolute',
+    height: 4,
+    backgroundColor: colors.purple3,
+    bottom: 0,
+  },
 })
diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx
new file mode 100644
index 000000000..5dab54de7
--- /dev/null
+++ b/src/view/com/util/ViewSelector.tsx
@@ -0,0 +1,131 @@
+import React, {useEffect, useState, useMemo} from 'react'
+import {FlatList, StyleSheet, View} from 'react-native'
+import {GestureDetector, Gesture} from 'react-native-gesture-handler'
+import {useSharedValue, withTiming, runOnJS} from 'react-native-reanimated'
+import {Selector} from './Selector'
+
+const HEADER_ITEM = {_reactKey: '__header__'}
+const SELECTOR_ITEM = {_reactKey: '__selector__'}
+const STICKY_HEADER_INDICES = [1]
+const SWIPE_GESTURE_MAX_DISTANCE = 200
+const SWIPE_GESTURE_HIT_SLOP = {left: -20, top: 0, right: 0, bottom: 0} // we ignore the left 20 pixels to avoid conflicts with the page-nav gesture
+
+export function ViewSelector({
+  sections,
+  items,
+  refreshing,
+  renderHeader,
+  renderItem,
+  onSelectView,
+  onRefresh,
+  onEndReached,
+}: {
+  sections: string[]
+  items: any[]
+  refreshing?: boolean
+  renderHeader?: () => JSX.Element
+  renderItem: (item: any) => JSX.Element
+  onSelectView?: (viewIndex: number) => void
+  onRefresh?: () => void
+  onEndReached?: (info: {distanceFromEnd: number}) => void
+}) {
+  const [selectedIndex, setSelectedIndex] = useState<number>(0)
+  const swipeGestureInterp = useSharedValue<number>(0)
+
+  // events
+  // =
+
+  const onPressSelection = (index: number) => setSelectedIndex(index)
+  useEffect(() => {
+    onSelectView?.(selectedIndex)
+  }, [selectedIndex])
+
+  // gestures
+  // =
+
+  const swipeGesture = useMemo(
+    () =>
+      Gesture.Pan()
+        .hitSlop(SWIPE_GESTURE_HIT_SLOP)
+        .onUpdate(e => {
+          // calculate [-1, 1] range for the gesture
+          const clamped = Math.min(e.translationX, SWIPE_GESTURE_MAX_DISTANCE)
+          const reversed = clamped * -1
+          const scaled = reversed / SWIPE_GESTURE_MAX_DISTANCE
+          swipeGestureInterp.value = scaled
+        })
+        .onEnd(e => {
+          if (swipeGestureInterp.value >= 0.5) {
+            // swiped to next
+            if (selectedIndex < sections.length - 1) {
+              // interp to the next item's position...
+              swipeGestureInterp.value = withTiming(1, {duration: 100}, () => {
+                // ...then update the index, which triggers the useEffect() below [1]
+                runOnJS(setSelectedIndex)(selectedIndex + 1)
+              })
+            } else {
+              swipeGestureInterp.value = withTiming(0, {duration: 100})
+            }
+          } else if (swipeGestureInterp.value <= -0.5) {
+            // swiped to prev
+            if (selectedIndex > 0) {
+              // interp to the prev item's position...
+              swipeGestureInterp.value = withTiming(-1, {duration: 100}, () => {
+                // ...then update the index, which triggers the useEffect() below [1]
+                runOnJS(setSelectedIndex)(selectedIndex - 1)
+              })
+            } else {
+              swipeGestureInterp.value = withTiming(0, {duration: 100})
+            }
+          } else {
+            swipeGestureInterp.value = withTiming(0, {duration: 100})
+          }
+        }),
+    [swipeGestureInterp, selectedIndex, sections.length],
+  )
+  useEffect(() => {
+    // [1] completes the swipe gesture animation by resetting the interp value
+    // this has to be done as an effect so that it occurs *after* the selectedIndex has been updated
+    swipeGestureInterp.value = 0
+  }, [swipeGestureInterp, selectedIndex])
+
+  // rendering
+  // =
+
+  const renderItemInternal = ({item}: {item: any}) => {
+    if (item === HEADER_ITEM) {
+      if (renderHeader) {
+        return renderHeader()
+      }
+      return <View />
+    } else if (item === SELECTOR_ITEM) {
+      return (
+        <Selector
+          items={sections}
+          selectedIndex={selectedIndex}
+          swipeGestureInterp={swipeGestureInterp}
+          onSelect={onPressSelection}
+        />
+      )
+    } else {
+      return renderItem(item)
+    }
+  }
+
+  const data = [HEADER_ITEM, SELECTOR_ITEM, ...items]
+  return (
+    <GestureDetector gesture={swipeGesture}>
+      <FlatList
+        data={data}
+        keyExtractor={item => item._reactKey}
+        renderItem={renderItemInternal}
+        stickyHeaderIndices={STICKY_HEADER_INDICES}
+        refreshing={refreshing}
+        onRefresh={onRefresh}
+        onEndReached={onEndReached}
+      />
+    </GestureDetector>
+  )
+}
+
+const styles = StyleSheet.create({})