From 2c5ee75a5a3e947672e3d7eccd27d9681be2f4a3 Mon Sep 17 00:00:00 2001 From: Jakub Kosmydel Date: Thu, 11 Dec 2025 13:56:42 +0100 Subject: [PATCH 1/5] [android] feat: add Scroll Gesture --- apps/common-app/App.tsx | 6 + apps/common-app/src/new_api/scroll/index.tsx | 469 ++++++++++++++++++ .../gesturehandler/core/GestureHandler.kt | 5 + .../core/GestureHandlerOrchestrator.kt | 90 ++-- .../core/ScrollGestureHandler.kt | 141 ++++++ .../gesturehandler/react/Extensions.kt | 2 + .../react/RNGestureHandlerFactoryUtil.kt | 2 + .../react/RNGestureHandlerRootView.kt | 2 +- .../ScrollGestureHandlerEventDataBuilder.kt | 44 ++ .../handlers/GestureHandlerEventPayload.ts | 54 ++ .../src/handlers/gestures/gesture.ts | 4 +- .../src/handlers/gestures/gestureObjects.ts | 10 + .../src/handlers/gestures/scrollGesture.ts | 56 +++ .../react-native-gesture-handler/src/index.ts | 3 + 14 files changed, 852 insertions(+), 36 deletions(-) create mode 100644 apps/common-app/src/new_api/scroll/index.tsx create mode 100644 packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/ScrollGestureHandler.kt create mode 100644 packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/eventbuilders/ScrollGestureHandlerEventDataBuilder.kt create mode 100644 packages/react-native-gesture-handler/src/handlers/gestures/scrollGesture.ts 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/index.tsx b/apps/common-app/src/new_api/scroll/index.tsx new file mode 100644 index 0000000000..dd1e8144a7 --- /dev/null +++ b/apps/common-app/src/new_api/scroll/index.tsx @@ -0,0 +1,469 @@ +import React, { useState } from 'react'; +import { + View, + Text, + StyleSheet, + Platform, + Dimensions, + Switch, +} 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 BOTTOM_SHEET_HEIGHT = SCREEN_HEIGHT * 0.5; +const COLLAPSED_TRANSLATE_Y = BOTTOM_SHEET_HEIGHT - 80; // Only show 80px when collapsed +const SCROLL_THRESHOLD = 2; // Amount of scroll needed to trigger expand/collapse +const SNAP_THRESHOLD = COLLAPSED_TRANSLATE_Y / 2; // Threshold for snapping to expanded/collapsed + +const DELTA_SCROLL_MULTIPLIER = 50; +const TOTAL_SCROLL_MULTIPLIER = 10; +const SCROLL_SCALE_MULTIPLIER = 1.05; + +function ScrollBottomSheet() { + const translateY = useSharedValue(COLLAPSED_TRANSLATE_Y); + const context = useSharedValue({ startY: 0 }); + + const springConfig = { + damping: 20, + stiffness: 90, + }; + + // Scroll gesture for mouse wheel / trackpad + const scrollGesture = Gesture.Scroll() + .onBegin((event) => { + 'worklet'; + console.log( + `[BottomSheet] Scroll onBegin - handlerTag: ${event.handlerTag}` + ); + }) + .onUpdate((event) => { + 'worklet'; + // Positive scrollY means scrolling up (away from user) -> expand + // Negative scrollY means scrolling down (toward user) -> collapse + if (event.scrollY > SCROLL_THRESHOLD && translateY.value > 0) { + translateY.value = withSpring(0, springConfig); + } else if ( + event.scrollY < -SCROLL_THRESHOLD && + translateY.value < COLLAPSED_TRANSLATE_Y + ) { + translateY.value = withSpring(COLLAPSED_TRANSLATE_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}` + ); + }); + + // Pan gesture for touch/drag + const panGesture = Gesture.Pan() + .onStart(() => { + 'worklet'; + context.value = { startY: translateY.value }; + }) + .onUpdate((event) => { + 'worklet'; + // Dragging down = positive translationY = increase translateY (collapse) + // Dragging up = negative translationY = decrease translateY (expand) + const newTranslateY = context.value.startY + event.translationY; + // Clamp between 0 (fully expanded) and COLLAPSED_TRANSLATE_Y (collapsed) + translateY.value = Math.max( + 0, + Math.min(COLLAPSED_TRANSLATE_Y, newTranslateY) + ); + }) + .onEnd((event) => { + 'worklet'; + // Snap to expanded or collapsed based on position and velocity + const shouldExpand = + translateY.value < SNAP_THRESHOLD || + (event.velocityY < -500 && translateY.value < COLLAPSED_TRANSLATE_Y); + + if (shouldExpand) { + translateY.value = withSpring(0, springConfig); + } else { + translateY.value = withSpring(COLLAPSED_TRANSLATE_Y, springConfig); + } + }); + + // Combine gestures - Race means the first gesture to activate wins + const gesture = Gesture.Race(scrollGesture, panGesture); + + const sheetStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: translateY.value }], + })); + + const handleStyle = useAnimatedStyle(() => ({ + transform: [ + { + rotate: `${interpolate( + translateY.value, + [0, COLLAPSED_TRANSLATE_Y], + [180, 0], + Extrapolation.CLAMP + )}deg`, + }, + ], + })); + + const contentOpacity = useAnimatedStyle(() => ({ + opacity: interpolate( + translateY.value, + [0, COLLAPSED_TRANSLATE_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. + + + {['Item 1', 'Item 2', 'Item 3', 'Item 4'].map((item) => ( + + {item} + + ))} + + + + + ); +} + +function ScrollBox({ + color, + title, + useDeltas, + useSpring, + onSpringChange, +}: { + color: string; + title: string; + useDeltas: boolean; + useSpring: boolean; + onSpringChange: (value: boolean) => void; +}) { + 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; + console.log( + `[${title}] Scroll: x=${event.scrollX.toFixed(2)}, y=${event.scrollY.toFixed(2)}, deltaX=${event.deltaX.toFixed(2)}, deltaY=${event.deltaY.toFixed(2)}` + ); + }) + .onEnd((event) => { + 'worklet'; + console.log( + `[${title}] Scroll onEnd - handlerTag: ${event.handlerTag}, state: ${event.state}, oldState: ${event.oldState}` + ); + }) + .onFinalize(() => { + 'worklet'; + isScrolling.value = false; + // Reset values - spring will be applied based on useSpring prop + 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; + + // Apply spring only when resetting (when not scrolling) + const targetX = isScrolling.value ? x : useSpring ? withSpring(0) : 0; + const targetY = isScrolling.value ? -y : useSpring ? 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 + + + + ); +} + +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) + + + + + + + + + 💡 Tip: Check the console for scroll event logs + + + + 💡 Scroll or drag on the bottom sheet to expand/collapse it + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 20, + backgroundColor: '#f5f5f5', + }, + 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, + }, + 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, + }, + hint: { + fontSize: 12, + color: '#888', + textAlign: 'center', + marginTop: 20, + }, + // Bottom Sheet styles + bottomSheet: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: BOTTOM_SHEET_HEIGHT, + backgroundColor: '#fff', + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + paddingHorizontal: 20, + elevation: 10, + shadowColor: '#000', + shadowOffset: { width: 0, height: -4 }, + shadowOpacity: 0.15, + shadowRadius: 8, + }, + handleContainer: { + alignItems: 'center', + paddingVertical: 12, + }, + handleArrow: { + marginBottom: 4, + }, + arrowText: { + fontSize: 16, + color: '#6C63FF', + }, + handleText: { + fontSize: 12, + color: '#888', + }, + sheetContent: { + flex: 1, + paddingBottom: 20, + }, + sheetTitle: { + fontSize: 18, + fontWeight: 'bold', + color: '#333', + marginBottom: 8, + }, + sheetDescription: { + fontSize: 14, + color: '#666', + marginBottom: 16, + lineHeight: 20, + }, + sheetItems: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 10, + }, + sheetItem: { + backgroundColor: '#f0f0ff', + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 12, + }, + sheetItemText: { + color: '#6C63FF', + fontWeight: '600', + }, +}); 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..b62bd1d99e --- /dev/null +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/ScrollGestureHandler.kt @@ -0,0 +1,141 @@ +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 + + // 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, From d21d18226902bce27f30855ec8d864e8017026d7 Mon Sep 17 00:00:00 2001 From: Jakub Kosmydel Date: Mon, 15 Dec 2025 10:15:05 +0100 Subject: [PATCH 2/5] fix --- apps/common-app/src/new_api/scroll/index.tsx | 37 ++++++++++---------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/apps/common-app/src/new_api/scroll/index.tsx b/apps/common-app/src/new_api/scroll/index.tsx index dd1e8144a7..666fd3f378 100644 --- a/apps/common-app/src/new_api/scroll/index.tsx +++ b/apps/common-app/src/new_api/scroll/index.tsx @@ -130,8 +130,8 @@ function ScrollBottomSheet() { })); return ( - - + + @@ -140,22 +140,23 @@ function ScrollBottomSheet() { 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. - - - {['Item 1', 'Item 2', 'Item 3', 'Item 4'].map((item) => ( - - {item} - - ))} - - - - + + + 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} + + ))} + + + ); } From 3d2c1add1082d0c96debaec5aa775ea24d215d30 Mon Sep 17 00:00:00 2001 From: Jakub Kosmydel Date: Mon, 15 Dec 2025 10:32:14 +0100 Subject: [PATCH 3/5] cancel gesture outside the view bounds --- .../gesturehandler/core/ScrollGestureHandler.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 index b62bd1d99e..c2f1766734 100644 --- 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 @@ -79,6 +79,16 @@ class ScrollGestureHandler(context: Context?) : GestureHandler() { 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 From cbc17882ebd40735e4ebeccc3d93cd3094a1bc26 Mon Sep 17 00:00:00 2001 From: Jakub Kosmydel Date: Mon, 15 Dec 2025 10:48:51 +0100 Subject: [PATCH 4/5] split demos --- .../src/new_api/scroll/BottomSheet.tsx | 196 +++++++++ .../new_api/scroll/HorizontalScrollView.tsx | 202 ++++++++++ .../src/new_api/scroll/ScrollBox.tsx | 155 ++++++++ apps/common-app/src/new_api/scroll/index.tsx | 375 +----------------- 4 files changed, 566 insertions(+), 362 deletions(-) create mode 100644 apps/common-app/src/new_api/scroll/BottomSheet.tsx create mode 100644 apps/common-app/src/new_api/scroll/HorizontalScrollView.tsx create mode 100644 apps/common-app/src/new_api/scroll/ScrollBox.tsx 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..6f80be26d6 --- /dev/null +++ b/apps/common-app/src/new_api/scroll/BottomSheet.tsx @@ -0,0 +1,196 @@ +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: { + height: SHEET_HEIGHT, + backgroundColor: '#fff', + borderRadius: 24, + paddingHorizontal: 20, + marginTop: 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/HorizontalScrollView.tsx b/apps/common-app/src/new_api/scroll/HorizontalScrollView.tsx new file mode 100644 index 0000000000..2e6da73071 --- /dev/null +++ b/apps/common-app/src/new_api/scroll/HorizontalScrollView.tsx @@ -0,0 +1,202 @@ +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 VIEWPORT_WIDTH = Dimensions.get('window').width - 40; +const CONTENT_WIDTH = 1200; +const SCROLL_SENSITIVITY = 15; +const OVERSCROLL_RESISTANCE = 0.3; +const BOUNCE_SPRING_CONFIG = { damping: 20, stiffness: 200 }; + +export function HorizontalScrollView() { + const scrollOffset = useSharedValue(0); + const maxScroll = CONTENT_WIDTH - VIEWPORT_WIDTH; + + const scrollGesture = Gesture.Scroll() + .onBegin((event) => { + 'worklet'; + console.log( + `[HorizontalScrollView] Scroll onBegin - handlerTag: ${event.handlerTag}` + ); + }) + .onUpdate((event) => { + 'worklet'; + const delta = -event.deltaX * SCROLL_SENSITIVITY; + const newOffset = scrollOffset.value + delta; + + if (newOffset < 0) { + scrollOffset.value = newOffset * OVERSCROLL_RESISTANCE; + } else if (newOffset > maxScroll) { + const overscroll = newOffset - maxScroll; + scrollOffset.value = maxScroll + overscroll * OVERSCROLL_RESISTANCE; + } else { + scrollOffset.value = newOffset; + } + }) + .onEnd(() => { + 'worklet'; + if (scrollOffset.value < 0) { + scrollOffset.value = withSpring(0, BOUNCE_SPRING_CONFIG); + } else if (scrollOffset.value > maxScroll) { + scrollOffset.value = withSpring(maxScroll, BOUNCE_SPRING_CONFIG); + } + }) + .onFinalize((event) => { + 'worklet'; + console.log( + `[HorizontalScrollView] Scroll onFinalize - handlerTag: ${event.handlerTag}, state: ${event.state}` + ); + }); + + const contentStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: -scrollOffset.value }], + })); + + const scrollbarStyle = useAnimatedStyle(() => { + const scrollbarWidth = (VIEWPORT_WIDTH / CONTENT_WIDTH) * VIEWPORT_WIDTH; + const scrollProgress = Math.max( + 0, + Math.min(1, scrollOffset.value / maxScroll) + ); + const scrollbarLeft = scrollProgress * (VIEWPORT_WIDTH - scrollbarWidth); + + return { + width: scrollbarWidth, + transform: [{ translateX: scrollbarLeft }], + opacity: interpolate( + Math.abs( + scrollOffset.value - + Math.max(0, Math.min(maxScroll, scrollOffset.value)) + ), + [0, 20], + [0.5, 0.8], + Extrapolation.CLAMP + ), + }; + }); + + const items = Array.from({ length: 15 }, (_, i) => ({ + id: i, + title: `Card ${i + 1}`, + color: `hsl(${(i * 24) % 360}, 70%, 60%)`, + })); + + return ( + + Gesture-Based Horizontal ScrollView + + Built entirely with Scroll gesture handler + + + + + + {items.map((item) => ( + + {item.id + 1} + {item.title} + + ))} + + + + + + + + + + ↔️ Scroll horizontally • Overscroll bounces back + + + ); +} + +const styles = StyleSheet.create({ + wrapper: { + marginVertical: 20, + }, + title: { + fontSize: 16, + fontWeight: '600', + color: '#333', + marginBottom: 2, + }, + subtitle: { + fontSize: 12, + color: '#888', + marginBottom: 12, + }, + scrollContainer: { + height: 140, + backgroundColor: '#fff', + borderRadius: 16, + overflow: 'hidden', + elevation: 4, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + }, + scrollContent: { + flexDirection: 'row', + paddingHorizontal: 12, + paddingVertical: 12, + gap: 12, + }, + card: { + width: 100, + height: 100, + borderRadius: 12, + justifyContent: 'center', + alignItems: 'center', + elevation: 2, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.15, + shadowRadius: 3, + }, + cardNumber: { + fontSize: 28, + fontWeight: 'bold', + color: '#fff', + opacity: 0.9, + }, + cardTitle: { + fontSize: 12, + color: '#fff', + marginTop: 4, + opacity: 0.8, + }, + scrollbarTrack: { + position: 'absolute', + left: 8, + right: 8, + bottom: 6, + height: 4, + backgroundColor: 'rgba(0,0,0,0.05)', + borderRadius: 2, + }, + scrollbarThumb: { + height: 4, + backgroundColor: 'rgba(108, 99, 255, 0.5)', + borderRadius: 2, + position: 'absolute', + left: 0, + }, + hint: { + fontSize: 11, + color: '#888', + textAlign: 'center', + marginTop: 8, + }, +}); 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 index 666fd3f378..c2e3b63b26 100644 --- a/apps/common-app/src/new_api/scroll/index.tsx +++ b/apps/common-app/src/new_api/scroll/index.tsx @@ -1,263 +1,9 @@ import React, { useState } from 'react'; -import { - View, - Text, - StyleSheet, - Platform, - Dimensions, - Switch, -} from 'react-native'; -import { Gesture, GestureDetector } from 'react-native-gesture-handler'; -import Animated, { - useAnimatedStyle, - useSharedValue, - withSpring, - interpolate, - Extrapolation, -} from 'react-native-reanimated'; +import { View, Text, StyleSheet, Platform, ScrollView } from 'react-native'; -const { height: SCREEN_HEIGHT } = Dimensions.get('window'); -const BOTTOM_SHEET_HEIGHT = SCREEN_HEIGHT * 0.5; -const COLLAPSED_TRANSLATE_Y = BOTTOM_SHEET_HEIGHT - 80; // Only show 80px when collapsed -const SCROLL_THRESHOLD = 2; // Amount of scroll needed to trigger expand/collapse -const SNAP_THRESHOLD = COLLAPSED_TRANSLATE_Y / 2; // Threshold for snapping to expanded/collapsed - -const DELTA_SCROLL_MULTIPLIER = 50; -const TOTAL_SCROLL_MULTIPLIER = 10; -const SCROLL_SCALE_MULTIPLIER = 1.05; - -function ScrollBottomSheet() { - const translateY = useSharedValue(COLLAPSED_TRANSLATE_Y); - const context = useSharedValue({ startY: 0 }); - - const springConfig = { - damping: 20, - stiffness: 90, - }; - - // Scroll gesture for mouse wheel / trackpad - const scrollGesture = Gesture.Scroll() - .onBegin((event) => { - 'worklet'; - console.log( - `[BottomSheet] Scroll onBegin - handlerTag: ${event.handlerTag}` - ); - }) - .onUpdate((event) => { - 'worklet'; - // Positive scrollY means scrolling up (away from user) -> expand - // Negative scrollY means scrolling down (toward user) -> collapse - if (event.scrollY > SCROLL_THRESHOLD && translateY.value > 0) { - translateY.value = withSpring(0, springConfig); - } else if ( - event.scrollY < -SCROLL_THRESHOLD && - translateY.value < COLLAPSED_TRANSLATE_Y - ) { - translateY.value = withSpring(COLLAPSED_TRANSLATE_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}` - ); - }); - - // Pan gesture for touch/drag - const panGesture = Gesture.Pan() - .onStart(() => { - 'worklet'; - context.value = { startY: translateY.value }; - }) - .onUpdate((event) => { - 'worklet'; - // Dragging down = positive translationY = increase translateY (collapse) - // Dragging up = negative translationY = decrease translateY (expand) - const newTranslateY = context.value.startY + event.translationY; - // Clamp between 0 (fully expanded) and COLLAPSED_TRANSLATE_Y (collapsed) - translateY.value = Math.max( - 0, - Math.min(COLLAPSED_TRANSLATE_Y, newTranslateY) - ); - }) - .onEnd((event) => { - 'worklet'; - // Snap to expanded or collapsed based on position and velocity - const shouldExpand = - translateY.value < SNAP_THRESHOLD || - (event.velocityY < -500 && translateY.value < COLLAPSED_TRANSLATE_Y); - - if (shouldExpand) { - translateY.value = withSpring(0, springConfig); - } else { - translateY.value = withSpring(COLLAPSED_TRANSLATE_Y, springConfig); - } - }); - - // Combine gestures - Race means the first gesture to activate wins - const gesture = Gesture.Race(scrollGesture, panGesture); - - const sheetStyle = useAnimatedStyle(() => ({ - transform: [{ translateY: translateY.value }], - })); - - const handleStyle = useAnimatedStyle(() => ({ - transform: [ - { - rotate: `${interpolate( - translateY.value, - [0, COLLAPSED_TRANSLATE_Y], - [180, 0], - Extrapolation.CLAMP - )}deg`, - }, - ], - })); - - const contentOpacity = useAnimatedStyle(() => ({ - opacity: interpolate( - translateY.value, - [0, COLLAPSED_TRANSLATE_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} - - ))} - - - - ); -} - -function ScrollBox({ - color, - title, - useDeltas, - useSpring, - onSpringChange, -}: { - color: string; - title: string; - useDeltas: boolean; - useSpring: boolean; - onSpringChange: (value: boolean) => void; -}) { - 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; - console.log( - `[${title}] Scroll: x=${event.scrollX.toFixed(2)}, y=${event.scrollY.toFixed(2)}, deltaX=${event.deltaX.toFixed(2)}, deltaY=${event.deltaY.toFixed(2)}` - ); - }) - .onEnd((event) => { - 'worklet'; - console.log( - `[${title}] Scroll onEnd - handlerTag: ${event.handlerTag}, state: ${event.state}, oldState: ${event.oldState}` - ); - }) - .onFinalize(() => { - 'worklet'; - isScrolling.value = false; - // Reset values - spring will be applied based on useSpring prop - 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; - - // Apply spring only when resetting (when not scrolling) - const targetX = isScrolling.value ? x : useSpring ? withSpring(0) : 0; - const targetY = isScrolling.value ? -y : useSpring ? 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 - - - - ); -} +import { ScrollBox } from './ScrollBox'; +import { HorizontalScrollView } from './HorizontalScrollView'; +import { BottomSheet } from './BottomSheet'; export default function ScrollExample() { const [totalScrollSpring, setTotalScrollSpring] = useState(true); @@ -279,9 +25,8 @@ export default function ScrollExample() { } return ( - + Scroll Gesture - Use your mouse wheel or trackpad to scroll over the elements below. @@ -312,21 +57,26 @@ export default function ScrollExample() { 💡 Tip: Check the console for scroll event logs + + 💡 Scroll or drag on the bottom sheet to expand/collapse it - - + + ); } const styles = StyleSheet.create({ container: { flex: 1, - padding: 20, backgroundColor: '#f5f5f5', }, + content: { + padding: 20, + paddingBottom: 40, + }, title: { fontSize: 24, fontWeight: 'bold', @@ -362,109 +112,10 @@ const styles = StyleSheet.create({ justifyContent: 'space-around', marginBottom: 20, }, - 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, - }, hint: { fontSize: 12, color: '#888', textAlign: 'center', marginTop: 20, }, - // Bottom Sheet styles - bottomSheet: { - position: 'absolute', - bottom: 0, - left: 0, - right: 0, - height: BOTTOM_SHEET_HEIGHT, - backgroundColor: '#fff', - borderTopLeftRadius: 24, - borderTopRightRadius: 24, - paddingHorizontal: 20, - elevation: 10, - shadowColor: '#000', - shadowOffset: { width: 0, height: -4 }, - shadowOpacity: 0.15, - shadowRadius: 8, - }, - handleContainer: { - alignItems: 'center', - paddingVertical: 12, - }, - handleArrow: { - marginBottom: 4, - }, - arrowText: { - fontSize: 16, - color: '#6C63FF', - }, - handleText: { - fontSize: 12, - color: '#888', - }, - sheetContent: { - flex: 1, - paddingBottom: 20, - }, - sheetTitle: { - fontSize: 18, - fontWeight: 'bold', - color: '#333', - marginBottom: 8, - }, - sheetDescription: { - fontSize: 14, - color: '#666', - marginBottom: 16, - lineHeight: 20, - }, - sheetItems: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 10, - }, - sheetItem: { - backgroundColor: '#f0f0ff', - paddingHorizontal: 16, - paddingVertical: 12, - borderRadius: 12, - }, - sheetItemText: { - color: '#6C63FF', - fontWeight: '600', - }, }); From f0b2688958a9d708a3d784130da051b2f908bb9d Mon Sep 17 00:00:00 2001 From: Jakub Kosmydel Date: Thu, 8 Jan 2026 13:32:07 +0100 Subject: [PATCH 5/5] remove the horizontal scroll demo for now --- .../src/new_api/scroll/BottomSheet.tsx | 10 +- .../new_api/scroll/HorizontalScrollView.tsx | 202 ------------------ apps/common-app/src/new_api/scroll/index.tsx | 70 +++--- 3 files changed, 40 insertions(+), 242 deletions(-) delete mode 100644 apps/common-app/src/new_api/scroll/HorizontalScrollView.tsx diff --git a/apps/common-app/src/new_api/scroll/BottomSheet.tsx b/apps/common-app/src/new_api/scroll/BottomSheet.tsx index 6f80be26d6..8008dc013b 100644 --- a/apps/common-app/src/new_api/scroll/BottomSheet.tsx +++ b/apps/common-app/src/new_api/scroll/BottomSheet.tsx @@ -136,14 +136,18 @@ export function BottomSheet() { const styles = StyleSheet.create({ sheet: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, height: SHEET_HEIGHT, backgroundColor: '#fff', - borderRadius: 24, + borderTopLeftRadius: 24, + borderTopRightRadius: 24, paddingHorizontal: 20, - marginTop: 20, elevation: 10, shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, + shadowOffset: { width: 0, height: -2 }, shadowOpacity: 0.15, shadowRadius: 8, }, diff --git a/apps/common-app/src/new_api/scroll/HorizontalScrollView.tsx b/apps/common-app/src/new_api/scroll/HorizontalScrollView.tsx deleted file mode 100644 index 2e6da73071..0000000000 --- a/apps/common-app/src/new_api/scroll/HorizontalScrollView.tsx +++ /dev/null @@ -1,202 +0,0 @@ -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 VIEWPORT_WIDTH = Dimensions.get('window').width - 40; -const CONTENT_WIDTH = 1200; -const SCROLL_SENSITIVITY = 15; -const OVERSCROLL_RESISTANCE = 0.3; -const BOUNCE_SPRING_CONFIG = { damping: 20, stiffness: 200 }; - -export function HorizontalScrollView() { - const scrollOffset = useSharedValue(0); - const maxScroll = CONTENT_WIDTH - VIEWPORT_WIDTH; - - const scrollGesture = Gesture.Scroll() - .onBegin((event) => { - 'worklet'; - console.log( - `[HorizontalScrollView] Scroll onBegin - handlerTag: ${event.handlerTag}` - ); - }) - .onUpdate((event) => { - 'worklet'; - const delta = -event.deltaX * SCROLL_SENSITIVITY; - const newOffset = scrollOffset.value + delta; - - if (newOffset < 0) { - scrollOffset.value = newOffset * OVERSCROLL_RESISTANCE; - } else if (newOffset > maxScroll) { - const overscroll = newOffset - maxScroll; - scrollOffset.value = maxScroll + overscroll * OVERSCROLL_RESISTANCE; - } else { - scrollOffset.value = newOffset; - } - }) - .onEnd(() => { - 'worklet'; - if (scrollOffset.value < 0) { - scrollOffset.value = withSpring(0, BOUNCE_SPRING_CONFIG); - } else if (scrollOffset.value > maxScroll) { - scrollOffset.value = withSpring(maxScroll, BOUNCE_SPRING_CONFIG); - } - }) - .onFinalize((event) => { - 'worklet'; - console.log( - `[HorizontalScrollView] Scroll onFinalize - handlerTag: ${event.handlerTag}, state: ${event.state}` - ); - }); - - const contentStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: -scrollOffset.value }], - })); - - const scrollbarStyle = useAnimatedStyle(() => { - const scrollbarWidth = (VIEWPORT_WIDTH / CONTENT_WIDTH) * VIEWPORT_WIDTH; - const scrollProgress = Math.max( - 0, - Math.min(1, scrollOffset.value / maxScroll) - ); - const scrollbarLeft = scrollProgress * (VIEWPORT_WIDTH - scrollbarWidth); - - return { - width: scrollbarWidth, - transform: [{ translateX: scrollbarLeft }], - opacity: interpolate( - Math.abs( - scrollOffset.value - - Math.max(0, Math.min(maxScroll, scrollOffset.value)) - ), - [0, 20], - [0.5, 0.8], - Extrapolation.CLAMP - ), - }; - }); - - const items = Array.from({ length: 15 }, (_, i) => ({ - id: i, - title: `Card ${i + 1}`, - color: `hsl(${(i * 24) % 360}, 70%, 60%)`, - })); - - return ( - - Gesture-Based Horizontal ScrollView - - Built entirely with Scroll gesture handler - - - - - - {items.map((item) => ( - - {item.id + 1} - {item.title} - - ))} - - - - - - - - - - ↔️ Scroll horizontally • Overscroll bounces back - - - ); -} - -const styles = StyleSheet.create({ - wrapper: { - marginVertical: 20, - }, - title: { - fontSize: 16, - fontWeight: '600', - color: '#333', - marginBottom: 2, - }, - subtitle: { - fontSize: 12, - color: '#888', - marginBottom: 12, - }, - scrollContainer: { - height: 140, - backgroundColor: '#fff', - borderRadius: 16, - overflow: 'hidden', - elevation: 4, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 8, - }, - scrollContent: { - flexDirection: 'row', - paddingHorizontal: 12, - paddingVertical: 12, - gap: 12, - }, - card: { - width: 100, - height: 100, - borderRadius: 12, - justifyContent: 'center', - alignItems: 'center', - elevation: 2, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.15, - shadowRadius: 3, - }, - cardNumber: { - fontSize: 28, - fontWeight: 'bold', - color: '#fff', - opacity: 0.9, - }, - cardTitle: { - fontSize: 12, - color: '#fff', - marginTop: 4, - opacity: 0.8, - }, - scrollbarTrack: { - position: 'absolute', - left: 8, - right: 8, - bottom: 6, - height: 4, - backgroundColor: 'rgba(0,0,0,0.05)', - borderRadius: 2, - }, - scrollbarThumb: { - height: 4, - backgroundColor: 'rgba(108, 99, 255, 0.5)', - borderRadius: 2, - position: 'absolute', - left: 0, - }, - hint: { - fontSize: 11, - color: '#888', - textAlign: 'center', - marginTop: 8, - }, -}); diff --git a/apps/common-app/src/new_api/scroll/index.tsx b/apps/common-app/src/new_api/scroll/index.tsx index c2e3b63b26..b76001e46a 100644 --- a/apps/common-app/src/new_api/scroll/index.tsx +++ b/apps/common-app/src/new_api/scroll/index.tsx @@ -2,7 +2,6 @@ import React, { useState } from 'react'; import { View, Text, StyleSheet, Platform, ScrollView } from 'react-native'; import { ScrollBox } from './ScrollBox'; -import { HorizontalScrollView } from './HorizontalScrollView'; import { BottomSheet } from './BottomSheet'; export default function ScrollExample() { @@ -25,46 +24,43 @@ export default function ScrollExample() { } 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) - - - - - - - - - 💡 Tip: Check the console for scroll event logs - + + + 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 - + + + + + + 💡 Scroll or drag on the bottom sheet to expand/collapse it + + - + ); }