diff --git a/example/app/package.json b/example/app/package.json index 0894dc9f4..5d6cbf9bd 100644 --- a/example/app/package.json +++ b/example/app/package.json @@ -20,10 +20,10 @@ "nanoid": "^3.3.3", "react": "17.0.2", "react-native": "0.68.1", - "react-native-gesture-handler": "^2.5.0", + "react-native-gesture-handler": "~2.8.0", "react-native-maps": "^0.30.1", "react-native-pager-view": "^5.4.24", - "react-native-reanimated": "^2.9.1", + "react-native-reanimated": "~2.12.0", "react-native-redash": "^16.0.11", "react-native-safe-area-context": "4.2.4", "react-native-screens": "^3.15.0", diff --git a/example/app/src/components/contactList/styles.web.ts b/example/app/src/components/contactList/styles.web.ts index b4f6f8ff0..44e5b1e37 100644 --- a/example/app/src/components/contactList/styles.web.ts +++ b/example/app/src/components/contactList/styles.web.ts @@ -9,10 +9,10 @@ export const styles = StyleSheet.create({ sectionHeaderTitle: { fontSize: 16, textTransform: 'uppercase', + color: 'black', }, container: { flex: 1, - paddingHorizontal: 16, }, contentContainer: { paddingHorizontal: 16, diff --git a/example/app/src/screens/modal/DetachedExample.tsx b/example/app/src/screens/modal/DetachedExample.tsx index 2e06327fc..456b995a6 100644 --- a/example/app/src/screens/modal/DetachedExample.tsx +++ b/example/app/src/screens/modal/DetachedExample.tsx @@ -76,7 +76,7 @@ const DetachedExample = () => { snapPoints={animatedSnapPoints} handleHeight={animatedHandleHeight} contentHeight={animatedContentHeight} - bottomInset={safeBottomArea + 34} + bottomInset={safeBottomArea + 16} enablePanDownToClose={true} style={styles.sheetContainer} backgroundComponent={null} @@ -118,7 +118,7 @@ const styles = StyleSheet.create({ contentContainerStyle: { paddingTop: 12, paddingBottom: 12, - paddingHorizontal: 16, + paddingHorizontal: 12, }, footer: { justifyContent: 'center', diff --git a/example/bare/ios/Podfile.lock b/example/bare/ios/Podfile.lock index 644322743..3d1109926 100644 --- a/example/bare/ios/Podfile.lock +++ b/example/bare/ios/Podfile.lock @@ -306,7 +306,7 @@ PODS: - React - react-native-maps (0.30.2): - React-Core - - react-native-pager-view (5.4.24): + - react-native-pager-view (5.4.25): - React-Core - react-native-safe-area-context (4.2.4): - RCT-Folly @@ -382,7 +382,7 @@ PODS: - React-perflogger (= 0.69.4) - RNCMaskedView (0.1.11): - React - - RNGestureHandler (2.6.2): + - RNGestureHandler (2.7.0): - React-Core - RNReanimated (2.10.0): - DoubleConversion @@ -411,7 +411,7 @@ PODS: - React-RCTText - ReactCommon/turbomodule/core - Yoga - - RNScreens (3.15.0): + - RNScreens (3.17.0): - React-Core - React-RCTImage - SocketRocket (0.6.0) @@ -629,7 +629,7 @@ SPEC CHECKSUMS: React-logger: 1088859f145b8f6dd0d3ed051a647ef0e3e80fad react-native-blur: cad4d93b364f91e7b7931b3fa935455487e5c33c react-native-maps: df7b9fca1b1c8d356fadbf5b8a63a5f8cf32fc73 - react-native-pager-view: 95d0418c3c74279840abec6926653d32447bafb6 + react-native-pager-view: da490aa1f902c9a5aeecf0909cc975ad0e92e53e react-native-safe-area-context: f98b0b16d1546d208fc293b4661e3f81a895afd9 React-perflogger: cb386fd44c97ec7f8199c04c12b22066b0f2e1e0 React-RCTActionSheet: f803a85e46cf5b4066c2ac5e122447f918e9c6e5 @@ -644,9 +644,9 @@ SPEC CHECKSUMS: React-runtimeexecutor: 61ee22a8cdf8b6bb2a7fb7b4ba2cc763e5285196 ReactCommon: 8f67bd7e0a6afade0f20718f859dc8c2275f2e83 RNCMaskedView: 0e1bc4bfa8365eba5fbbb71e07fbdc0555249489 - RNGestureHandler: 4defbd70b2faf3d6761b82fa7880285241762cb0 + RNGestureHandler: 7673697e7c0e9391adefae4faa087442bc04af33 RNReanimated: 7faa787e8d4493fbc95fab2ad331fa7625828cfa - RNScreens: 4a1af06327774490d97342c00aee0c2bafb497b7 + RNScreens: 0df01424e9e0ed7827200d6ed1087ddd06c493f9 SocketRocket: fccef3f9c5cedea1353a9ef6ada904fde10d6608 Yoga: ff994563b2fd98c982ca58e8cd9db2cdaf4dda74 YogaKit: f782866e155069a2cca2517aafea43200b01fd5a diff --git a/example/expo/babel.config.js b/example/expo/babel.config.js index 8c702fa88..dd6659691 100644 --- a/example/expo/babel.config.js +++ b/example/expo/babel.config.js @@ -16,6 +16,7 @@ module.exports = function (api) { return { presets: ['babel-preset-expo'], plugins: [ + '@babel/plugin-proposal-export-namespace-from', 'react-native-reanimated/plugin', [ 'module-resolver', diff --git a/example/expo/index.ts b/example/expo/index.ts index d70a55804..58a0f2985 100644 --- a/example/expo/index.ts +++ b/example/expo/index.ts @@ -1,5 +1,8 @@ import { registerRootComponent } from 'expo'; +import { enableExperimentalWebImplementation } from 'react-native-gesture-handler'; +enableExperimentalWebImplementation(true); + import { enableScreens } from 'react-native-screens'; enableScreens(true); diff --git a/example/expo/package.json b/example/expo/package.json index 521b9a090..e7adbb90d 100644 --- a/example/expo/package.json +++ b/example/expo/package.json @@ -5,13 +5,13 @@ "private": true, "main": "./index.ts", "scripts": { - "start": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", - "web": "expo start --web", - "eject": "expo eject" + "start": "npx expo start", + "android": "npx expo start --android", + "ios": "npx expo start --ios", + "web": "npx expo start --web" }, "dependencies": { + "@expo/webpack-config": "^0.17.2", "@gorhom/portal": "^1.0.13", "@gorhom/showcase-template": "^2.1.0", "@react-navigation/bottom-tabs": "^6.3.1", @@ -20,28 +20,29 @@ "@react-navigation/native": "^6.0.10", "@react-navigation/native-stack": "^6.6.2", "@react-navigation/stack": "^6.2.1", - "expo": "^46.0.0", - "expo-status-bar": "~1.4.0", + "expo": "^47.0.0", + "expo-status-bar": "~1.4.2", "faker": "^4.1.0", "nanoid": "^3.3.3", - "react": "18.0.0", - "react-dom": "18.0.0", - "react-native": "0.69.4", - "react-native-gesture-handler": "~2.5.0", - "react-native-pager-view": "5.4.24", - "react-native-reanimated": "~2.9.1", + "react": "18.1.0", + "react-dom": "18.1.0", + "react-native": "0.70.5", + "react-native-gesture-handler": "~2.8.0", + "react-native-pager-view": "6.0.1", + "react-native-reanimated": "~2.12.0", "react-native-redash": "^16.2.4", - "react-native-safe-area-context": "4.3.1", - "react-native-screens": "~3.15.0", + "react-native-safe-area-context": "4.4.1", + "react-native-screens": "~3.18.0", "react-native-tab-view": "^3.1.1", "react-native-web": "~0.18.7" }, "devDependencies": { - "@babel/core": "^7.18.6", - "@types/react": "~18.0.0", - "@types/react-native": "~0.69.1", + "@babel/core": "^7.19.3", + "@babel/plugin-proposal-export-namespace-from": "^7.18.9", + "@types/react": "~18.0.24", + "@types/react-native": "~0.70.6", + "babel-loader": "^8.2.3", "babel-plugin-module-resolver": "^4.1.0", - "expo-cli": "^6.0.2", "typescript": "^4.6.3" }, "resolutions": { diff --git a/example/expo/web/index.html b/example/expo/web/index.html new file mode 100644 index 000000000..b98004386 --- /dev/null +++ b/example/expo/web/index.html @@ -0,0 +1,121 @@ + + + + + + + + + + %WEB_TITLE% + + + + + + + +
+ + diff --git a/package.json b/package.json index b450a0d19..5302630c4 100644 --- a/package.json +++ b/package.json @@ -69,8 +69,8 @@ "peerDependencies": { "react": "*", "react-native": "*", - "react-native-gesture-handler": ">=2.5.0", - "react-native-reanimated": ">=2.9.0" + "react-native-gesture-handler": ">=2.6.0", + "react-native-reanimated": ">=2.10.0" }, "react-native-builder-bob": { "source": "src", diff --git a/src/components/bottomSheet/BottomSheet.tsx b/src/components/bottomSheet/BottomSheet.tsx index bd5ebddaf..8f6013255 100644 --- a/src/components/bottomSheet/BottomSheet.tsx +++ b/src/components/bottomSheet/BottomSheet.tsx @@ -1370,12 +1370,15 @@ const BottomSheetComponent = forwardRef( /** * Calculate the keyboard height in the container. */ - animatedKeyboardHeightInContainer.value = $modal - ? Math.abs( - _keyboardHeight - - Math.abs(bottomInset - animatedContainerOffset.value.bottom) - ) - : Math.abs(_keyboardHeight - animatedContainerOffset.value.bottom); + animatedKeyboardHeightInContainer.value = + _keyboardHeight === 0 + ? 0 + : $modal + ? Math.abs( + _keyboardHeight - + Math.abs(bottomInset - animatedContainerOffset.value.bottom) + ) + : Math.abs(_keyboardHeight - animatedContainerOffset.value.bottom); /** * if keyboard state is equal to the previous state, then exit the method diff --git a/src/components/bottomSheetDebugView/ReText.web.tsx b/src/components/bottomSheetDebugView/ReText.web.tsx new file mode 100644 index 000000000..94fc67c38 --- /dev/null +++ b/src/components/bottomSheetDebugView/ReText.web.tsx @@ -0,0 +1,53 @@ +import React, { useRef } from 'react'; +import { TextProps as RNTextProps, TextInput } from 'react-native'; +import Animated, { + useAnimatedReaction, + useDerivedValue, +} from 'react-native-reanimated'; + +interface TextProps { + text: string; + value: Animated.SharedValue | number; + style?: Animated.AnimateProps['style']; +} + +const AnimatedTextInput = Animated.createAnimatedComponent(TextInput); + +const ReText = (props: TextProps) => { + const { text, value: _providedValue, style } = { style: {}, ...props }; + const textRef = useRef(null); + + const providedValue = useDerivedValue(() => { + const value = + typeof _providedValue === 'number' + ? _providedValue + : typeof _providedValue.value === 'number' + ? _providedValue.value.toFixed(2) + : _providedValue.value; + + return `${text}: ${value}`; + }); + + //region effects + useAnimatedReaction( + () => providedValue.value, + result => { + textRef.current?.setNativeProps({ + text: result, + }); + } + ); + //endregion + + return ( + + ); +}; + +export default ReText; diff --git a/src/components/bottomSheetDebugView/styles.web.ts b/src/components/bottomSheetDebugView/styles.web.ts new file mode 100644 index 000000000..d77bfdc0b --- /dev/null +++ b/src/components/bottomSheetDebugView/styles.web.ts @@ -0,0 +1,20 @@ +import { StyleSheet } from 'react-native'; + +export const styles = StyleSheet.create({ + container: { + position: 'absolute', + left: 4, + top: 80, + padding: 2, + width: 400, + backgroundColor: 'rgba(0, 0,0,0.5)', + }, + text: { + fontSize: 14, + lineHeight: 16, + textAlignVertical: 'center', + height: 20, + padding: 0, + color: 'white', + }, +}); diff --git a/src/components/bottomSheetHandleContainer/BottomSheetHandleContainer.tsx b/src/components/bottomSheetHandleContainer/BottomSheetHandleContainer.tsx index 87b5551e1..23724d557 100644 --- a/src/components/bottomSheetHandleContainer/BottomSheetHandleContainer.tsx +++ b/src/components/bottomSheetHandleContainer/BottomSheetHandleContainer.tsx @@ -8,6 +8,7 @@ import { useBottomSheetInternal, } from '../../hooks'; import { print } from '../../utilities'; +import { styles } from './styles'; import type { BottomSheetHandleContainerProps } from './types'; function BottomSheetHandleContainerComponent({ @@ -137,6 +138,7 @@ function BottomSheetHandleContainerComponent({ accessibilityRole="adjustable" accessibilityLabel="Bottom Sheet handle" accessibilityHint="Drag up or down to extend or minimize the Bottom Sheet" + style={styles.container} onLayout={handleContainerLayout} > ( const nativeGesture = useMemo( () => - Gesture.Simultaneous( - Gesture.Native().shouldCancelWhenOutside(false), - draggableGesture! - ), + Gesture.Native() + // @ts-ignore + .simultaneousWithExternalGesture(draggableGesture!) + .shouldCancelWhenOutside(false), [draggableGesture] ); //#endregion diff --git a/src/components/bottomSheetView/BottomSheetView.tsx b/src/components/bottomSheetView/BottomSheetView.tsx index 12a2df6c7..16005ef62 100644 --- a/src/components/bottomSheetView/BottomSheetView.tsx +++ b/src/components/bottomSheetView/BottomSheetView.tsx @@ -19,27 +19,38 @@ function BottomSheetViewComponent({ animatedFooterHeight, } = useBottomSheetInternal(); - // styles + //#region styles + const flattenContainerStyle = useMemo( + () => StyleSheet.flatten(style), + [style] + ); const containerStylePaddingBottom = useMemo(() => { - const flattenStyle = StyleSheet.flatten(style); const paddingBottom = - flattenStyle && 'paddingBottom' in flattenStyle - ? flattenStyle.paddingBottom + flattenContainerStyle && 'paddingBottom' in flattenContainerStyle + ? flattenContainerStyle.paddingBottom : 0; return typeof paddingBottom === 'number' ? paddingBottom : 0; - }, [style]); - const containerAnimatedStyle = useAnimatedStyle( + }, [flattenContainerStyle]); + const containerStyle = useMemo(() => { + return { + ...flattenContainerStyle, + paddingBottom: 0, + }; + }, [flattenContainerStyle]); + const spaceStyle = useAnimatedStyle( () => ({ - paddingBottom: enableFooterMarginAdjustment + opacity: 0, + height: enableFooterMarginAdjustment ? animatedFooterHeight.value + containerStylePaddingBottom : containerStylePaddingBottom, }), - [containerStylePaddingBottom, enableFooterMarginAdjustment] - ); - const containerStyle = useMemo( - () => [style, containerAnimatedStyle], - [style, containerAnimatedStyle] + [ + enableFooterMarginAdjustment, + containerStylePaddingBottom, + animatedFooterHeight, + ] ); + //#endregion // callback const handleSettingScrollable = useCallback(() => { @@ -54,6 +65,7 @@ function BottomSheetViewComponent({ return ( {children} + ); } diff --git a/src/hooks/useBottomSheetDynamicSnapPoints.ts b/src/hooks/useBottomSheetDynamicSnapPoints.ts index a1c25735d..cb3f471cd 100644 --- a/src/hooks/useBottomSheetDynamicSnapPoints.ts +++ b/src/hooks/useBottomSheetDynamicSnapPoints.ts @@ -1,4 +1,5 @@ import { useCallback } from 'react'; +import { LayoutChangeEvent } from 'react-native'; import { useDerivedValue, useSharedValue } from 'react-native-reanimated'; import { INITIAL_HANDLE_HEIGHT, @@ -45,7 +46,7 @@ export const useBottomSheetDynamicSnapPoints = ( nativeEvent: { layout: { height }, }, - }) => { + }: LayoutChangeEvent) => { animatedContentHeight.value = height; }, [animatedContentHeight] diff --git a/src/hooks/useGestureEventsHandlersDefault.tsx b/src/hooks/useGestureEventsHandlersDefault.tsx index efd28dc1a..0ded828d9 100644 --- a/src/hooks/useGestureEventsHandlersDefault.tsx +++ b/src/hooks/useGestureEventsHandlersDefault.tsx @@ -33,7 +33,6 @@ const INITIAL_CONTEXT: GestureEventContextType = { const resetContext = (context: any) => { 'worklet'; - Object.keys(context).map(key => { context[key] = undefined; }); diff --git a/src/hooks/useGestureEventsHandlersDefault.web.tsx b/src/hooks/useGestureEventsHandlersDefault.web.tsx new file mode 100644 index 000000000..fc4204ff6 --- /dev/null +++ b/src/hooks/useGestureEventsHandlersDefault.web.tsx @@ -0,0 +1,396 @@ +import { Keyboard, Platform } from 'react-native'; +import { + runOnJS, + useSharedValue, + useWorkletCallback, +} from 'react-native-reanimated'; +import { useBottomSheetInternal } from './useBottomSheetInternal'; +import { + ANIMATION_SOURCE, + GESTURE_SOURCE, + KEYBOARD_STATE, + SCROLLABLE_TYPE, + WINDOW_HEIGHT, +} from '../constants'; +import type { GestureEventHandlerCallbackType } from '../types'; +import { clamp } from '../utilities/clamp'; +import { snapPoint } from '../utilities/snapPoint'; + +type GestureEventContextType = { + initialPosition: number; + initialKeyboardState: KEYBOARD_STATE; + initialTranslationY: number; + isScrollablePositionLocked: boolean; +}; + +const INITIAL_CONTEXT: GestureEventContextType = { + initialPosition: 0, + initialTranslationY: 0, + initialKeyboardState: KEYBOARD_STATE.UNDETERMINED, + isScrollablePositionLocked: false, +}; + +const resetContext = (context: any) => { + 'worklet'; + Object.keys(context).map(key => { + context[key] = undefined; + }); +}; + +export const useGestureEventsHandlersDefault = () => { + //#region variables + const { + animatedPosition, + animatedSnapPoints, + animatedKeyboardState, + animatedKeyboardHeight, + animatedContainerHeight, + animatedScrollableType, + animatedHighestSnapPoint, + animatedClosedPosition, + animatedScrollableContentOffsetY, + enableOverDrag, + enablePanDownToClose, + overDragResistanceFactor, + isInTemporaryPosition, + isScrollableRefreshable, + animateToPosition, + stopAnimation, + } = useBottomSheetInternal(); + + const context = useSharedValue({ + ...INITIAL_CONTEXT, + }); + //#endregion + + //#region gesture methods + const handleOnStart: GestureEventHandlerCallbackType = useWorkletCallback( + function handleOnStart(__, { translationY }) { + // cancel current animation + stopAnimation(); + + // store current animated position + context.value = { + ...context.value, + initialPosition: animatedPosition.value, + initialKeyboardState: animatedKeyboardState.value, + initialTranslationY: translationY, + }; + + /** + * if the scrollable content is scrolled, then + * we lock the position. + */ + if (animatedScrollableContentOffsetY.value > 0) { + context.value.isScrollablePositionLocked = true; + } + }, + [ + stopAnimation, + animatedPosition, + animatedKeyboardState, + animatedScrollableContentOffsetY, + ] + ); + const handleOnChange: GestureEventHandlerCallbackType = useWorkletCallback( + function handleOnChange(source, { translationY }) { + let highestSnapPoint = animatedHighestSnapPoint.value; + + translationY = translationY - context.value.initialTranslationY; + /** + * if keyboard is shown, then we set the highest point to the current + * position which includes the keyboard height. + */ + if ( + isInTemporaryPosition.value && + context.value.initialKeyboardState === KEYBOARD_STATE.SHOWN + ) { + highestSnapPoint = context.value.initialPosition; + } + + /** + * if current position is out of provided `snapPoints` and smaller then + * highest snap pont, then we set the highest point to the current position. + */ + if ( + isInTemporaryPosition.value && + context.value.initialPosition < highestSnapPoint + ) { + highestSnapPoint = context.value.initialPosition; + } + + const lowestSnapPoint = enablePanDownToClose + ? animatedContainerHeight.value + : animatedSnapPoints.value[0]; + + /** + * if scrollable is refreshable and sheet position at the highest + * point, then do not interact with current gesture. + */ + if ( + source === GESTURE_SOURCE.CONTENT && + isScrollableRefreshable.value && + animatedPosition.value === highestSnapPoint + ) { + return; + } + + /** + * a negative scrollable content offset to be subtracted from accumulated + * current position and gesture translation Y to allow user to drag the sheet, + * when scrollable position at the top. + * a negative scrollable content offset when the scrollable is not locked. + */ + const negativeScrollableContentOffset = + (context.value.initialPosition === highestSnapPoint && + source === GESTURE_SOURCE.CONTENT) || + !context.value.isScrollablePositionLocked + ? animatedScrollableContentOffsetY.value * -1 + : 0; + + /** + * an accumulated value of starting position with gesture translation y. + */ + const draggedPosition = context.value.initialPosition + translationY; + + /** + * an accumulated value of dragged position and negative scrollable content offset, + * this will insure locking sheet position when user is scrolling the scrollable until, + * they reach to the top of the scrollable. + */ + const accumulatedDraggedPosition = + draggedPosition + negativeScrollableContentOffset; + + /** + * a clamped value of the accumulated dragged position, to insure keeping the dragged + * position between the highest and lowest snap points. + */ + const clampedPosition = clamp( + accumulatedDraggedPosition, + highestSnapPoint, + lowestSnapPoint + ); + + /** + * if scrollable position is locked and the animated position + * reaches the highest point, then we unlock the scrollable position. + */ + if ( + context.value.isScrollablePositionLocked && + source === GESTURE_SOURCE.CONTENT && + animatedPosition.value === highestSnapPoint + ) { + context.value.isScrollablePositionLocked = false; + } + + /** + * over-drag implementation. + */ + if (enableOverDrag) { + if ( + (source === GESTURE_SOURCE.HANDLE || + animatedScrollableType.value === SCROLLABLE_TYPE.VIEW) && + draggedPosition < highestSnapPoint + ) { + const resistedPosition = + highestSnapPoint - + Math.sqrt(1 + (highestSnapPoint - draggedPosition)) * + overDragResistanceFactor; + animatedPosition.value = resistedPosition; + return; + } + + if ( + source === GESTURE_SOURCE.HANDLE && + draggedPosition > lowestSnapPoint + ) { + const resistedPosition = + lowestSnapPoint + + Math.sqrt(1 + (draggedPosition - lowestSnapPoint)) * + overDragResistanceFactor; + animatedPosition.value = resistedPosition; + return; + } + + if ( + source === GESTURE_SOURCE.CONTENT && + draggedPosition + negativeScrollableContentOffset > lowestSnapPoint + ) { + const resistedPosition = + lowestSnapPoint + + Math.sqrt( + 1 + + (draggedPosition + + negativeScrollableContentOffset - + lowestSnapPoint) + ) * + overDragResistanceFactor; + animatedPosition.value = resistedPosition; + return; + } + } + + animatedPosition.value = clampedPosition; + }, + [ + enableOverDrag, + enablePanDownToClose, + overDragResistanceFactor, + isInTemporaryPosition, + isScrollableRefreshable, + animatedHighestSnapPoint, + animatedContainerHeight, + animatedSnapPoints, + animatedPosition, + animatedScrollableType, + animatedScrollableContentOffsetY, + ] + ); + const handleOnEnd: GestureEventHandlerCallbackType = useWorkletCallback( + function handleOnEnd(source, { translationY, absoluteY, velocityY }) { + const highestSnapPoint = animatedHighestSnapPoint.value; + const isSheetAtHighestSnapPoint = + animatedPosition.value === highestSnapPoint; + + /** + * if scrollable is refreshable and sheet position at the highest + * point, then do not interact with current gesture. + */ + if ( + source === GESTURE_SOURCE.CONTENT && + isScrollableRefreshable.value && + isSheetAtHighestSnapPoint + ) { + return; + } + + /** + * if the sheet is in a temporary position and the gesture ended above + * the current position, then we snap back to the temporary position. + */ + if ( + isInTemporaryPosition.value && + context.value.initialPosition >= animatedPosition.value + ) { + if (context.value.initialPosition > animatedPosition.value) { + animateToPosition( + context.value.initialPosition, + ANIMATION_SOURCE.GESTURE, + velocityY / 2 + ); + } + return; + } + + /** + * close keyboard if current position is below the recorded + * start position and keyboard still shown. + */ + const isScrollable = + animatedScrollableType.value !== SCROLLABLE_TYPE.UNDETERMINED && + animatedScrollableType.value !== SCROLLABLE_TYPE.VIEW; + + /** + * if keyboard is shown and the sheet is dragged down, + * then we dismiss the keyboard. + */ + if ( + context.value.initialKeyboardState === KEYBOARD_STATE.SHOWN && + animatedPosition.value > context.value.initialPosition + ) { + /** + * if the platform is ios, current content is scrollable and + * the end touch point is below the keyboard position then + * we exit the method. + * + * because the the keyboard dismiss is interactive in iOS. + */ + if ( + !( + Platform.OS === 'ios' && + isScrollable && + absoluteY > WINDOW_HEIGHT - animatedKeyboardHeight.value + ) + ) { + runOnJS(Keyboard.dismiss)(); + } + } + + /** + * reset isInTemporaryPosition value + */ + if (isInTemporaryPosition.value) { + isInTemporaryPosition.value = false; + } + + /** + * clone snap points array, and insert the container height + * if pan down to close is enabled. + */ + const snapPoints = animatedSnapPoints.value.slice(); + if (enablePanDownToClose) { + snapPoints.unshift(animatedClosedPosition.value); + } + + /** + * calculate the destination point, using redash. + */ + const destinationPoint = snapPoint( + translationY + context.value.initialPosition, + velocityY, + snapPoints + ); + + /** + * if destination point is the same as the current position, + * then no need to perform animation. + */ + if (destinationPoint === animatedPosition.value) { + return; + } + + const wasGestureHandledByScrollView = + source === GESTURE_SOURCE.CONTENT && + animatedScrollableContentOffsetY.value > 0; + /** + * prevents snapping from top to middle / bottom with repeated interrupted scrolls + */ + if (wasGestureHandledByScrollView && isSheetAtHighestSnapPoint) { + return; + } + + animateToPosition( + destinationPoint, + ANIMATION_SOURCE.GESTURE, + velocityY / 2 + ); + }, + [ + enablePanDownToClose, + isInTemporaryPosition, + isScrollableRefreshable, + animatedClosedPosition, + animatedHighestSnapPoint, + animatedKeyboardHeight, + animatedPosition, + animatedScrollableType, + animatedSnapPoints, + animatedScrollableContentOffsetY, + animateToPosition, + ] + ); + const handleOnFinalize: GestureEventHandlerCallbackType = useWorkletCallback( + function handleOnFinalize() { + resetContext(context); + }, + [context] + ); + //#endregion + + return { + handleOnStart, + handleOnChange, + handleOnEnd, + handleOnFinalize, + }; +}; diff --git a/src/hooks/useScrollHandler.web.ts b/src/hooks/useScrollHandler.web.ts new file mode 100644 index 000000000..6b11a17e7 --- /dev/null +++ b/src/hooks/useScrollHandler.web.ts @@ -0,0 +1,171 @@ +import { useEffect, useRef, TouchEvent } from 'react'; +import { useSharedValue } from 'react-native-reanimated'; +import { useBottomSheetInternal } from './useBottomSheetInternal'; +import { ANIMATION_STATE, SCROLLABLE_STATE } from '../constants'; +import { getRefNativeTag } from '../utilities/getRefNativeTag'; +import type { Scrollable } from '../types'; + +export type ScrollEventContextType = { + initialContentOffsetY: number; + shouldLockInitialPosition: boolean; +}; + +export const useScrollHandler = () => { + //#region refs + const scrollableRef = useRef(); + //#endregion + + //#region variables + const scrollableContentOffsetY = useSharedValue(0); + //#endregion + + //#region hooks + const { + animatedScrollableState, + animatedAnimationState, + animatedScrollableContentOffsetY, + } = useBottomSheetInternal(); + //#endregion + + //#region effects + useEffect(() => { + const element = getRefNativeTag(scrollableRef) as any; + + var scrollOffset = 0; + var supportsPassive = false; + var maybePrevent = false; + var lastTouchY = 0; + + var initialContentOffsetY = 0; + var shouldLockInitialPosition = false; + + function handleOnTouchStart(event: TouchEvent) { + if (event.touches.length !== 1) return; + + initialContentOffsetY = element.scrollTop; + lastTouchY = event.touches[0].clientY; + maybePrevent = scrollOffset <= 0; + } + + function handleOnTouchMove(event: TouchEvent) { + if (animatedScrollableState.value === SCROLLABLE_STATE.LOCKED) { + return event.preventDefault(); + } + + if (maybePrevent) { + maybePrevent = false; + + const touchY = event.touches[0].clientY; + const touchYDelta = touchY - lastTouchY; + + if (touchYDelta > 0) { + return event.preventDefault(); + } + } + + return true; + } + + function handleOnTouchEnd() { + if (animatedScrollableState.value === SCROLLABLE_STATE.LOCKED) { + const lockPosition = shouldLockInitialPosition + ? initialContentOffsetY ?? 0 + : 0; + element.scroll({ + top: 0, + left: 0, + behavior: 'instant', + }); + scrollableContentOffsetY.value = lockPosition; + return; + } + } + + function handleOnScroll(event: TouchEvent) { + scrollOffset = element.scrollTop; + + if (animatedAnimationState.value !== ANIMATION_STATE.RUNNING) { + scrollableContentOffsetY.value = Math.max(0, scrollOffset); + animatedScrollableContentOffsetY.value = Math.max(0, scrollOffset); + } + + if (scrollOffset <= 0) { + event.preventDefault(); + event.stopPropagation(); + return false; + } + return true; + } + + try { + // @ts-ignore + window.addEventListener('test', null, { + // @ts-ignore + get passive() { + supportsPassive = true; + }, + }); + } catch (e) {} + + element.addEventListener( + 'touchstart', + handleOnTouchStart, + supportsPassive + ? { + passive: true, + } + : false + ); + + element.addEventListener( + 'touchmove', + handleOnTouchMove, + supportsPassive + ? { + passive: false, + } + : false + ); + + element.addEventListener( + 'touchend', + handleOnTouchEnd, + supportsPassive + ? { + passive: false, + } + : false + ); + + element.addEventListener( + 'scroll', + handleOnScroll, + supportsPassive + ? { + passive: false, + } + : false + ); + + return () => { + // @ts-ignore + window.removeEventListener('test', null); + element.removeEventListener('touchstart', handleOnTouchStart); + element.removeEventListener('touchmove', handleOnTouchMove); + element.removeEventListener('touchend', handleOnTouchEnd); + element.removeEventListener('scroll', handleOnScroll); + }; + }, [ + animatedAnimationState, + animatedScrollableContentOffsetY, + animatedScrollableState, + scrollableContentOffsetY, + ]); + //#endregion + + return { + scrollHandler: undefined, + scrollableRef, + scrollableContentOffsetY, + }; +}; diff --git a/src/types.d.ts b/src/types.d.ts index a0a310405..7357f0506 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -137,7 +137,7 @@ export type GestureEventContextType = { export type GestureEventHandlerCallbackType = ( source: GESTURE_SOURCE, - event: GestureStateChangeEvent + payload: GestureEventPayloadType ) => void; export type GestureEventsHandlersHookType = () => { diff --git a/src/utilities/getRefNativeTag.web.ts b/src/utilities/getRefNativeTag.web.ts new file mode 100644 index 000000000..08c3ca2ff --- /dev/null +++ b/src/utilities/getRefNativeTag.web.ts @@ -0,0 +1,6 @@ +import type { RefObject } from 'react'; +import { findNodeHandle } from 'react-native'; + +export function getRefNativeTag(ref: RefObject) { + return findNodeHandle(ref?.current) || null; +} diff --git a/yarn.lock b/yarn.lock index 56a06cecb..eae10e6f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1901,13 +1901,13 @@ "@types/yargs-parser" "*" "@typescript-eslint/eslint-plugin@^5.30.5": - version "5.38.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.38.1.tgz#9f05d42fa8fb9f62304cc2f5c2805e03c01c2620" - integrity sha512-ky7EFzPhqz3XlhS7vPOoMDaQnQMn+9o5ICR9CPr/6bw8HrFkzhMSxuA3gRfiJVvs7geYrSeawGJjZoZQKCOglQ== + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.39.0.tgz#778b2d9e7f293502c7feeea6c74dca8eb3e67511" + integrity sha512-xVfKOkBm5iWMNGKQ2fwX5GVgBuHmZBO1tCRwXmY5oAIsPscfwm2UADDuNB8ZVYCtpQvJK4xpjrK7jEhcJ0zY9A== dependencies: - "@typescript-eslint/scope-manager" "5.38.1" - "@typescript-eslint/type-utils" "5.38.1" - "@typescript-eslint/utils" "5.38.1" + "@typescript-eslint/scope-manager" "5.39.0" + "@typescript-eslint/type-utils" "5.39.0" + "@typescript-eslint/utils" "5.39.0" debug "^4.3.4" ignore "^5.2.0" regexpp "^3.2.0" @@ -1915,69 +1915,69 @@ tsutils "^3.21.0" "@typescript-eslint/parser@^5.30.5": - version "5.38.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.38.1.tgz#c577f429f2c32071b92dff4af4f5fbbbd2414bd0" - integrity sha512-LDqxZBVFFQnQRz9rUZJhLmox+Ep5kdUmLatLQnCRR6523YV+XhRjfYzStQ4MheFA8kMAfUlclHSbu+RKdRwQKw== + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.39.0.tgz#93fa0bc980a3a501e081824f6097f7ca30aaa22b" + integrity sha512-PhxLjrZnHShe431sBAGHaNe6BDdxAASDySgsBCGxcBecVCi8NQWxQZMcizNA4g0pN51bBAn/FUfkWG3SDVcGlA== dependencies: - "@typescript-eslint/scope-manager" "5.38.1" - "@typescript-eslint/types" "5.38.1" - "@typescript-eslint/typescript-estree" "5.38.1" + "@typescript-eslint/scope-manager" "5.39.0" + "@typescript-eslint/types" "5.39.0" + "@typescript-eslint/typescript-estree" "5.39.0" debug "^4.3.4" -"@typescript-eslint/scope-manager@5.38.1": - version "5.38.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.38.1.tgz#f87b289ef8819b47189351814ad183e8801d5764" - integrity sha512-BfRDq5RidVU3RbqApKmS7RFMtkyWMM50qWnDAkKgQiezRtLKsoyRKIvz1Ok5ilRWeD9IuHvaidaLxvGx/2eqTQ== +"@typescript-eslint/scope-manager@5.39.0": + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.39.0.tgz#873e1465afa3d6c78d8ed2da68aed266a08008d0" + integrity sha512-/I13vAqmG3dyqMVSZPjsbuNQlYS082Y7OMkwhCfLXYsmlI0ca4nkL7wJ/4gjX70LD4P8Hnw1JywUVVAwepURBw== dependencies: - "@typescript-eslint/types" "5.38.1" - "@typescript-eslint/visitor-keys" "5.38.1" + "@typescript-eslint/types" "5.39.0" + "@typescript-eslint/visitor-keys" "5.39.0" -"@typescript-eslint/type-utils@5.38.1": - version "5.38.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.38.1.tgz#7f038fcfcc4ade4ea76c7c69b2aa25e6b261f4c1" - integrity sha512-UU3j43TM66gYtzo15ivK2ZFoDFKKP0k03MItzLdq0zV92CeGCXRfXlfQX5ILdd4/DSpHkSjIgLLLh1NtkOJOAw== +"@typescript-eslint/type-utils@5.39.0": + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.39.0.tgz#0a8c00f95dce4335832ad2dc6bc431c14e32a0a6" + integrity sha512-KJHJkOothljQWzR3t/GunL0TPKY+fGJtnpl+pX+sJ0YiKTz3q2Zr87SGTmFqsCMFrLt5E0+o+S6eQY0FAXj9uA== dependencies: - "@typescript-eslint/typescript-estree" "5.38.1" - "@typescript-eslint/utils" "5.38.1" + "@typescript-eslint/typescript-estree" "5.39.0" + "@typescript-eslint/utils" "5.39.0" debug "^4.3.4" tsutils "^3.21.0" -"@typescript-eslint/types@5.38.1": - version "5.38.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.38.1.tgz#74f9d6dcb8dc7c58c51e9fbc6653ded39e2e225c" - integrity sha512-QTW1iHq1Tffp9lNfbfPm4WJabbvpyaehQ0SrvVK2yfV79SytD9XDVxqiPvdrv2LK7DGSFo91TB2FgWanbJAZXg== +"@typescript-eslint/types@5.39.0": + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.39.0.tgz#f4e9f207ebb4579fd854b25c0bf64433bb5ed78d" + integrity sha512-gQMZrnfEBFXK38hYqt8Lkwt8f4U6yq+2H5VDSgP/qiTzC8Nw8JO3OuSUOQ2qW37S/dlwdkHDntkZM6SQhKyPhw== -"@typescript-eslint/typescript-estree@5.38.1": - version "5.38.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.38.1.tgz#657d858d5d6087f96b638ee383ee1cff52605a1e" - integrity sha512-99b5e/Enoe8fKMLdSuwrfH/C0EIbpUWmeEKHmQlGZb8msY33qn1KlkFww0z26o5Omx7EVjzVDCWEfrfCDHfE7g== +"@typescript-eslint/typescript-estree@5.39.0": + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.39.0.tgz#c0316aa04a1a1f4f7f9498e3c13ef1d3dc4cf88b" + integrity sha512-qLFQP0f398sdnogJoLtd43pUgB18Q50QSA+BTE5h3sUxySzbWDpTSdgt4UyxNSozY/oDK2ta6HVAzvGgq8JYnA== dependencies: - "@typescript-eslint/types" "5.38.1" - "@typescript-eslint/visitor-keys" "5.38.1" + "@typescript-eslint/types" "5.39.0" + "@typescript-eslint/visitor-keys" "5.39.0" debug "^4.3.4" globby "^11.1.0" is-glob "^4.0.3" semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/utils@5.38.1", "@typescript-eslint/utils@^5.10.0": - version "5.38.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.38.1.tgz#e3ac37d7b33d1362bb5adf4acdbe00372fb813ef" - integrity sha512-oIuUiVxPBsndrN81oP8tXnFa/+EcZ03qLqPDfSZ5xIJVm7A9V0rlkQwwBOAGtrdN70ZKDlKv+l1BeT4eSFxwXA== +"@typescript-eslint/utils@5.39.0", "@typescript-eslint/utils@^5.10.0": + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.39.0.tgz#b7063cca1dcf08d1d21b0d91db491161ad0be110" + integrity sha512-+DnY5jkpOpgj+EBtYPyHRjXampJfC0yUZZzfzLuUWVZvCuKqSdJVC8UhdWipIw7VKNTfwfAPiOWzYkAwuIhiAg== dependencies: "@types/json-schema" "^7.0.9" - "@typescript-eslint/scope-manager" "5.38.1" - "@typescript-eslint/types" "5.38.1" - "@typescript-eslint/typescript-estree" "5.38.1" + "@typescript-eslint/scope-manager" "5.39.0" + "@typescript-eslint/types" "5.39.0" + "@typescript-eslint/typescript-estree" "5.39.0" eslint-scope "^5.1.1" eslint-utils "^3.0.0" -"@typescript-eslint/visitor-keys@5.38.1": - version "5.38.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.38.1.tgz#508071bfc6b96d194c0afe6a65ad47029059edbc" - integrity sha512-bSHr1rRxXt54+j2n4k54p4fj8AHJ49VDWtjpImOpzQj4qjAiOpPni+V1Tyajh19Api1i844F757cur8wH3YvOA== +"@typescript-eslint/visitor-keys@5.39.0": + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.39.0.tgz#8f41f7d241b47257b081ddba5d3ce80deaae61e2" + integrity sha512-yyE3RPwOG+XJBLrhvsxAidUgybJVQ/hG8BhiJo0k8JSAYfk/CshVcxf0HwP4Jt7WZZ6vLmxdo1p6EyN3tzFTkg== dependencies: - "@typescript-eslint/types" "5.38.1" + "@typescript-eslint/types" "5.39.0" eslint-visitor-keys "^3.3.0" JSONStream@^1.0.4: @@ -3228,9 +3228,9 @@ copyfiles@^2.4.1: yargs "^16.1.0" core-js-compat@^3.25.1: - version "3.25.4" - resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.25.4.tgz#730a255d4a47a937513abf1672bf278dc24dcebf" - integrity sha512-gCEcIEEqCR6230WroNunK/653CWKhqyCKJ9b+uESqOt/WFJA8B4lTnnQFdpYY5vmBcwJAA90Bo5vXs+CVsf6iA== + version "3.25.5" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.25.5.tgz#0016e8158c904f7b059486639e6e82116eafa7d9" + integrity sha512-ovcyhs2DEBUIE0MGEKHP4olCUW/XYte3Vroyxuh38rD1wAO4dHohsovUC4eAOuzFxE6b+RXvBU3UZ9o0YhUTkA== dependencies: browserslist "^4.21.4" @@ -4303,9 +4303,9 @@ for-in@^1.0.2: integrity sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ== form-data-encoder@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-2.1.2.tgz#5996b7c236e8c418d08316055a2235226c5e4061" - integrity sha512-FCaIOVTRA9E0siY6FeXid7D5yrCqpsErplUkE2a1BEiKj1BE9z6FbKB4ntDTwC4NVLie9p+4E9nX4mWwEOT05A== + version "2.1.3" + resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-2.1.3.tgz#682cd821a8423605093992ff895e6b2ed5a9d429" + integrity sha512-KqU0nnPMgIJcCOFTNJFEA8epcseEaoox4XZffTgy8jlI6pL/5EFyR54NRG7CnCJN0biY7q52DO3MH6/sJ/TKlQ== form-data@4.0.0: version "4.0.0" @@ -7421,9 +7421,9 @@ react-native-builder-bob@^0.18.3: jetifier "^2.0.0" react-native-gesture-handler@^2.5.0: - version "2.6.2" - resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.6.2.tgz#f3b68d374f5dda603ff29f7df2edb39472eb97ce" - integrity sha512-Ff/WKlR8KiM1wq7UJZvIyCB+OsweewaeZk+4RDIYNGM9tvNIAXEm/MtYnLHiBXiSJjZItF/8B83gE6pVq40vIw== + version "2.7.0" + resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.7.0.tgz#53ad828add926c8e025f68ea581758c0f8893054" + integrity sha512-0jr3FNm2R3gv/v6XTtENgjv0fewD6LEct8EWmXw/oHw36M3YiIIpxnW57thL+0YiKwyLBXN0QHL4JZbs/heW2Q== dependencies: "@egjs/hammerjs" "^2.0.17" hoist-non-react-statics "^3.3.0"