about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2022-12-07 15:51:06 -0600
committerPaul Frazee <pfrazee@gmail.com>2022-12-07 15:51:06 -0600
commit9ce02dff5b995d384bdc5ea43323a0d03ca754cc (patch)
treeda40c795ca8358d74659d40ad5369c5a14534f79 /src
parent79d5708b695be94750af4c49785a7de0801dc2ae (diff)
downloadvoidsky-9ce02dff5b995d384bdc5ea43323a0d03ca754cc.tar.zst
Add HorzSwipe gesture and integrate it into the ViewSelector
Diffstat (limited to 'src')
-rw-r--r--src/state/models/shell-ui.ts5
-rw-r--r--src/view/com/util/Selector.tsx60
-rw-r--r--src/view/com/util/ViewSelector.tsx107
-rw-r--r--src/view/com/util/gestures/HorzSwipe.tsx126
-rw-r--r--src/view/screens/Menu.tsx1
-rw-r--r--src/view/screens/Profile.tsx1
6 files changed, 191 insertions, 109 deletions
diff --git a/src/state/models/shell-ui.ts b/src/state/models/shell-ui.ts
index fa8e3c18f..b3fe5104f 100644
--- a/src/state/models/shell-ui.ts
+++ b/src/state/models/shell-ui.ts
@@ -66,6 +66,7 @@ export interface ComposerOpts {
 }
 
 export class ShellUiModel {
+  isViewControllingSwipes = false
   isModalActive = false
   activeModal:
     | ConfirmModel
@@ -80,6 +81,10 @@ export class ShellUiModel {
     makeAutoObservable(this)
   }
 
+  setViewControllingSwipes(v: boolean) {
+    this.isViewControllingSwipes = v
+  }
+
   openModal(
     modal:
       | ConfirmModel
diff --git a/src/view/com/util/Selector.tsx b/src/view/com/util/Selector.tsx
index 06e8cda80..ed042d7c1 100644
--- a/src/view/com/util/Selector.tsx
+++ b/src/view/com/util/Selector.tsx
@@ -1,10 +1,11 @@
 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 {
+  Animated,
+  StyleSheet,
+  Text,
+  TouchableWithoutFeedback,
+  View,
+} from 'react-native'
 import {colors} from '../../lib/styles'
 
 interface Layout {
@@ -12,17 +13,15 @@ interface Layout {
   width: number
 }
 
-const DEFAULT_SWIPE_GESTURE_INTERP = {value: 0}
-
 export function Selector({
   selectedIndex,
   items,
-  swipeGestureInterp,
+  panX,
   onSelect,
 }: {
   selectedIndex: number
   items: string[]
-  swipeGestureInterp?: SharedValue<number>
+  panX: Animated.Value
   onSelect?: (index: number) => void
 }) {
   const [itemLayouts, setItemLayouts] = useState<undefined | Layout[]>(
@@ -43,27 +42,24 @@ export function Selector({
     return [left, middle, right]
   }, [selectedIndex, items, itemLayouts])
 
-  const interp = swipeGestureInterp || DEFAULT_SWIPE_GESTURE_INTERP
-  const underlinePos = useAnimatedStyle(() => {
-    const other =
-      interp.value === 0
-        ? currentLayouts[1]
-        : interp.value < 0
-        ? currentLayouts[0]
-        : currentLayouts[2]
-    return {
-      left: interpolate(
-        Math.abs(interp.value),
-        [0, 1],
-        [currentLayouts[1].x, other.x],
-      ),
-      width: interpolate(
-        Math.abs(interp.value),
-        [0, 1],
-        [currentLayouts[1].width, other.width],
-      ),
-    }
-  }, [currentLayouts, interp])
+  const underlineStyle = {
+    left: panX.interpolate({
+      inputRange: [-1, 0, 1],
+      outputRange: [
+        currentLayouts[0].x,
+        currentLayouts[1].x,
+        currentLayouts[2].x,
+      ],
+    }),
+    width: panX.interpolate({
+      inputRange: [-1, 0, 1],
+      outputRange: [
+        currentLayouts[0].width,
+        currentLayouts[1].width,
+        currentLayouts[2].width,
+      ],
+    }),
+  }
 
   const onLayout = () => {
     const promises = []
@@ -89,7 +85,7 @@ export function Selector({
 
   return (
     <View style={[styles.outer]} onLayout={onLayout}>
-      <Animated.View style={[styles.underline, underlinePos]} />
+      <Animated.View style={[styles.underline, underlineStyle]} />
       {items.map((item, i) => {
         const selected = i === selectedIndex
         return (
diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx
index b404988f3..f7652334f 100644
--- a/src/view/com/util/ViewSelector.tsx
+++ b/src/view/com/util/ViewSelector.tsx
@@ -1,15 +1,13 @@
 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'
+import {HorzSwipe} from './gestures/HorzSwipe'
+import {useAnimatedValue} from '../../lib/useAnimatedValue'
+import {useStores} from '../../../state'
 
 const HEADER_ITEM = {_reactKey: '__header__'}
 const SELECTOR_ITEM = {_reactKey: '__selector__'}
 const STICKY_HEADER_INDICES = [1]
-const SWIPE_GESTURE_MAX_DISTANCE = 200
-const SWIPE_GESTURE_VEL_TRIGGER = 2000
-const SWIPE_GESTURE_HIT_SLOP = {left: -50, top: 0, right: 0, bottom: 0} // we ignore the left 20 pixels to avoid conflicts with the page-nav gesture
 
 export function ViewSelector({
   sections,
@@ -32,72 +30,26 @@ export function ViewSelector({
   onRefresh?: () => void
   onEndReached?: (info: {distanceFromEnd: number}) => void
 }) {
+  const store = useStores()
   const [selectedIndex, setSelectedIndex] = useState<number>(0)
-  const swipeGestureInterp = useSharedValue<number>(0)
+  const panX = useAnimatedValue(0)
 
   // events
   // =
 
+  const onSwipeEnd = (dx: number) => {
+    if (dx !== 0) {
+      setSelectedIndex(selectedIndex + dx)
+    }
+  }
   const onPressSelection = (index: number) => setSelectedIndex(index)
   useEffect(() => {
+    store.shell.setViewControllingSwipes(
+      Boolean(swipeEnabled) && selectedIndex > 0,
+    )
     onSelectView?.(selectedIndex)
   }, [selectedIndex])
 
-  // gestures
-  // =
-
-  const swipeGesture = useMemo(() => {
-    if (!swipeEnabled) return undefined
-    return 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 => {
-        const vx = e.velocityX
-        if (
-          swipeGestureInterp.value >= 0.5 ||
-          (vx < 0 && Math.abs(vx) > SWIPE_GESTURE_VEL_TRIGGER)
-        ) {
-          // 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 ||
-          (vx > 0 && Math.abs(vx) > SWIPE_GESTURE_VEL_TRIGGER)
-        ) {
-          // 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})
-        }
-      })
-  }, [swipeEnabled, 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
   // =
 
@@ -111,8 +63,8 @@ export function ViewSelector({
       return (
         <Selector
           items={sections}
+          panX={panX}
           selectedIndex={selectedIndex}
-          swipeGestureInterp={swipeGestureInterp}
           onSelect={onPressSelection}
         />
       )
@@ -122,21 +74,24 @@ export function ViewSelector({
   }
 
   const data = [HEADER_ITEM, SELECTOR_ITEM, ...items]
-  const listEl = (
-    <FlatList
-      data={data}
-      keyExtractor={item => item._reactKey}
-      renderItem={renderItemInternal}
-      stickyHeaderIndices={STICKY_HEADER_INDICES}
-      refreshing={refreshing}
-      onRefresh={onRefresh}
-      onEndReached={onEndReached}
-    />
+  return (
+    <HorzSwipe
+      panX={panX}
+      swipeEnabled={swipeEnabled || false}
+      canSwipeLeft={selectedIndex > 0}
+      canSwipeRight={selectedIndex < sections.length - 1}
+      onSwipeEnd={onSwipeEnd}>
+      <FlatList
+        data={data}
+        keyExtractor={item => item._reactKey}
+        renderItem={renderItemInternal}
+        stickyHeaderIndices={STICKY_HEADER_INDICES}
+        refreshing={refreshing}
+        onRefresh={onRefresh}
+        onEndReached={onEndReached}
+      />
+    </HorzSwipe>
   )
-  if (swipeEnabled) {
-    return <GestureDetector gesture={swipeGesture}>{listEl}</GestureDetector>
-  }
-  return listEl
 }
 
 const styles = StyleSheet.create({})
diff --git a/src/view/com/util/gestures/HorzSwipe.tsx b/src/view/com/util/gestures/HorzSwipe.tsx
new file mode 100644
index 000000000..7ae1ee759
--- /dev/null
+++ b/src/view/com/util/gestures/HorzSwipe.tsx
@@ -0,0 +1,126 @@
+import React from 'react'
+import {
+  Animated,
+  GestureResponderEvent,
+  I18nManager,
+  PanResponder,
+  PanResponderGestureState,
+  useWindowDimensions,
+  View,
+} from 'react-native'
+import {clamp} from 'lodash'
+
+interface Props {
+  panX: Animated.Value
+  canSwipeLeft: boolean
+  canSwipeRight: boolean
+  swipeEnabled: boolean
+  onSwipeStart?: () => void
+  onSwipeEnd?: (dx: number) => void
+  children: React.ReactNode
+}
+
+export function HorzSwipe({
+  panX,
+  canSwipeLeft,
+  canSwipeRight,
+  swipeEnabled = true,
+  onSwipeStart,
+  onSwipeEnd,
+  children,
+}: Props) {
+  const winDim = useWindowDimensions()
+
+  const swipeVelocityThreshold = 35
+  const swipeDistanceThreshold = winDim.width / 1.75
+
+  const isMovingHorizontally = (
+    _: GestureResponderEvent,
+    gestureState: PanResponderGestureState,
+  ) => {
+    return (
+      Math.abs(gestureState.dx) > Math.abs(gestureState.dy * 1.5) &&
+      Math.abs(gestureState.vx) > Math.abs(gestureState.vy * 1.5)
+    )
+  }
+
+  const canMoveScreen = (
+    event: GestureResponderEvent,
+    gestureState: PanResponderGestureState,
+  ) => {
+    if (swipeEnabled === false) {
+      return false
+    }
+
+    const diffX = I18nManager.isRTL ? -gestureState.dx : gestureState.dx
+    return (
+      isMovingHorizontally(event, gestureState) &&
+      ((diffX > 0 && canSwipeLeft) || (diffX < 0 && canSwipeRight))
+    )
+  }
+
+  const startGesture = () => {
+    onSwipeStart?.()
+
+    // TODO
+    // if (keyboardDismissMode === 'on-drag') {
+    //   Keyboard.dismiss()
+    // }
+
+    panX.stopAnimation()
+    // @ts-expect-error: _value is private, but docs use it as well
+    panX.setOffset(panX._value)
+  }
+
+  const respondToGesture = (
+    _: GestureResponderEvent,
+    gestureState: PanResponderGestureState,
+  ) => {
+    const diffX = I18nManager.isRTL ? -gestureState.dx : gestureState.dx
+
+    if (
+      // swiping left
+      (diffX > 0 && !canSwipeLeft) ||
+      // swiping right
+      (diffX < 0 && !canSwipeRight)
+    ) {
+      return
+    }
+
+    panX.setValue(clamp(diffX / swipeDistanceThreshold, -1, 1) * -1)
+  }
+
+  const finishGesture = (
+    _: GestureResponderEvent,
+    gestureState: PanResponderGestureState,
+  ) => {
+    panX.flattenOffset()
+    panX.setValue(0)
+    if (
+      Math.abs(gestureState.dx) > Math.abs(gestureState.dy) &&
+      Math.abs(gestureState.vx) > Math.abs(gestureState.vy) &&
+      (Math.abs(gestureState.dx) > swipeDistanceThreshold / 3 ||
+        Math.abs(gestureState.vx) > swipeVelocityThreshold)
+    ) {
+      onSwipeEnd?.(((gestureState.dx / Math.abs(gestureState.dx)) * -1) | 0)
+    } else {
+      onSwipeEnd?.(0)
+    }
+  }
+
+  const panResponder = PanResponder.create({
+    onMoveShouldSetPanResponder: canMoveScreen,
+    onMoveShouldSetPanResponderCapture: canMoveScreen,
+    onPanResponderGrant: startGesture,
+    onPanResponderMove: respondToGesture,
+    onPanResponderTerminate: finishGesture,
+    onPanResponderRelease: finishGesture,
+    onPanResponderTerminationRequest: () => true,
+  })
+
+  return (
+    <View {...panResponder.panHandlers} style={{flex: 1}}>
+      {children}
+    </View>
+  )
+}
diff --git a/src/view/screens/Menu.tsx b/src/view/screens/Menu.tsx
index 2b7c87311..ce2107158 100644
--- a/src/view/screens/Menu.tsx
+++ b/src/view/screens/Menu.tsx
@@ -140,7 +140,6 @@ export const Menu = ({navIdx, visible}: ScreenParams) => {
           }
           label="Settings"
           url="/settings"
-          count={store.me.notificationCount}
         />
       </View>
       <View style={styles.section}>
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 11d276683..b10ad80f8 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -237,6 +237,7 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
         />
       ) : uiState.profile.hasLoaded ? (
         <ViewSelector
+          swipeEnabled
           sections={uiState.selectorItems}
           items={items}
           renderHeader={renderHeader}