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,