Android端嵌套BottomSheet的FlashList无法滚动问题
Android端自定义BottomSheet嵌套FlashList无法滚动问题
问题说明
仅Android端存在异常:将List组件嵌套进自定义BottomSheet组件后,其中的FlashList无法滚动;移除BottomSheet组件后滚动恢复正常,故问题与BottomSheet相关。iOS端无此问题。
使用方式
组件嵌套结构如下:
<GestureHandlerRootView style={{ height: '100%', width: '100%', position: 'absolute' }}> <View style={{ position: 'absolute', height: '100%', width: '100%', }}> <BottomSheet ref={ref}> <List /> </BottomSheet> </View> </GestureHandlerRootView>
相关组件代码
BottomSheet组件代码
import { Dimensions, StyleSheet, Text, View } from 'react-native'; import React, { useCallback, useEffect, useImperativeHandle } from 'react'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { Extrapolate, interpolate, useAnimatedStyle, useSharedValue, withSpring, withTiming, } from 'react-native-reanimated'; const { height: SCREEN_HEIGHT } = Dimensions.get('window'); const MAX_TRANSLATE_Y = -SCREEN_HEIGHT + 50; type BottomSheetProps = { children?: React.ReactNode; }; export type BottomSheetRefProps = { scrollTo: (destination: number) => void; isActive: () => boolean; }; const BottomSheet = React.forwardRef<BottomSheetRefProps, BottomSheetProps>( ({ children }, ref) => { const translateY = useSharedValue(0); const active = useSharedValue(false); const scrollTo = useCallback((destination: number) => { 'worklet'; active.value = destination !== 0; translateY.value = withSpring(destination, { damping: 50 }); }, []); const isActive = useCallback(() => { return active.value; }, []); useImperativeHandle(ref, () => ({ scrollTo, isActive }), [ scrollTo, isActive, ]); const context = useSharedValue({ y: 0 }); const gesture = Gesture.Pan() .onStart(() => { context.value = { y: translateY.value }; }) .onUpdate((event) => { translateY.value = event.translationY + context.value.y; translateY.value = Math.max(translateY.value, MAX_TRANSLATE_Y); }) .onEnd(() => { if (translateY.value > -SCREEN_HEIGHT / 3) { scrollTo(0); } else if (translateY.value < -SCREEN_HEIGHT / 1.5) { scrollTo(MAX_TRANSLATE_Y); } }); const rBottomSheetStyle = useAnimatedStyle(() => { const borderRadius = interpolate( translateY.value, [MAX_TRANSLATE_Y + 50, MAX_TRANSLATE_Y], [25, 5], Extrapolate.CLAMP ); return { borderRadius, transform: [{ translateY: translateY.value }], }; }); return ( <View> <GestureDetector gesture={gesture}> <Animated.View style={[styles.bottomSheetContainer, rBottomSheetStyle]}> <View style={styles.line} /> {children} </Animated.View> </GestureDetector> </View> ); } ); const styles = StyleSheet.create({ bottomSheetContainer: { height: SCREEN_HEIGHT, width: '100%', backgroundColor: 'black', position: 'absolute', top: SCREEN_HEIGHT, borderRadius: 25, }, line: { width: 75, height: 4, backgroundColor: 'grey', alignSelf: 'center', marginVertical: 15, borderRadius: 2, }, }); export default BottomSheet;
List组件代码
import { StyleSheet, Text, View } from 'react-native' import React from 'react' import { FlashList } from '@shopify/flash-list' const List = () => { const data = [ { id: '1', title: 'Item 1' }, { id: '2', title: 'Item 2' }, { id: '3', title: 'Item 1' }, { id: '243', title: 'Item 2' }, { id: '14dff3', title: 'Item 1' }, { id: '2564ewe36', title: 'Item 2' }, { id: '24ere3', title: 'Item 2' }, { id: '14drereff3', title: 'Item 1' }, { id: '25erer6436', title: 'Item 2' }, { id: '24rereer3', title: 'Item 2' }, { id: '14dferrf3', title: 'Item 1' }, { id: '2564er36', title: 'Item 2' } ]; return ( <View style={styles.container}> <View style={{ flex: 1, maxHeight: '80%', }}> <FlashList nestedScrollEnabled={true} data={data} estimatedItemSize={100} contentContainerStyle={{ padding: 37 }} renderItem={({ item }) => ( <> <Text style={styles.text}> React Native React Native React Native React Native React Native React Native React Native React Native React Native </Text> </> )} keyExtractor={(item) => item.id} /> </View> </View> ) } const styles = StyleSheet.create({ container: { backgroundColor: 'black', borderTopLeftRadius: 40, borderTopRightRadius: 40, height: '100%', width: '100%', position: 'absolute' }, text: { color: 'gray', fontWeight: '500', fontSize: 14, left: 15, textAlign: 'left', width: '90%', marginBottom: 20 }, }) export default List
复现步骤
在Android模拟器中,拖动列表区域尝试滚动,列表无响应;iOS端该操作正常。
环境信息
- Gesture Handler版本:2.12.1
- React Native版本:0.72.4
- 平台:Android
- JavaScript运行时:Hermes
- 开发模式:非Expo的React Native
- 架构:Fabric(新架构)
- 构建类型:调试模式
- 设备:Android模拟器(Google Pixel 4)
解决方案
问题根源是Android端手势冲突:BottomSheet的Pan手势拦截了FlashList的滚动手势,导致列表无法响应操作。以下是三种可行解决方式:
方法1:限制BottomSheet手势触发区域
将GestureDetector仅包裹顶部的拖拽条(line组件),只有拖动该区域时才触发BottomSheet滑动,列表区域的滚动手势不受影响。修改BottomSheet的返回部分:
return ( <Animated.View style={[styles.bottomSheetContainer, rBottomSheetStyle]}> <GestureDetector gesture={gesture}> <View style={styles.line} /> </GestureDetector> {children} </Animated.View> );
此方式最简单,适合仅通过顶部拖拽条控制BottomSheet的场景。
方法2:通过手势优先级配置解决冲突
给BottomSheet的Pan手势添加simultaneousWithExternalGesture,允许FlashList的滚动手势同时响应:
- 在List组件中给FlashList添加gestureRef:
// List组件内部 const listGestureRef = useRef(); // FlashList组件属性添加 <FlashList gestureRef={listGestureRef} // 其余属性不变 />
- 在BottomSheet的Pan手势中配置:
const gesture = Gesture.Pan() .simultaneousWithExternalGesture(listGestureRef) // 原有onStart、onUpdate、onEnd逻辑不变
方法3:动态判断是否处理BottomSheet手势
通过判断列表的滚动状态,仅当列表无法滚动时(处于顶部向下拖、底部向上拖)才处理BottomSheet的手势:
// BottomSheet中引入useRef获取列表引用,假设List组件暴露FlashList的ref const listRef = useRef(); const gesture = Gesture.Pan() .onStart((event) => { const scrollMetrics = listRef.current?.getScrollableMetrics(); if (!scrollMetrics) return; const canScrollVertical = scrollMetrics.contentSize.height > scrollMetrics.layoutMeasurement.height; const isAtTop = scrollMetrics.contentOffset.y === 0; const isAtBottom = scrollMetrics.contentOffset.y >= (scrollMetrics.contentSize.height - scrollMetrics.layoutMeasurement.height); // 仅当列表无法滚动或处于边界且拖动方向与滚动方向冲突时,才处理BottomSheet手势 if (!canScrollVertical || (isAtTop && event.translationY > 0) || (isAtBottom && event.translationY < 0)) { context.value = { y: translateY.value }; } }) .onUpdate((event) => { // 仅在允许处理手势时更新BottomSheet位置 const scrollMetrics = listRef.current?.getScrollableMetrics(); if (!scrollMetrics) return; const canScrollVertical = scrollMetrics.contentSize.height > scrollMetrics.layoutMeasurement.height; const isAtTop = scrollMetrics.contentOffset.y === 0; const isAtBottom = scrollMetrics.contentOffset.y >= (scrollMetrics.contentSize.height - scrollMetrics.layoutMeasurement.height); if (!canScrollVertical || (isAtTop && event.translationY > 0) || (isAtBottom && event.translationY < 0)) { translateY.value = event.translationY + context.value.y; translateY.value = Math.max(translateY.value, MAX_TRANSLATE_Y); } }) .onEnd(() => { // 仅在允许处理手势时执行收尾逻辑 const scrollMetrics = listRef.current?.getScrollableMetrics(); if (!scrollMetrics) return; const canScrollVertical = scrollMetrics.contentSize.height > scrollMetrics.layoutMeasurement.height; const isAtTop = scrollMetrics.contentOffset.y === 0; const isAtBottom = scrollMetrics.contentOffset.y >= (scrollMetrics.contentSize.height - scrollMetrics.layoutMeasurement.height); if (!canScrollVertical || (isAtTop && translateY.value > 0) || (isAtBottom && translateY.value < 0)) { if (translateY.value > -SCREEN_HEIGHT / 3) { scrollTo(0); } else if (translateY.value < -SCREEN_HEIGHT / 1.5) { scrollTo(MAX_TRANSLATE_Y); } } });
内容的提问来源于stack exchange,提问作者James
相关产品推荐
相关产品推荐

