ScrollViewMarker:让 react-native-screens 知道哪个列表才是内容
UIKit 和 Material 都靠猜来判断哪个滚动视图是内容,而 React Native 的视图扁平化会让这个猜测悄悄失效。Software Mansion 新加的 ScrollViewMarker 把答案直接告诉屏幕,顺带修掉了 Android 头部的闪白。
中文
复制

有个 Liquid Glass 标签栏在滚动时不肯最小化?这篇就是为你写的。react-native-screens 发布了一个小组件 ScrollViewMarker,本文说明它为什么存在、解决了什么问题。文中也会讲到 Android 上 version 5 的新 Stack 头部。我们在实现它时撞上了同一类问题,所以你不必再踩一遍。
原生 UI 期望你的滚动视图在哪
原生导航 UI 会对滚动做出反应。在 iOS 上,大标题会收起,内容滚到导航栏下方后导航栏外观会变化。从 iOS 26 起,标签栏也能最小化;iOS 27 把这一行为扩展到了导航栏。在 Android 上,Material 应用栏会抬升。所有这些都取决于同一件事,这个屏幕的内容滚动视图到底是哪一块。
UIKit 靠“分析视图层级”来挑选滚动视图,这是 Apple 文档的说法。Apple 之外没人知道确切规则,但能观察到的只有一条,滚动视图必须位于第一后代路径上,而重排子视图是经典的解法。Android 的 Material AppBarLayout 有自己的一套,它会检查 CoordinatorLayout 的直接子视图里有没有可滚动的东西。至于 UIKit 不提供的那部分,react-native-screens 有自己的启发式规则。比如 v4 的 form sheet 实现必须把你的 ScrollView 调整到当前 detent 的高度。它的做法是逐层向下走第一个子视图,直到出现 UIScrollView,然后要求严格的形状,第一个子视图是可选的头部,第二个是滚动视图,不能有别的。
启发式规则五花八门,但共享同一个假设,列表是层级里的第一个东西。这不仅限制了你的屏幕布局,React Native 还很容易用视图扁平化把它打破。
视图扁平化在哪里打破了假设
在 RN 应用里,这个假设会以你从 JavaScript 实现中看不出来的方式被打破。一个只用 flex: 1 包着列表的 View,可能会被 React Native 扁平化掉,于是列表确实就是第一个原生子视图,一切正常。给同一个 View 加上 backgroundColor,它就会被实例化为原生视图;列表不再是第一个,标签栏也就悄悄停止最小化了。我们在 #3954 里收到了这个确切的报告。
失败是静默的。没有任何报错,效果就是不生效,然后你开始翻查视图层级。在此之前,我们一直建议要么把 ScrollView 保留为第一个子节点,要么在包装层上加 collapsable={false} 来关闭扁平化,要么带着一个包含更高级 ScrollView 查找逻辑的补丁。这些做法都能解决问题,但都强制要求严格的视图层级,还要求开发者掌握本不该操心的知识。
Apple 有现成的方案。UIKit 提供了 setContentScrollView(_:for:),文档中的原话是:“如果视图层级复杂,视图控制器可能无法选中合适的滚动视图来观察。”我们需要 React Native 里的对应实现。
显式标记滚动视图
看一个常见布局。列表上方有一条置顶消息栏,整体放在一个负责绘制背景的 View 里。在此之前,原生侧只能靠启发式规则自己去找那个列表。一个屏幕通常从单个列表开始,这种情况下启发式找起来毫无问题。然后屏幕变复杂了。列表上方多了一条置顶消息栏,包装层有了背景,中间的某个组件被扁平化或不再被扁平化。启发式规则能扛住其中一部分变化,但没有任何保证,而且规则失效时也不会告诉你。
// before: hope the heuristic finds it
<View style={styles.screen}>
<PinnedMessage />
<FlatList data={chats} renderItem={renderChat} />
</View>
ScrollViewMarker 用声明取代了这种碰运气。把滚动视图包起来,屏幕就知道哪个是内容:
// after: point which scroll view is the content
<View style={styles.screen}>
<PinnedMessage />
<ScrollViewMarker style={{ flex: 1 }}>
<FlatList data={chats} renderItem={renderChat} />
</ScrollViewMarker>
</View>
这个标记会把该滚动视图与最近的 Stack 或 Tabs 屏幕关联起来。嵌套容器会把它逐层传递,所以标签页里的堆栈同样适用。之后的行为在各平台上有所不同。
在 iOS 上,UIKit 现在会观察你标记的那个列表。导航栏和标签栏会在内容到达时切换外观,在 iOS 26+ 上标签栏还会最小化。这个标记还携带 scrollEdgeEffects,因此你可以在列表所在的位置直接为每条边选择边缘模糊的样式,在嵌套容器中以内层设置为准。

一开始没有任何标记。列表照常滚动,但标签栏始终完全展开,也没有任何东西告诉你原因。列表根本不在 first-descendant 路径上,固定的消息栏才在,所以 UIKit 永远不会把它选为内容滚动视图。
然后加上 ScrollViewMarker,别的什么都没变。再滚动一次,标签栏收成一个圆,里面是当前标签的图标。它一直保持收起,直到列表回到顶部才重新展开。这就是我们预期的原生最小化行为。标记只是告诉它该看哪里。
在 Android 上,small Stack 的头部该抬起时抬起,并且保持抬起。这是 liftOnScroll,默认开启。滚动到下方时被隐藏的头部,回来时是收起的,而不是突然弹开。

在 Android 上,同一个屏幕放进 version 5 的 Stack 里,再配一个 small Material 3 头部。头部会随内容滚走,一旦向上滚动就立刻滑回来。
列表在顶部时,头部应该是白色;内容滚到它下面时,立刻变蓝;中间没有过渡状态。Bug 出在头部滑回来的那一刻。不加标记时,向下滚动,头部消失,状态栏后面只剩一条蓝色。向上滚动,它滑回来。这时盯住它的背景。回来时是蓝色,掉成白色几帧,再动画回蓝色。这是 Material 在重新判断内容是否已滚动,而且判断错了,因为它找不到你的列表;下一次滚动事件又会把头部抬起来。下面 Android 一节解释原因。
加上标记之后,头部每次滑回来都是蓝色,并保持蓝色。只有列表回到顶部时它才变白,这才是正确的静止状态。
标记的预期
直接包住可滚动组件。标记只接受一个子组件。FlatList 或 SectionList 都算,因为 ScrollView 就是它们的根。每个 screen 只用一个标记;用了多个时,最后挂载的那个生效。标记在布局上就是一个普通 view,所以列表原来有的 flex: 1 要给它。
第三方列表也能用,只要标记的子组件是那个滚动 view。LegendList 的根节点渲染出来是 ScrollView,所以按 FlatList 的方式包。FlashList 会在它的 ScrollView 外面套一层 View,这种情况要通过 renderScrollComponent 把标记和滚动 view 一起交给它。
4.x 怎么办
在 4.x 里,scrollEdgeEffects 设在 screen 上,靠的是“第一个后代”启发式。为了兼容性它保持原样,在 4.x 上 collapsable={false} 仍然是诚实的答案。ScrollViewMarker 是 version 5 的功能。
工作原理
到这里,使用它需要的东西就都齐了。如果你好奇,下面讲的是底层发生了什么。
标记是一个原生组件,只有一个可滚动的子组件。它挂到 window 上时,会取出包住的滚动 view,沿 view 层级向上找到最近的 Stack screen 或 Tabs screen,把滚动 view 注册到那里。此后这个 screen 就知道自己的内容滚动 view 了。启发式还在,但只作为最后的兜底。screen 先取标记注册的,再取嵌套容器为当前 screen 报告的,最后才开始猜。
中间这一步正是嵌套得以成立的原因。承载堆栈的 tab 会去询问堆栈,堆栈去询问最上层的 screen,最上层的 screen 则以被标记的可滚动组件作答。容器在挂载时就会向父 screen 注册,整条链路因此自动接好。
iOS
注册的最后一步是对所有边缘调用 setContentScrollView(_:for:),于是 UIKit 直接观察你的列表,而不必再去搜索一个。重复点击 tab 时的滚动到顶部也走同一条嵌套链,用同样的方式解析出滚动视图。
这个标记在 iOS 26+ 上还有第二个用途。每个滚动视图边缘都有一个 UIScrollEdgeEffect,也就是内容在栏下方逐渐消隐所用的模糊效果。以前 scrollEdgeEffects 挂在 screen 上,再套用到启发式搜索找到的视图上,这意味着它可能悄无声息地失效。改用 ScrollViewMarker 之后,它作用于标记所包裹的那个滚动视图,不会再落空。
Android
version 5 里的 Stack 头部是一个 Material 3 AppBarLayout,而 small 头部会在内容滚动到其下方时抬升,也就是给背景着色。这就是 liftOnScroll,默认开启。Material 通过向目标视图询问 canScrollVertically(-1) 来决定是否抬升。你可以显式指定这个目标。如果不指定,Material 会使用当前正在嵌套滚动的那个视图,但它在每次布局以及 app bar 偏移量每次变化时,都会从 findFirstScrollingChild 重新计算状态,而后者只看 CoordinatorLayout 的直接子节点。在我们的 Stack 实现里,这个搜索永远找不到东西。CoordinatorLayout 只有一个内容子节点,就是我们用来包裹 screen 的一个普通 FrameLayout,而你的列表在它下面某处,所以无论你怎么组织视图,Material 都会判定内容没有滚动。
标记提供的正是这个显式目标。当一个 ScrollViewMarker 把它的滚动视图注册到 Stack screen 时,头部会把它交给 setLiftOnScrollTargetView,此后每次重新计算都会去询问你的列表。闪烁问题就是这样解决的。重新显示的头部是否应以收起状态回来,也由同一个滚动视图作答;Tabs 的滚动到顶部同样用它。
试用
ScrollViewMarker 已在 react-native-screens 5.0.0-alpha 中提供。想了解它是怎么拼起来的,可以看这几个 PR:组件本身、Tabs 在 iOS 和 Android 上的集成,以及 liftOnScroll。示例应用里还有几个场景,跑起来就能看到每种集成的效果。期待你的反馈。玩得开心!