diff --git a/apps/common-app/App.tsx b/apps/common-app/App.tsx index b2a49d60f9..16ad333f46 100644 --- a/apps/common-app/App.tsx +++ b/apps/common-app/App.tsx @@ -69,6 +69,7 @@ import HoverableIcons from './src/new_api/hoverable_icons'; import VelocityTest from './src/new_api/velocityTest'; import Swipeable from './src/new_api/swipeable'; import Pressable from './src/new_api/pressable'; +import Scroll from './src/new_api/scroll'; import EmptyExample from './src/empty/EmptyExample'; import RectButtonBorders from './src/release_tests/rectButton'; @@ -113,6 +114,11 @@ const EXAMPLES: ExamplesSection[] = [ { name: 'Pressable', component: Pressable }, { name: 'Hover', component: Hover }, { name: 'Hoverable icons', component: HoverableIcons }, + { + name: 'Scroll', + component: Scroll, + unsupportedPlatforms: new Set(['web', 'ios', 'macos']), + }, { name: 'Horizontal Drawer (Reanimated 2 & RNGH 2)', component: BetterHorizontalDrawer, diff --git a/apps/common-app/src/new_api/scroll/BottomSheet.tsx b/apps/common-app/src/new_api/scroll/BottomSheet.tsx new file mode 100644 index 0000000000..8008dc013b --- /dev/null +++ b/apps/common-app/src/new_api/scroll/BottomSheet.tsx @@ -0,0 +1,200 @@ +import React from 'react'; +import { View, Text, StyleSheet, Dimensions } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSpring, + interpolate, + Extrapolation, +} from 'react-native-reanimated'; + +const { height: SCREEN_HEIGHT } = Dimensions.get('window'); +const SHEET_HEIGHT = SCREEN_HEIGHT * 0.5; +const COLLAPSED_Y = SHEET_HEIGHT - 80; +const SCROLL_THRESHOLD = 2; +const SNAP_THRESHOLD = COLLAPSED_Y / 2; + +const springConfig = { damping: 20, stiffness: 90 }; + +export function BottomSheet() { + const translateY = useSharedValue(COLLAPSED_Y); + const context = useSharedValue({ startY: 0 }); + + const scrollGesture = Gesture.Scroll() + .onBegin((event) => { + 'worklet'; + console.log( + `[BottomSheet] Scroll onBegin - handlerTag: ${event.handlerTag}` + ); + }) + .onUpdate((event) => { + 'worklet'; + if (event.scrollY > SCROLL_THRESHOLD && translateY.value > 0) { + translateY.value = withSpring(0, springConfig); + } else if ( + event.scrollY < -SCROLL_THRESHOLD && + translateY.value < COLLAPSED_Y + ) { + translateY.value = withSpring(COLLAPSED_Y, springConfig); + } + }) + .onEnd((event) => { + 'worklet'; + console.log( + `[BottomSheet] Scroll onEnd - handlerTag: ${event.handlerTag}, state: ${event.state}, oldState: ${event.oldState}` + ); + }) + .onFinalize((event) => { + 'worklet'; + console.log( + `[BottomSheet] Scroll onFinalize - handlerTag: ${event.handlerTag}, state: ${event.state}, oldState: ${event.oldState}` + ); + }); + + const panGesture = Gesture.Pan() + .onStart(() => { + 'worklet'; + context.value = { startY: translateY.value }; + }) + .onUpdate((event) => { + 'worklet'; + const newTranslateY = context.value.startY + event.translationY; + translateY.value = Math.max(0, Math.min(COLLAPSED_Y, newTranslateY)); + }) + .onEnd((event) => { + 'worklet'; + const shouldExpand = + translateY.value < SNAP_THRESHOLD || + (event.velocityY < -500 && translateY.value < COLLAPSED_Y); + + if (shouldExpand) { + translateY.value = withSpring(0, springConfig); + } else { + translateY.value = withSpring(COLLAPSED_Y, springConfig); + } + }); + + const gesture = Gesture.Race(scrollGesture, panGesture); + + const sheetStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: translateY.value }], + })); + + const handleStyle = useAnimatedStyle(() => ({ + transform: [ + { + rotate: `${interpolate( + translateY.value, + [0, COLLAPSED_Y], + [180, 0], + Extrapolation.CLAMP + )}deg`, + }, + ], + })); + + const contentOpacity = useAnimatedStyle(() => ({ + opacity: interpolate( + translateY.value, + [0, COLLAPSED_Y], + [1, 0], + Extrapolation.CLAMP + ), + })); + + return ( + + + + + + + + Scroll or drag to expand/collapse + + + + + Bottom Sheet Content + + This bottom sheet responds to both scroll and pan gestures. Use mouse + wheel/trackpad or drag to expand/collapse. + + + {Array.from({ length: 100 }).map((_, index) => ( + // eslint-disable-next-line @eslint-react/no-array-index-key + + {index} + + ))} + + + + ); +} + +const styles = StyleSheet.create({ + sheet: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: SHEET_HEIGHT, + backgroundColor: '#fff', + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + paddingHorizontal: 20, + elevation: 10, + shadowColor: '#000', + shadowOffset: { width: 0, height: -2 }, + shadowOpacity: 0.15, + shadowRadius: 8, + }, + handleContainer: { + alignItems: 'center', + paddingVertical: 12, + }, + handleArrow: { + marginBottom: 4, + }, + arrowText: { + fontSize: 16, + color: '#6C63FF', + }, + handleText: { + fontSize: 12, + color: '#888', + }, + content: { + flex: 1, + paddingBottom: 20, + }, + title: { + fontSize: 18, + fontWeight: 'bold', + color: '#333', + marginBottom: 8, + }, + description: { + fontSize: 14, + color: '#666', + marginBottom: 16, + lineHeight: 20, + }, + items: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 10, + }, + item: { + backgroundColor: '#f0f0ff', + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 12, + }, + itemText: { + color: '#6C63FF', + fontWeight: '600', + }, +}); diff --git a/apps/common-app/src/new_api/scroll/ScrollBox.tsx b/apps/common-app/src/new_api/scroll/ScrollBox.tsx new file mode 100644 index 0000000000..35c23f5784 --- /dev/null +++ b/apps/common-app/src/new_api/scroll/ScrollBox.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { View, Text, StyleSheet, Switch } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; + +const DELTA_SCROLL_MULTIPLIER = 50; +const TOTAL_SCROLL_MULTIPLIER = 10; +const SCROLL_SCALE_MULTIPLIER = 1.05; + +interface ScrollBoxProps { + color: string; + title: string; + useDeltas: boolean; + useSpring: boolean; + onSpringChange: (value: boolean) => void; +} + +export function ScrollBox({ + color, + title, + useDeltas, + useSpring: useSpringAnimation, + onSpringChange, +}: ScrollBoxProps) { + const scrollX = useSharedValue(0); + const scrollY = useSharedValue(0); + const deltaX = useSharedValue(0); + const deltaY = useSharedValue(0); + const isScrolling = useSharedValue(false); + + const gesture = Gesture.Scroll() + .onBegin((event) => { + 'worklet'; + isScrolling.value = true; + console.log( + `[${title}] Scroll onBegin - handlerTag: ${event.handlerTag}, state: ${event.state}` + ); + }) + .onUpdate((event) => { + 'worklet'; + scrollX.value = event.scrollX; + scrollY.value = event.scrollY; + deltaX.value = event.deltaX; + deltaY.value = event.deltaY; + }) + .onEnd((event) => { + 'worklet'; + console.log( + `[${title}] Scroll onEnd - handlerTag: ${event.handlerTag}, state: ${event.state}, oldState: ${event.oldState}` + ); + }) + .onFinalize(() => { + 'worklet'; + isScrolling.value = false; + scrollX.value = 0; + scrollY.value = 0; + deltaX.value = 0; + deltaY.value = 0; + }); + + const animatedStyle = useAnimatedStyle(() => { + const x = useDeltas + ? deltaX.value * DELTA_SCROLL_MULTIPLIER + : scrollX.value * TOTAL_SCROLL_MULTIPLIER; + const y = useDeltas + ? deltaY.value * DELTA_SCROLL_MULTIPLIER + : scrollY.value * TOTAL_SCROLL_MULTIPLIER; + + const targetX = isScrolling.value + ? x + : useSpringAnimation + ? withSpring(0) + : 0; + const targetY = isScrolling.value + ? -y + : useSpringAnimation + ? withSpring(0) + : 0; + + return { + transform: [ + { translateX: isScrolling.value ? x : targetX }, + { translateY: isScrolling.value ? -y : targetY }, + { scale: isScrolling.value ? SCROLL_SCALE_MULTIPLIER : 1 }, + ], + opacity: isScrolling.value ? 0.8 : 1, + }; + }); + + return ( + + {title} + + {useDeltas ? '(deltaX/deltaY)' : '(scrollX/scrollY)'} + + + + + + Spring + + + + ); +} + +const styles = StyleSheet.create({ + boxContainer: { + alignItems: 'center', + }, + label: { + fontSize: 12, + color: '#888', + marginBottom: 2, + }, + sublabel: { + fontSize: 10, + color: '#aaa', + marginBottom: 8, + }, + switchContainer: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 8, + gap: 6, + }, + switchLabel: { + fontSize: 12, + color: '#666', + }, + box: { + width: 120, + height: 120, + borderRadius: 16, + justifyContent: 'center', + alignItems: 'center', + overflow: 'hidden', + elevation: 4, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + }, +}); diff --git a/apps/common-app/src/new_api/scroll/index.tsx b/apps/common-app/src/new_api/scroll/index.tsx new file mode 100644 index 0000000000..b76001e46a --- /dev/null +++ b/apps/common-app/src/new_api/scroll/index.tsx @@ -0,0 +1,117 @@ +import React, { useState } from 'react'; +import { View, Text, StyleSheet, Platform, ScrollView } from 'react-native'; + +import { ScrollBox } from './ScrollBox'; +import { BottomSheet } from './BottomSheet'; + +export default function ScrollExample() { + const [totalScrollSpring, setTotalScrollSpring] = useState(true); + const [deltaScrollSpring, setDeltaScrollSpring] = useState(true); + + if (Platform.OS !== 'android') { + return ( + + Scroll Gesture + + This gesture is only available on Android. + + + The Scroll gesture responds to mouse wheel and trackpad scroll events + (ACTION_SCROLL from Android). + + + ); + } + + return ( + + + Scroll Gesture + + Use your mouse wheel or trackpad to scroll over the elements below. + + + Box Demo + + Compare total scroll (scrollX/scrollY) vs delta values (deltaX/deltaY) + + + + + + + + + 💡 Scroll or drag on the bottom sheet to expand/collapse it + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + content: { + padding: 20, + paddingBottom: 40, + }, + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 10, + color: '#333', + }, + description: { + fontSize: 14, + color: '#666', + marginBottom: 20, + lineHeight: 20, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '600', + color: '#333', + marginTop: 10, + marginBottom: 4, + }, + sectionDescription: { + fontSize: 12, + color: '#888', + marginBottom: 12, + }, + unsupported: { + fontSize: 16, + color: '#FF6B6B', + marginBottom: 10, + fontWeight: '600', + }, + boxesRow: { + flexDirection: 'row', + justifyContent: 'space-around', + marginBottom: 20, + }, + hint: { + fontSize: 12, + color: '#888', + textAlign: 'center', + marginTop: 20, + }, +}); diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt index b9e4b2c6e2..a5c67862e1 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt @@ -21,6 +21,7 @@ import com.swmansion.gesturehandler.RNSVGHitTester import com.swmansion.gesturehandler.react.RNGestureHandlerTouchEvent import com.swmansion.gesturehandler.react.eventbuilders.GestureHandlerEventDataBuilder import com.swmansion.gesturehandler.react.isHoverAction +import com.swmansion.gesturehandler.react.isScrollAction import java.lang.IllegalStateException import java.util.* @@ -388,6 +389,8 @@ open class GestureHandler { if (sourceEvent.isHoverAction()) { onHandleHover(adaptedTransformedEvent, adaptedSourceEvent) + } else if (sourceEvent.isScrollAction()) { + onHandleScroll(adaptedTransformedEvent, adaptedSourceEvent) } else { onHandle(adaptedTransformedEvent, adaptedSourceEvent) } @@ -725,6 +728,8 @@ open class GestureHandler { protected open fun onHandleHover(event: MotionEvent, sourceEvent: MotionEvent) {} + protected open fun onHandleScroll(event: MotionEvent, sourceEvent: MotionEvent) {} + protected open fun onStateChange(newState: Int, previousState: Int) {} protected open fun onReset() {} protected open fun onCancel() {} diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt index ced298e523..e94a5b5d33 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt @@ -10,6 +10,7 @@ import com.facebook.react.uimanager.RootView import com.swmansion.gesturehandler.react.RNGestureHandlerRootHelper import com.swmansion.gesturehandler.react.RNGestureHandlerRootView import com.swmansion.gesturehandler.react.isHoverAction +import com.swmansion.gesturehandler.react.isScrollAction import java.util.* class GestureHandlerOrchestrator( @@ -48,7 +49,8 @@ class GestureHandlerOrchestrator( val action = event.actionMasked if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN || - action == MotionEvent.ACTION_HOVER_MOVE + action == MotionEvent.ACTION_HOVER_MOVE || + action == MotionEvent.ACTION_SCROLL ) { extractGestureHandlers(event) } else if (action == MotionEvent.ACTION_CANCEL) { @@ -290,11 +292,14 @@ class GestureHandlerOrchestrator( return } - if (!handler.wantsEvent(sourceEvent)) { + val action = sourceEvent.actionMasked + + // Scroll events bypass the normal wantsEvent check since they don't have traditional pointer tracking + val isScrollEvent = sourceEvent.isScrollAction() + if (!isScrollEvent && !handler.wantsEvent(sourceEvent)) { return } - val action = sourceEvent.actionMasked val event = transformEventToViewCoords(handler.view, MotionEvent.obtain(sourceEvent)) // Touch events are sent before the handler itself has a chance to process them, @@ -311,34 +316,43 @@ class GestureHandlerOrchestrator( if (!handler.isAwaiting || action != MotionEvent.ACTION_MOVE) { val isFirstEvent = handler.state == 0 - handler.handle(event, sourceEvent) - if (handler.isActive) { - // After handler is done waiting for other one to fail its progress should be - // reset, otherwise there may be a visible jump in values sent by the handler. - // When handler is waiting it's already activated but the `isAwaiting` flag - // prevents it from receiving touch stream. When the flag is changed, the - // difference between this event and the last one may be large enough to be - // visible in interactions based on this gesture. This makes it consistent with - // the behavior on iOS. - if (handler.shouldResetProgress) { - handler.shouldResetProgress = false - handler.resetProgress() + + // For scroll events, we handle them directly since they don't have traditional pointer tracking + if (isScrollEvent && handler is ScrollGestureHandler) { + handler.handleScrollEvent(event, sourceEvent) + if (handler.isActive) { + handler.dispatchHandlerUpdate(event) + } + } else { + handler.handle(event, sourceEvent) + if (handler.isActive) { + // After handler is done waiting for other one to fail its progress should be + // reset, otherwise there may be a visible jump in values sent by the handler. + // When handler is waiting it's already activated but the `isAwaiting` flag + // prevents it from receiving touch stream. When the flag is changed, the + // difference between this event and the last one may be large enough to be + // visible in interactions based on this gesture. This makes it consistent with + // the behavior on iOS. + if (handler.shouldResetProgress) { + handler.shouldResetProgress = false + handler.resetProgress() + } + handler.dispatchHandlerUpdate(event) } - handler.dispatchHandlerUpdate(event) - } - if (handler.needsPointerData && isFirstEvent) { - handler.updatePointerData(event, sourceEvent) - } + if (handler.needsPointerData && isFirstEvent) { + handler.updatePointerData(event, sourceEvent) + } - // if event was of type UP or POINTER_UP we request handler to stop tracking now that - // the event has been dispatched - if (action == MotionEvent.ACTION_UP || - action == MotionEvent.ACTION_POINTER_UP || - action == MotionEvent.ACTION_HOVER_EXIT - ) { - val pointerId = event.getPointerId(event.actionIndex) - handler.stopTrackingPointer(pointerId) + // if event was of type UP or POINTER_UP we request handler to stop tracking now that + // the event has been dispatched + if (action == MotionEvent.ACTION_UP || + action == MotionEvent.ACTION_POINTER_UP || + action == MotionEvent.ACTION_HOVER_EXIT + ) { + val pointerId = event.getPointerId(event.actionIndex) + handler.stopTrackingPointer(pointerId) + } } } @@ -518,12 +532,20 @@ class GestureHandlerOrchestrator( // There's only one exception - RootViewGestureHandler. TalkBack uses hover events, // so we need to pass them into RootViewGestureHandler, otherwise press and hold // gesture stops working correctly (see https://github.com/software-mansion/react-native-gesture-handler/issues/3407) - private fun shouldHandlerSkipHoverEvents(handler: GestureHandler, event: MotionEvent): Boolean { - val shouldSkipHoverEvents = - handler !is HoverGestureHandler && - handler !is RNGestureHandlerRootHelper.RootViewGestureHandler + // Similarly, scroll events should only be handled by ScrollGestureHandler. + private fun shouldHandlerSkipSpecialEvents(handler: GestureHandler, event: MotionEvent): Boolean { + if (event.isHoverAction()) { + val shouldSkipHoverEvents = + handler !is HoverGestureHandler && + handler !is RNGestureHandlerRootHelper.RootViewGestureHandler + return shouldSkipHoverEvents + } + + if (event.isScrollAction()) { + return handler !is ScrollGestureHandler + } - return shouldSkipHoverEvents && event.isHoverAction() + return false } private fun recordViewHandlersForPointer( @@ -541,7 +563,7 @@ class GestureHandlerOrchestrator( continue } - if (shouldHandlerSkipHoverEvents(handler, event)) { + if (shouldHandlerSkipSpecialEvents(handler, event)) { continue } diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/ScrollGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/ScrollGestureHandler.kt new file mode 100644 index 0000000000..c2f1766734 --- /dev/null +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/ScrollGestureHandler.kt @@ -0,0 +1,151 @@ +package com.swmansion.gesturehandler.core + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.view.MotionEvent +import com.facebook.react.bridge.ReadableMap +import com.swmansion.gesturehandler.react.eventbuilders.ScrollGestureHandlerEventDataBuilder + +class ScrollGestureHandler(context: Context?) : GestureHandler() { + // Accumulated scroll values + var scrollX = 0f + private set + var scrollY = 0f + private set + + // Per-event scroll deltas (from the last scroll event) + var deltaX = 0f + private set + var deltaY = 0f + private set + + // Position tracking for scroll events (since they bypass normal handle method) + var lastScrollPositionX = 0f + private set + var lastScrollPositionY = 0f + private set + var lastScrollAbsoluteX = 0f + private set + var lastScrollAbsoluteY = 0f + private set + + // Handler for scroll end timeout + private val handler = Handler(Looper.getMainLooper()) + private val scrollEndRunnable = Runnable { + if (state == STATE_ACTIVE) { + end() + } + } + + override fun resetConfig() { + super.resetConfig() + } + + override fun onReset() { + scrollX = 0f + scrollY = 0f + deltaX = 0f + deltaY = 0f + lastScrollPositionX = 0f + lastScrollPositionY = 0f + lastScrollAbsoluteX = 0f + lastScrollAbsoluteY = 0f + handler.removeCallbacks(scrollEndRunnable) + } + + override fun resetProgress() { + scrollX = 0f + scrollY = 0f + deltaX = 0f + deltaY = 0f + } + + fun handleScrollEvent(event: MotionEvent, sourceEvent: MotionEvent) { + if (!isEnabled || + state == STATE_CANCELLED || + state == STATE_FAILED || + state == STATE_END + ) { + return + } + + // Cancel any pending scroll end timeout + handler.removeCallbacks(scrollEndRunnable) + + // Update position from the event (since scroll events bypass normal handle method) + lastScrollPositionX = event.x + lastScrollPositionY = event.y + lastScrollAbsoluteX = sourceEvent.rawX + lastScrollAbsoluteY = sourceEvent.rawY + + // Cancel the gesture if the pointer is outside the view bounds + if (!isWithinBounds(view, event.x, event.y)) { + if (state == STATE_ACTIVE) { + cancel() + } else if (state == STATE_BEGAN) { + fail() + } + return + } + + // AXIS_HSCROLL and AXIS_VSCROLL give the scroll delta + // Positive AXIS_VSCROLL means scrolling up/away from user + // Positive AXIS_HSCROLL means scrolling right + val hScroll = sourceEvent.getAxisValue(MotionEvent.AXIS_HSCROLL) + val vScroll = sourceEvent.getAxisValue(MotionEvent.AXIS_VSCROLL) + + // Store per-event deltas + deltaX = hScroll + deltaY = vScroll + + // Accumulate total scroll + scrollX += hScroll + scrollY += vScroll + + when (state) { + STATE_UNDETERMINED -> { + begin() + activate() + } + STATE_ACTIVE -> { + // Handler is already active, just update values + } + } + + // Schedule scroll end after timeout (no more scroll events received) + handler.postDelayed(scrollEndRunnable, SCROLL_END_TIMEOUT_MS) + } + + override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) { + // Regular touch events are not handled by this gesture. + // Only fail if we've already begun - if we're still in UNDETERMINED state, + // there's nothing to fail and calling fail() would send a spurious state + // change event to JS causing onFinalize to be called without onBegin. + if (state != STATE_UNDETERMINED) { + fail() + } + } + + override fun onHandleScroll(event: MotionEvent, sourceEvent: MotionEvent) { + handleScrollEvent(event, sourceEvent) + } + + class Factory : GestureHandler.Factory() { + override val type = ScrollGestureHandler::class.java + override val name = "ScrollGestureHandler" + + override fun create(context: Context?): ScrollGestureHandler = ScrollGestureHandler(context) + + override fun setConfig(handler: ScrollGestureHandler, config: ReadableMap) { + super.setConfig(handler, config) + } + + override fun createEventBuilder(handler: ScrollGestureHandler) = ScrollGestureHandlerEventDataBuilder(handler) + } + + companion object { + // Time in ms to wait after the last scroll event before ending the gesture + private const val SCROLL_END_TIMEOUT_MS = 150L + } +} diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/Extensions.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/Extensions.kt index 51180b29de..ff0fed21c5 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/Extensions.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/Extensions.kt @@ -19,3 +19,5 @@ fun Context.isScreenReaderOn() = fun MotionEvent.isHoverAction(): Boolean = action == MotionEvent.ACTION_HOVER_MOVE || action == MotionEvent.ACTION_HOVER_ENTER || action == MotionEvent.ACTION_HOVER_EXIT + +fun MotionEvent.isScrollAction(): Boolean = action == MotionEvent.ACTION_SCROLL diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerFactoryUtil.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerFactoryUtil.kt index 7b9a046d65..4bf7ff4c57 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerFactoryUtil.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerFactoryUtil.kt @@ -9,6 +9,7 @@ import com.swmansion.gesturehandler.core.NativeViewGestureHandler import com.swmansion.gesturehandler.core.PanGestureHandler import com.swmansion.gesturehandler.core.PinchGestureHandler import com.swmansion.gesturehandler.core.RotationGestureHandler +import com.swmansion.gesturehandler.core.ScrollGestureHandler import com.swmansion.gesturehandler.core.TapGestureHandler object RNGestureHandlerFactoryUtil { @@ -22,6 +23,7 @@ object RNGestureHandlerFactoryUtil { FlingGestureHandler.Factory(), ManualGestureHandler.Factory(), HoverGestureHandler.Factory(), + ScrollGestureHandler.Factory(), ) @Suppress("UNCHECKED_CAST") diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt index 2801b8a0f5..0ce4d447cb 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt @@ -41,7 +41,7 @@ class RNGestureHandlerRootView(context: Context?) : ReactViewGroup(context) { } override fun dispatchGenericMotionEvent(ev: MotionEvent) = - if (rootViewEnabled && ev.isHoverAction() && rootHelper!!.dispatchTouchEvent(ev)) { + if (rootViewEnabled && (ev.isHoverAction() || ev.isScrollAction()) && rootHelper!!.dispatchTouchEvent(ev)) { true } else { super.dispatchGenericMotionEvent(ev) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/eventbuilders/ScrollGestureHandlerEventDataBuilder.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/eventbuilders/ScrollGestureHandlerEventDataBuilder.kt new file mode 100644 index 0000000000..0b8cda121d --- /dev/null +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/eventbuilders/ScrollGestureHandlerEventDataBuilder.kt @@ -0,0 +1,44 @@ +package com.swmansion.gesturehandler.react.eventbuilders + +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.PixelUtil +import com.swmansion.gesturehandler.core.ScrollGestureHandler + +class ScrollGestureHandlerEventDataBuilder(handler: ScrollGestureHandler) : + GestureHandlerEventDataBuilder(handler) { + private val x: Float + private val y: Float + private val absoluteX: Float + private val absoluteY: Float + private val scrollX: Float + private val scrollY: Float + private val deltaX: Float + private val deltaY: Float + + init { + // Use scroll-specific position values since scroll events bypass normal handle method + x = handler.lastScrollPositionX + y = handler.lastScrollPositionY + absoluteX = handler.lastScrollAbsoluteX + absoluteY = handler.lastScrollAbsoluteY + scrollX = handler.scrollX + scrollY = handler.scrollY + deltaX = handler.deltaX + deltaY = handler.deltaY + } + + override fun buildEventData(eventData: WritableMap) { + super.buildEventData(eventData) + + with(eventData) { + putDouble("x", PixelUtil.toDIPFromPixel(x).toDouble()) + putDouble("y", PixelUtil.toDIPFromPixel(y).toDouble()) + putDouble("absoluteX", PixelUtil.toDIPFromPixel(absoluteX).toDouble()) + putDouble("absoluteY", PixelUtil.toDIPFromPixel(absoluteY).toDouble()) + putDouble("scrollX", scrollX.toDouble()) + putDouble("scrollY", scrollY.toDouble()) + putDouble("deltaX", deltaX.toDouble()) + putDouble("deltaY", deltaY.toDouble()) + } + } +} diff --git a/packages/react-native-gesture-handler/src/handlers/GestureHandlerEventPayload.ts b/packages/react-native-gesture-handler/src/handlers/GestureHandlerEventPayload.ts index a5a3e13c1e..5fbebcbcc9 100644 --- a/packages/react-native-gesture-handler/src/handlers/GestureHandlerEventPayload.ts +++ b/packages/react-native-gesture-handler/src/handlers/GestureHandlerEventPayload.ts @@ -227,3 +227,57 @@ export type HoverGestureHandlerEventPayload = { */ stylusData?: StylusData; }; + +export type ScrollGestureHandlerEventPayload = { + /** + * X coordinate of the current position of the pointer relative to the view + * attached to the handler. Expressed in point units. + */ + x: number; + + /** + * Y coordinate of the current position of the pointer relative to the view + * attached to the handler. Expressed in point units. + */ + y: number; + + /** + * X coordinate of the current position of the pointer relative to the window. + * The value is expressed in point units. It is recommended to use it instead + * of `x` in cases when the original view can be transformed as an + * effect of the gesture. + */ + absoluteX: number; + + /** + * Y coordinate of the current position of the pointer relative to the window. + * The value is expressed in point units. It is recommended to use it instead + * of `y` in cases when the original view can be transformed as an + * effect of the gesture. + */ + absoluteY: number; + + /** + * Accumulated horizontal scroll delta since the gesture started. + * Positive values indicate scrolling right. + */ + scrollX: number; + + /** + * Accumulated vertical scroll delta since the gesture started. + * Positive values indicate scrolling up/away from user. + */ + scrollY: number; + + /** + * Horizontal scroll delta from the current scroll event. + * Positive values indicate scrolling right. + */ + deltaX: number; + + /** + * Vertical scroll delta from the current scroll event. + * Positive values indicate scrolling up/away from user. + */ + deltaY: number; +}; diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/gesture.ts b/packages/react-native-gesture-handler/src/handlers/gestures/gesture.ts index d5e4fe527e..6a464f3d82 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/gesture.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/gesture.ts @@ -19,6 +19,7 @@ import type { TapGestureHandlerEventPayload, NativeViewGestureHandlerPayload, HoverGestureHandlerEventPayload, + ScrollGestureHandlerEventPayload, } from '../GestureHandlerEventPayload'; import { isRemoteDebuggingEnabled } from '../../utils'; @@ -33,7 +34,8 @@ export type GestureType = | BaseGesture | BaseGesture | BaseGesture - | BaseGesture; + | BaseGesture + | BaseGesture; export type GestureRef = | number diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/gestureObjects.ts b/packages/react-native-gesture-handler/src/handlers/gestures/gestureObjects.ts index 0ed518a164..d74c7e5692 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/gestureObjects.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/gestureObjects.ts @@ -14,6 +14,7 @@ import { TapGesture } from './tapGesture'; import { NativeGesture } from './nativeGesture'; import { ManualGesture } from './manualGesture'; import { HoverGesture } from './hoverGesture'; +import { ScrollGesture } from './scrollGesture'; /** * `Gesture` is the object that allows you to create and compose gestures. @@ -112,6 +113,15 @@ export const GestureObjects = { return new HoverGesture(); }, + /** + * #### Android only + * A continuous gesture that can recognize scroll events from a mouse wheel or trackpad. + * This gesture responds to ACTION_SCROLL events from Android. + */ + Scroll: () => { + return new ScrollGesture(); + }, + /** * Builds a composed gesture consisting of gestures provided as parameters. * The first one that becomes active cancels the rest of gestures. diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/scrollGesture.ts b/packages/react-native-gesture-handler/src/handlers/gestures/scrollGesture.ts new file mode 100644 index 0000000000..ab951a83d8 --- /dev/null +++ b/packages/react-native-gesture-handler/src/handlers/gestures/scrollGesture.ts @@ -0,0 +1,56 @@ +import { BaseGestureConfig, ContinousBaseGesture } from './gesture'; +import { GestureUpdateEvent } from '../gestureHandlerCommon'; +import type { ScrollGestureHandlerEventPayload } from '../GestureHandlerEventPayload'; + +export type ScrollGestureChangeEventPayload = { + changeX: number; + changeY: number; +}; + +function changeEventCalculator( + current: GestureUpdateEvent, + previous?: GestureUpdateEvent +) { + 'worklet'; + let changePayload: ScrollGestureChangeEventPayload; + if (previous === undefined) { + changePayload = { + changeX: current.scrollX, + changeY: current.scrollY, + }; + } else { + changePayload = { + changeX: current.scrollX - previous.scrollX, + changeY: current.scrollY - previous.scrollY, + }; + } + + return { ...current, ...changePayload }; +} + +export class ScrollGesture extends ContinousBaseGesture< + ScrollGestureHandlerEventPayload, + ScrollGestureChangeEventPayload +> { + public config: BaseGestureConfig = {}; + + constructor() { + super(); + + this.handlerName = 'ScrollGestureHandler'; + } + + onChange( + callback: ( + event: GestureUpdateEvent< + ScrollGestureHandlerEventPayload & ScrollGestureChangeEventPayload + > + ) => void + ) { + // @ts-ignore TS being overprotective, ScrollGestureHandlerEventPayload is Record + this.handlers.changeEventCalculator = changeEventCalculator; + return super.onChange(callback); + } +} + +export type ScrollGestureType = InstanceType; diff --git a/packages/react-native-gesture-handler/src/index.ts b/packages/react-native-gesture-handler/src/index.ts index dcf7998e59..1b6c5d148a 100644 --- a/packages/react-native-gesture-handler/src/index.ts +++ b/packages/react-native-gesture-handler/src/index.ts @@ -30,6 +30,7 @@ export type { RotationGestureHandlerEventPayload, NativeViewGestureHandlerPayload, FlingGestureHandlerEventPayload, + ScrollGestureHandlerEventPayload, } from './handlers/GestureHandlerEventPayload'; export type { TapGestureHandlerProps } from './handlers/TapGestureHandler'; export type { ForceTouchGestureHandlerProps } from './handlers/ForceTouchGestureHandler'; @@ -62,6 +63,8 @@ export type { ForceTouchGestureType as ForceTouchGesture } from './handlers/gest export type { NativeGestureType as NativeGesture } from './handlers/gestures/nativeGesture'; export type { ManualGestureType as ManualGesture } from './handlers/gestures/manualGesture'; export type { HoverGestureType as HoverGesture } from './handlers/gestures/hoverGesture'; +export type { ScrollGestureType as ScrollGesture } from './handlers/gestures/scrollGesture'; +export type { ScrollGestureChangeEventPayload } from './handlers/gestures/scrollGesture'; export type { ComposedGestureType as ComposedGesture, RaceGestureType as RaceGesture,