From 9ce02dff5b995d384bdc5ea43323a0d03ca754cc Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 7 Dec 2022 15:51:06 -0600 Subject: Add HorzSwipe gesture and integrate it into the ViewSelector --- src/state/models/shell-ui.ts | 5 ++ src/view/com/util/Selector.tsx | 60 +++++++-------- src/view/com/util/ViewSelector.tsx | 107 ++++++++------------------ src/view/com/util/gestures/HorzSwipe.tsx | 126 +++++++++++++++++++++++++++++++ src/view/screens/Menu.tsx | 1 - src/view/screens/Profile.tsx | 1 + 6 files changed, 191 insertions(+), 109 deletions(-) create mode 100644 src/view/com/util/gestures/HorzSwipe.tsx (limited to 'src') 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 + panX: Animated.Value onSelect?: (index: number) => void }) { const [itemLayouts, setItemLayouts] = useState( @@ -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 ( - + {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(0) - const swipeGestureInterp = useSharedValue(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 ( ) @@ -122,21 +74,24 @@ export function ViewSelector({ } const data = [HEADER_ITEM, SELECTOR_ITEM, ...items] - const listEl = ( - item._reactKey} - renderItem={renderItemInternal} - stickyHeaderIndices={STICKY_HEADER_INDICES} - refreshing={refreshing} - onRefresh={onRefresh} - onEndReached={onEndReached} - /> + return ( + 0} + canSwipeRight={selectedIndex < sections.length - 1} + onSwipeEnd={onSwipeEnd}> + item._reactKey} + renderItem={renderItemInternal} + stickyHeaderIndices={STICKY_HEADER_INDICES} + refreshing={refreshing} + onRefresh={onRefresh} + onEndReached={onEndReached} + /> + ) - if (swipeEnabled) { - return {listEl} - } - 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 ( + + {children} + + ) +} 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} /> 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 ? (