You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React Native Expo自定义底部Sheet组件:解决内容超出最大SnapPoint时ScrollView无法触发滚动的手势冲突问题

React Native Expo自定义底部Sheet组件:解决内容超出最大SnapPoint时ScrollView无法触发滚动的手势冲突问题

Hey there! Let's fix this scroll conflict issue with your custom bottom sheet. The core problem here is that your PanGesture is intercepting all drag gestures—even when the inner ScrollView should take over once its content hits the edge. Let's break down the solution step by step.

Root Cause

When your sheet is at the maximum snap point (like 90% of the screen height), if the inner content is taller than the sheet's visible area, users expect to scroll the content first. But right now, your sheet's PanGesture catches every vertical drag, even when the ScrollView is still scrollable. We need to add logic that lets the ScrollView handle gestures when it can, and only let the sheet's drag take over when the ScrollView can't scroll anymore (like when it's scrolled all the way to the top or bottom, or the content doesn't exceed the sheet's height).

Modified Full Code

Here's the updated version of your component with all the fixes:

import React, { forwardRef, useImperativeHandle, useEffect, useState, useRef } from 'react';
import { Dimensions, TouchableWithoutFeedback, BackHandler } from 'react-native';
import { GestureDetector, Gesture, GestureHandlerScrollView } from 'react-native-gesture-handler';
import Animated, { 
  useSharedValue, 
  useAnimatedStyle, 
  withSpring, 
  withTiming, 
  runOnJS, 
  useAnimatedReaction,
  useAnimatedScrollHandler,
} from 'react-native-reanimated';

const { width, height } = Dimensions.get('window');

const MekBsheet = forwardRef(({ 
  children, 
  snapPoints = [50, 70, 90], 
  vertical = true, 
  animationConfig = { damping: 3, stiffness: 200 }, 
  animCloseConfig = { damping: 10, stiffness: 100, overshootClamping: true }, 
  animOpenConfig = { damping: 10, stiffness: 50, overshootClamping: true }, 
  style, 
  scrollViewProps, 
  sheetBgStyle, 
  initialSnapIndex = 0, 
  enableSwipeToOpen = true, 
  onChange = () => { }, 
  onOpen = () => { }, 
  onClose = () => { }, 
}, ref) => {
  const points = snapPoints
    .map(p => vertical ? height - height * (p / 100) : width - width * (p / 100))
    .sort((a, b) => a - b);

  const hiddenPosition = vertical ? height : width;
  const translate = useSharedValue(hiddenPosition);
  const startPos = useSharedValue(0);
  const isAnimating = useSharedValue(false);
  const [isActive, setIsActive] = useState(false);
  const [backdropVisible, setBackdropVisible] = useState(false);

  // NEW: ScrollView ref to track scroll state
  const scrollViewRef = useRef(null);
  // NEW: Track if ScrollView can scroll (content exceeds container, not at top/bottom)
  const isScrollViewScrollable = useSharedValue(false);
  const scrollOffset = useSharedValue(0);
  const contentHeight = useSharedValue(0);
  const containerHeight = useSharedValue(0);

  // NEW: Animated scroll handler to track scroll position and dimensions
  const scrollHandler = useAnimatedScrollHandler({
    onScroll: (event) => {
      scrollOffset.value = event.contentOffset.y;
    },
    onLayout: (event) => {
      containerHeight.value = event.layoutMeasurement.height;
      contentHeight.value = event.contentSize.height;
    },
  });

  // NEW: Update scrollable state when scroll position or dimensions change
  useAnimatedReaction(
    () => [scrollOffset.value, contentHeight.value, containerHeight.value],
    ([offset, contentH, containerH]) => {
      // Check if content is taller than container, and we're not at top/bottom
      const canScroll = contentH > containerH && (offset > 0 && offset < contentH - containerH);
      isScrollViewScrollable.value = canScroll;
    },
    []
  );

  const updateSnapState = (val) => {
    const closestIndex = points.reduce((closestIdx, point, idx, arr) => {
      if (closestIdx === -1) return idx;
      return Math.abs(point - val) < Math.abs(arr[closestIdx] - val) ? idx : closestIdx;
    }, -1);
    const snapIndex = closestIndex !== -1 ? closestIndex : null;

    runOnJS(onChange)(snapIndex);

    const isHidden = val >= hiddenPosition - 30;
    if (isHidden) {
      if (isActive) {
        runOnJS(setIsActive)(false);
      }
      runOnJS(onClose)();
    } else {
      if (!isActive) {
        runOnJS(setIsActive)(true);
        runOnJS(onOpen)();
      }
    }
  };

  useImperativeHandle(ref, () => ({
    open: (index = points.length - 1) => {
      const target = points[index];
      isAnimating.value = true;
      translate.value = withSpring(target, animOpenConfig, () => {
        isAnimating.value = false;
        runOnJS(updateSnapState)(target);
      });
    },
    close: () => {
      if (isAnimating.value) return;
      isAnimating.value = true;
      translate.value = withSpring(hiddenPosition, animCloseConfig, () => {
        isAnimating.value = false;
        runOnJS(updateSnapState)(hiddenPosition);
      });
    },
    snapTo: (index) => {
      const target = points[index];
      isAnimating.value = true;
      translate.value = withSpring(target, animationConfig, () => {
        isAnimating.value = false;
        runOnJS(updateSnapState)(target);
      });
    },
    getCurrentSnap: () => translate.value,
  }));

  // NEW: Modified PanGesture with scrollable check
  const panGesture = Gesture.Pan()
    .onStart(() => {
      startPos.value = translate.value;
    })
    .onUpdate(event => {
      // Only handle pan if ScrollView isn't scrollable
      if (isScrollViewScrollable.value) return;

      const delta = vertical ? event.translationY : event.translationX;
      const newVal = startPos.value + delta;
      const minVal = points[0];
      const maxVal = hiddenPosition;

      if (enableSwipeToOpen || translate.value < hiddenPosition - 10) {
        translate.value = Math.min(Math.max(newVal, minVal), maxVal);
      }
    })
    .onEnd(() => {
      if (isScrollViewScrollable.value) return;

      const current = translate.value;
      if (current > points[points.length - 1] + 60) {
        isAnimating.value = true;
        translate.value = withSpring(hiddenPosition, animCloseConfig, () => {
          isAnimating.value = false;
          runOnJS(updateSnapState)(hiddenPosition);
        });
        return;
      }

      const closest = points.reduce((prev, curr) => 
        Math.abs(curr - current) < Math.abs(prev - current) ? curr : prev 
      );
      isAnimating.value = true;
      translate.value = withSpring(closest, animationConfig, () => {
        isAnimating.value = false;
        runOnJS(updateSnapState)(closest);
      });
    });

  // NEW: Use Gesture.exclusive to let ScrollView gesture take priority
  const exclusiveGesture = Gesture.exclusive(
    Gesture.Native().simultaneousWithExternalGesture(), // ScrollView's built-in gesture
    panGesture
  );

  const animatedSheetStyle = useAnimatedStyle(() => ({
    transform: vertical ? [{ translateY: translate.value }] : [{ translateX: translate.value }],
  }));

  const backdropOpacity = useSharedValue(0);
  const animatedBgStyle = useAnimatedStyle(() => ({
    opacity: backdropOpacity.value,
  }));

  const handleBackdropPress = () => {
    if (isAnimating.value) return;
    isAnimating.value = true;
    translate.value = withSpring(hiddenPosition, animCloseConfig, () => {
      isAnimating.value = false;
      runOnJS(updateSnapState)(hiddenPosition);
    });
  };

  useEffect(() => {
    const initialPoint = points[initialSnapIndex];
    if (initialPoint === undefined) {
      console.warn('[MekBsheet] Warning: initialSnapIndex is out of bounds');
    }
    translate.value = hiddenPosition;
    setTimeout(() => {
      const point = initialPoint || hiddenPosition;
      isAnimating.value = true;
      translate.value = withSpring(point, animationConfig, () => {
        isAnimating.value = false;
        runOnJS(updateSnapState)(point);
      });
    }, 50);
  }, []);

  useEffect(() => {
    const onBackPress = () => {
      if (isActive && !isAnimating.value) {
        runOnJS(handleBackdropPress)();
        return true;
      }
      return false;
    };

    const backHandler = BackHandler.addEventListener('hardwareBackPress', onBackPress);
    return () => backHandler.remove();
  }, [isActive, isAnimating.value]);

  // Update backdrop opacity when sheet moves
  useAnimatedReaction(
    () => translate.value,
    (val) => {
      const visible = val < hiddenPosition - 30;
      runOnJS(setBackdropVisible)(visible);
      backdropOpacity.value = withTiming(visible ? 0.5 : 0, { duration: 200 });
    },
    []
  );

  return (
    <>
      {backdropVisible && (
        <TouchableWithoutFeedback onPress={handleBackdropPress}>
          <Animated.View
            style={[
              {
                position: 'absolute',
                top: 0,
                left: 0,
                right: 0,
                bottom: 0,
                backgroundColor: 'rgba(0,0,0,0.5)',
              },
              animatedBgStyle,
            ]}
          />
        </TouchableWithoutFeedback>
      )}
      <GestureDetector gesture={exclusiveGesture}>
        <Animated.View
          style={[
            {
              position: 'absolute',
              left: 0,
              right: 0,
              bottom: 0,
              backgroundColor: 'white',
            },
            sheetBgStyle,
            animatedSheetStyle,
            style,
          ]}
        >
          {/* NEW: Use GestureHandlerScrollView instead of native ScrollView for better gesture integration */}
          <GestureHandlerScrollView
            ref={scrollViewRef}
            {...scrollViewProps}
            scrollEventThrottle={16}
            onScroll={Animated.event(
              [{ nativeEvent: { contentOffset: { y: scrollOffset.value } } }],
              { useNativeDriver: true, listener: scrollViewProps?.onScroll }
            )}
            onLayout={Animated.event(
              [{ nativeEvent: { layoutMeasurement: { height: containerHeight.value }, contentSize: { height: contentHeight.value } } }],
              { useNativeDriver: false, listener: scrollViewProps?.onLayout }
            )}
          >
            {children}
          </GestureHandlerScrollView>
        </Animated.View>
      </GestureDetector>
    </>
  );
});

export default MekBsheet;

Key Changes Explained

Let's walk through the most important fixes:

  1. ScrollView State Tracking

    • We added scrollViewRef, along with shared values to track scroll position (scrollOffset), container height (containerHeight), and content height (contentHeight).
    • The useAnimatedScrollHandler and useAnimatedReaction work together to update isScrollViewScrollable—this value is true only when the content is taller than the ScrollView and we're not at the very top or bottom.
  2. Conditional Pan Gesture Handling

    • We added checks in the PanGesture's onUpdate and onEnd methods: if isScrollViewScrollable.value is true, the sheet ignores the drag gesture entirely, letting the ScrollView handle it.
  3. Gesture Priority with Gesture.exclusive

    • Using Gesture.exclusive ensures that the ScrollView's native scroll gesture takes precedence over the sheet's PanGesture. This is crucial because it prevents the sheet from stealing gestures before the ScrollView can respond.
  4. Replaced Native ScrollView with GestureHandlerScrollView

    • react-native-gesture-handler's ScrollView has better gesture integration, so we swapped the native ScrollView for it to ensure consistent behavior across devices.

Additional Notes

  • If you need to add a custom onScroll or onLayout to your ScrollView, we've already included logic to merge your custom handlers with the tracking logic—just pass them in scrollViewProps as usual.
  • Test your sheet with different content heights and snap points to make sure the gesture handoff works smoothly between the ScrollView and the sheet.
  • If you're using horizontal scrolling, you'll need to adjust the scroll tracking logic to use contentOffset.x instead of y.

内容来源于stack exchange

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.08 08:34:30