ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

BottomSheet被列表误收起?Compose手势冲突的三种修复方案

BottomSheet被列表误收起?Compose手势冲突的三种修复方案 先说结论这个问题十有八九不是BottomSheet本身出bug而是你的列表和Sheet之间在“抢手势”。我是在做一个评论弹窗时踩进去的——用户在评论区里翻到第35条手指刚轻轻往下一拉整个BottomSheet“嗖”地一声缩回屏幕底部当场就愣住了。后来把日志打出来才发现问题根本不是“消失”而是BottomSheet被列表的滚动事件顺带收起或者反过来Sheet把本应属于LazyColumn的拖拽手势抢了过去。这个坑在Compose里非常典型尤其当你用Material3的ModalBottomSheet包一个LazyColumn做信息流、评价列表、筛选面板的时候。这篇文章会把现象、底层原因、三种解法、实测结论全部讲透适合那种“明明照着文档写却没跑出预期效果”的Compose开发者。1. 先分清问题BottomSheet是被“误收起”还是被“抢手势”动手修之前必须搞清楚一件事你遇到的“异常消失”到底是哪种形态。因为三种形态的原因完全不同瞎改代码只会更乱。1.1 三种常见表现表现A列表滑到顶部后继续下拉Sheet被拉下去最后收起。这是正常设计不是Bug。Material3的BottomSheet默认就允许这种联动用户从顶部继续下拉时收起。表现B列表还在中间位置比如停在第20条手指轻轻往下拉一点点Sheet整块缩走。这是异常行为属于Sheet意外消费了列表的滚动距离。表现C快速滑动列表fling后手指已经松开但Sheet因为惯性自己收起来了。这也是异常行为问题出在fling速度被错误传递给Sheet的锚点动画。我这篇文章主要解决B和C。A不用修修了反而违反用户直觉。1.2 最小复现代码下面这段代码就是最典型的触发器。看起来毫无问题实际上B、C两个异常都会出现。OptIn(ExperimentalMaterial3Api::class) Composable fun CommentSheet(showSheet: Boolean, onDismiss: () - Unit) { val sheetState rememberModalBottomSheetState() if (showSheet) { ModalBottomSheet( onDismissRequest onDismiss, sheetState sheetState ) { LazyColumn( modifier Modifier.fillMaxSize() ) { items(50) { index - CommentItem(index) } } } } }这个代码跑起来表现B的复现路径是把列表滚到中部然后以较快速度下拉列表Sheet会跟着缩。表现C的复现路径是快速上滑列表让列表产生惯性滚动放手后Sheet偶尔也会自动收起。很多人第一反应是给ModalBottomSheet加sheetState.hide()的判断逻辑或者监听currentValue做反向控制这些都属于“在错误层面打补丁”。先搞清楚传播链路再动手才是正路。2. 嵌套滚动与锚点拖拽它们是如何把列表和Sheet绑在一起的2.1 锚点机制Material3的ModalBottomSheetState底层是一个AnchoredDraggableState它维护三个锚点Expanded展开、PartiallyExpanded半展开、Hidden隐藏。Sheet的手势拖拽本质上就是在这几个锚点之间滑动切换。也就是说BottomSheet本身就是一个“可拖拽控件”它会监听手指的垂直拖拽。而LazyColumn也是一个“可滚动控件”它也监听垂直拖拽。同一个手势两个接收方这就是冲突的根源。2.2 一次下拉手势的传播链路Compose的嵌套滚动机制通过NestedScrollConnection把滚动事件层层分发。一条典型链路是手指在LazyColumn上下拉Scrollable组件优先处理。如果LazyColumn还能滚动手势距离由它自己消费。如果LazyColumn已经滚到边界无法继续消费剩下的滚动距离会通过NestedScrollDispatcher向上分发。父级的NestedScrollConnection这里就是Sheet内部的拖拽逻辑拿到剩余距离把Sheet往下拉。问题就出在第3步和第4步的衔接上。默认情况下LazyColumn滚到顶部后继续下拉的剩余距离会被Sheet消费这是A情况的正常路径。但在列表滚到一半时如果下拉手势产生了能触发拖拽判定的速度或距离Sheet的AnchoredDraggable也可能在竞争中被激活直接把整个Sheet拖走。还有一个很容易被忽略的细节Compose中多个手势检测器并发时谁能先越过touch slop手势触发阈值谁就赢得手势所有权。LazyColumn在滚动时Sheet的拖拽检测也在并行运行。正常滑动时列表的scrollable会先抢到手势但一旦遇到快速滚动产生的惯性阶段fling速度会通过NestedScrollSource.SideEffect分发给SheetSheet拿到向下的速度后就会朝着Hidden方向动画。2.3 真正的元凶根因可以归纳成一句话列表滚动到边界后剩余的位移和惯性速度没有被有意识地过滤而是被Sheet直接当成了拖拽指令。这也解释了为什么修复方案不是“禁用手势”那么简单——禁用所有手势会让Sheet失去正常的收起能力而是要精确控制什么情况下让Sheet接收滚动什么情况下不让。3. 方案一自定义NestedScrollConnection给Sheet发“边界通行证”这个方法是我个人最推荐的因为它保留了“下拉收起Sheet”的完整用户直觉同时把异常行为拦在源头。核心思路是只有在LazyColumn已经滚到顶部、且手势方向为向下的时候才允许滚动距离被Sheet消费。3.1 核心代码OptIn(ExperimentalMaterial3Api::class) Composable fun SafeCommentSheet(showSheet: Boolean, onDismiss: () - Unit) { val listState rememberLazyListState() val sheetState rememberModalBottomSheetState( skipPartiallyExpanded true ) val scope rememberCoroutineScope() val sheetBoundaryConnection remember(listState, sheetState) { object : NestedScrollConnection { override fun onPreScroll( available: Offset, source: NestedScrollSource ): Offset { val listAtTop listState.firstVisibleItemIndex 0 listState.firstVisibleItemScrollOffset 0 val pullingDown available.y 0 return if (listAtTop pullingDown) { available } else { Offset.Zero } } override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource ): Offset { val listAtTop listState.firstVisibleItemIndex 0 listState.firstVisibleItemScrollOffset 0 val pullingDown available.y 0 return if (listAtTop pullingDown) { Offset(0f, available.y) } else { Offset.Zero } } } } if (showSheet) { ModalBottomSheet( onDismissRequest { scope.launch { sheetState.hide() } onDismiss() }, sheetState sheetState ) { LazyColumn( state listState, modifier Modifier .fillMaxSize() .nestedScroll(sheetBoundaryConnection) ) { items(50) { index - CommentItem(index) } } } } }3.2 原理说明onPreScroll在子级消费之前调用onPostScroll在子级消费之后调用。我这里两个方法都实现目的是列表在顶部时onPreScroll直接返回available意味着Sheet提前拿走了整段下拉距离列表不会产生overscroll闪动。如果某些滚动距离还是从子级漏出来了onPostScroll会把余量二次分发给Sheet保证底部面板跟随手感不断裂。列表不在顶部时两个方法都返回Offset.Zero表示“我不碰这段滚动距离”LazyColumn正常滚动Sheet完全不动。这里有个很隐蔽的细节remember(listState, sheetState)里必须在object表达式内部读取listState.firstVisibleItemIndex不能在remember外面的作用域提前取。因为滚动状态是动态的NestedScrollConnection的回调里每次拿到的才是当时的最新值。3.3 适用场景方案一适合绝大多数“Sheet里有长列表用户希望下拉收起”的场景。它不会伤害手势直觉也不需要额外加关闭按钮属于最小侵入修复。4. 方案二关闭Sheet手势把拖拽主动权彻底交给列表如果你根本没打算让用户通过下拉手势收起Sheet那就不用费劲写NestedScrollConnection了直接关闭Sheet自身的拖拽手势。4.1 一行配置搞定Material3的ModalBottomSheet从1.1.0开始提供sheetGesturesEnabled参数默认是true。把它设成falseSheet就不会再参与任何手势竞争。OptIn(ExperimentalMaterial3Api::class) Composable fun SafeCommentSheetV2(showSheet: Boolean, onDismiss: () - Unit) { val sheetState rememberModalBottomSheetState() if (showSheet) { ModalBottomSheet( onDismissRequest onDismiss, sheetState sheetState, sheetGesturesEnabled false ) { LazyColumn( modifier Modifier.fillMaxSize() ) { items(50) { index - CommentItem(index) } } } } }就这么简单。关闭之后LazyColumn内部的滚动完全自主不再有任何Sheet拖拽逻辑来抢手势。列表滑到顶部继续下拉时会产生正常的overscroll效果Sheet纹丝不动。4.2 使用边界与代价这个方案最大缺点用户不能通过下拉手势收起Sheet只能靠点击遮罩、点关闭按钮或系统返回键。如果你的Sheet是一个完整的详情页、设置面板、评分页里面有明确的关闭按钮那完全没问题。但如果你的Sheet是那种轻量的通知面板用户习惯性往下拉关闭这个方案就会显得很生硬。另一个细节是关闭手势后sheetState.hide()依然可以正常调用点击遮罩触发的onDismissRequest也不受影响。所以开发成本极低适合“先止血、后优化”的紧急修复场景。5. 方案三用confirmValueChange拦住异常状态跃迁方案一负责“预防”接下来这个方案负责“兜底”。它解决的是表现C那种情况已经发生了快速滑动惯性速度已经传给Sheet状态正在向Hidden跳转。这时候动作再快也拦不住拖拽的过程但可以在状态真正切换之前踩一脚刹车。5.1 状态变更回调解读rememberModalBottomSheetState第三个参数是confirmValueChange: (SheetValue) - Boolean。每次Sheet尝试从当前锚点切换到目标锚点时这个回调都会被调用。返回true放行返回false拦下。我们可以利用它建立一条规则只有列表滚到顶部时才允许Sheet切到Hidden状态。OptIn(ExperimentalMaterial3Api::class) Composable fun SafeCommentSheetV3(showSheet: Boolean, onDismiss: () - Unit) { val listState rememberLazyListState() val sheetState rememberModalBottomSheetState( skipPartiallyExpanded true, confirmValueChange { value - val listAtTop listState.firstVisibleItemIndex 0 listState.firstVisibleItemScrollOffset 0 when (value) { SheetValue.Hidden - listAtTop else - true } } ) if (showSheet) { ModalBottomSheet( onDismissRequest onDismiss, sheetState sheetState ) { LazyColumn( state listState, modifier Modifier.fillMaxSize() ) { items(50) { index - CommentItem(index) } } } } }这段逻辑理解起来很直观如果列表不在顶部任何试图把Sheet切到Hidden的请求都会被拒绝。滑动惯性冲到一半状态切不过去Sheet就会自动回到Expanded。5.2 组合使用与局限单用方案三有一个明显手感问题快速下拉列表时Sheet被拦下过渡动画会“弹”回展开状态视觉上会有一瞬间的抽搐。所以我的建议是方案三不要单独用而是作为方案一的第二道保险。实际组合方式方案一处理逐帧滚动和手势分发方案三在状态层面兜底。两个一起上表现B和C都能被覆盖。还要注意一个常见误区很多人会在ConfirmValueChange里直接访问listState的最新值这没问题因为回调执行时读到的就是最新状态。但千万别在rememberModalBottomSheetState()初始化时就传入一个固定bool比如confirmValueChange { it SheetValue.Expanded }那样Sheet永远无法隐藏遮罩点击关闭也会失灵。6. 实测验证与那些容易上头的小坑6.1 实测效果对比我用的环境是Compose BOM 2023.10.01、Material3 1.1.2、targetSdk 34测试机型Pixel 7和一台小米13。复现场景是50条评论的列表复现路径为“快速上滑列表—松开—观察Sheet是否自动收起”。方案表现A顶部下拉收起表现B列表中部下拉触发收起表现C快速滑动惯性收起实现成本默认写法正常必现偶发0方案一NestedScrollConnection正常消失明显减少中方案二sheetGesturesEnabledfalse无此能力消失消失低方案三confirmValueChange正常偶发被拦截但伴随回弹低方案一 方案三正常消失消失中实测下来方案一加方案三是体验最完整的组合。方案二适合产品上本来就不需要下拉收起的场景省心省力。6.2 几个容易上头的小坑第一个坑不要用sheetState.isVisible做反向监听。我刚开始试图通过LaunchedEffect监听isVisible一旦发现异常收起就强制调expand()结果状态被反复拉扯直接抛出IllegalStateException死循环。正确入口只有confirmValueChange这是状态改变的合法拦截点。第二个坑内容不足一屏时LazyColumn根本没有滚动能力所有下拉手势天然全给Sheet看起来就像“列表没划两下Sheet就没了”。这种场景用方案三或skipPartiallyExpanded true都治标不治本最好把ModalBottomSheet的初始值设为SheetValue.Expanded同时给内容区加fillMaxHeight让列表有足够的滚动空间。第三个坑sheetGesturesEnabled false不会禁用点击遮罩关闭onDismissRequest照常触发。如果你不希望在禁用手势时还能点击遮罩关闭需要自己控制showSheet状态或者设置properties ModalBottomSheetProperties(shouldDismissOnBackPress false)配合处理。第四个坑代码里直接写scope.launch { sheetState.hide() }时如果confirmValueChange返回了falsehide()会卡住不执行。要确保关闭按钮的逻辑和confirmValueChange的规则不冲突否则会出现“点了关闭按钮没反应”的诡异现象。6.3 我个人现在的默认搭配做一个Sheet内嵌列表的需求时我现在的默认模板是方案一处理滚动边界方案三兜底状态异常skipPartiallyExpanded true消除半展开造成的视觉错乱。至于sheetGesturesEnabled只在产品明确说“不需要下拉关闭”时才设为false。最后补一个调试技巧在NestedScrollConnection的每个回调里打印available和consumed你能非常直观地看到手势距离是被谁消费掉的。我刚排查时就是靠这个确认了“列表在中间位置时onPostScroll的available依然冒到了Sheet层”。打印几轮日志你会对Compose嵌套滚动的传播机制有完全不一样的体感。
返回列表