React Native
Introducing ScrollViewMarker in react-native-screens
Hey! If you've encountered a Liquid Glass tab bar that refuses to minimize while you scroll, this one is for you.
react-native-screens ships a small component called ScrollViewMarker, and this post explains why it exists and what it fixes. It also covers the new Stack header on Android in version 5, where we ran into the same kind of problem while building it, so you won't have to.Where native UI expects your scroll view to be
Native navigation UI reacts to scrolling. On iOS, the large title collapses, and the navigation bars change their appearance once content scrolls under them. Since iOS 26, the tab bar can also minimize; iOS 27 extends that to the navigation bar as well. On Android, the Material app bar lifts. All of this needs one piece of information: which scroll view is the content scroll view of this screen.
UIKit picks the scroll view by "analyzing the view hierarchy", as Apple's documentation puts it. Nobody outside Apple knows the exact rule, but the observable part is that the scroll view has to be in the first-descendant path, and reordering subviews is the classic fix. Android's Material
AppBarLayout has its own version - it inspects the direct children of its CoordinatorLayout for something scrollable. And react-native-screens has heuristics of its own for what UIKit doesn't provide. The v4 form sheet implementation, for example, has to resize your ScrollView to the current detent: it does so by walking down the first subview at every level until a UIScrollView turns up, and then expects a strict shape - an optional header as the first child, the scroll view as the second, nothing else.There are many different heuristics, but one shared assumption: that the list is the first thing in the hierarchy. Not only does it restrict your screen layout, but React Native can easily break it with view flattening.
Where view flattening breaks the assumption
In a RN app, that assumption breaks in ways you can't see from the JavaScript implementation. A
View with just flex: 1 around your list might get flattened away by React Native, so the list really is the first native child and everything works. Style the same View with a backgroundColor, and it is materialized as a native view; the list is no longer first, and the tab bar quietly stops minimizing. We got this exact report in #3954 .The failure is silent. Nothing throws; the effect doesn't happen, and you start digging through your view hierarchy. Up until now, we always recommended to either keep the
ScrollView as the first child, add collapsable={false} on the wrapper to disable the flattening, or carry a patch with more advanced ScrollView search logic. While all of them resolved the problem, they forced a strict view hierarchy and required knowledge that no developer should have to worry about.Apple has a solution. UIKit has
setContentScrollView(_:for:), documented with the sentence "If the view hierarchy is complex, the view controller might not select the appropriate scroll view to observe". We needed the equivalent in React Native.Marking the scroll view explicitly
Take a common layout - a pinned message bar above a list, inside a
View that paints the background. Until now, the native side had to find that list on its own, with heuristics. A screen usually starts as a lone list, which they find without any problems. Then the screen grows, a pinned message bar lands above the list, the wrapper gets a background, a component in between gets flattened or stops being flattened. The heuristics survive some of these changes, but nothing guarantees it, and nothing told you when they didn't.// before: hope the heuristic finds it
<View style={styles.screen}>
<PinnedMessage />
<FlatList data={chats} renderItem={renderChat} />
</View>ScrollViewMarker replaces that hope with a declaration. Wrap the scroll view, and the screen knows which one is the content:// after: point which scroll view is the content
<View style={styles.screen}>
<PinnedMessage />
<ScrollViewMarker style={{ flex: 1 }}>
<FlatList data={chats} renderItem={renderChat} />
</ScrollViewMarker>
</View>The marker integrates that scroll view with the nearest Stack or Tabs screen. Nested containers pass it along, so it also works for a stack inside a tab. What happens from there is different on each platform.
On iOS, UIKit now watches the list you marked. The navigation bar and the tab bar switch their appearance when the content reaches them, and on iOS 26+ the tab bar minimizes. The marker also carries
scrollEdgeEffects, so you pick the style of the edge blur for each edge right where the list is, and in nested containers the innermost setting wins.At first there is no marker. The list scrolls as it should, but the tab bar stays fully expanded and nothing tells you why. The list is simply not on the first-descendant path, the pinned message bar is, so UIKit never picks it as the content scroll view.
Then the
ScrollViewMarker go in, and nothing else changes. Scroll again and the tab bar collapses into a single circle with the active tab's icon. It stays collapsed until the list is back at the top, then expands again. That's native minimization behavior we expect. The marker only told it where to look.On Android, the
small Stack header lifts when it should and stays lifted. That's liftOnScroll, on by default. A header that was hidden while you were scrolled down comes back collapsed instead of jumping open.The same screen on Android, inside a v5 Stack with a
small Material 3 header. The header scrolls away with the content and slides back in the moment you scroll up.You should expect a white header while the list is at the top, a blue one as soon as content is scrolled beneath it, and nothing in between. The bug occurs in the moment the header slides back in. Without the marker, scroll down and the header goes away, leaving only the blue strip behind the status bar. Scroll up and it slides back. Watch its background right then: it comes back blue, drops to white for a few frames, and animates back to blue. That's Material re-checking whether the content is scrolled and getting the wrong answer, because it cannot find your list; the next scroll event lifts the header again. The Android section below explains why.
With the marker in place, the header slides back blue and stays blue, every time. Only when the list is back at the top does it turn white, which is the correct resting state.
What the marker expects
Wrap the scrollable directly. The marker expects a single child.
FlatList or SectionList count, since a ScrollView is their root. Use one marker per screen; with several, the last one to attach wins. The marker is a plain view for layout purposes, so give it the flex: 1 your list used to have.Third-party lists work too, as long as the marker's child is the scroll view.
LegendList renders a ScrollView as its root, so you wrap it like a FlatList. FlashList puts a View around its ScrollView, so hand it the marker together with the scroll view through renderScrollComponent instead.What about 4.x
In 4.x,
scrollEdgeEffects is set on the screen and relies on the first-descendant heuristic. It stays as is for compatibility, and collapsable={false} remains the honest answer there. ScrollViewMarker is a version 5 feature.How it works
That's everything you need to use it. The rest of this post is about what happens underneath, if you're curious.
The marker is a native component with exactly one scrollable child. When it attaches to the window, it takes the wrapped scroll view and walks up the view hierarchy to the nearest Stack screen or Tabs screen, and registers the scroll view there. From then on that screen knows its content scroll view. The heuristic is still around, but only as the last fallback: a screen first takes what the marker registered, then whatever a nested container reports for its current screen, and only then starts guessing.
That middle step is what makes nesting work. A tab that hosts a stack asks the stack; the stack asks the screen on top, and the top screen answers with the marked scrollable. Containers just register with their parent screen as soon as they mount, so the chain wires itself up.
iOS
Registration ends in a call to
setContentScrollView(_:for:) for all edges, so UIKit observes your list directly instead of searching for one. Our scroll-to-top on repeated tab selection resolves the scroll view the same way, through the whole nesting chain.The marker has a second job on iOS 26+, where every scroll view edge has a
UIScrollEdgeEffect - the blur that content dissolves into under the bars. scrollEdgeEffects used to live on the screen and were applied to whatever the heuristic found, which meant it may silently fail. With ScrollViewMarker, it is applied to the scroll view the marker wraps, so it can't miss.Android
The Stack header in version 5 is a Material 3
AppBarLayout, and the small header lifts, that is, tints its background, once content scrolls beneath it. That's liftOnScroll, enabled by default. Material decides whether to lift by asking a target view canScrollVertically(-1). You can set that target explicitly. If you don't, Material uses whichever view is currently nested-scrolling, but on every layout pass and every change of the app bar's offset it recomputes the state from findFirstScrollingChild, which only looks at direct children of the CoordinatorLayout. In our Stack implementation, that search never finds anything. The CoordinatorLayout has a single content child, a plain FrameLayout we wrap the screen in, and your list sits somewhere below it, so Material concludes the content is not scrolled no matter how you arrange your views.That explicit target is what the marker provides. When a
ScrollViewMarker registers its scroll view with the Stack screen, the header hands it to setLiftOnScrollTargetView, and from then on every recomputation asks your list. That is what resolves the flicker. The same scroll view answers whether a re-shown header should come back collapsed, and Tabs use it for scroll-to-top.Try it
ScrollViewMarker is available in react-native-screens 5.0.0-alpha. If you're curious how it came together, have a look at the PRs: the component itself, the Tabs integration on iOS and Android, and liftOnScroll. Our example app also has a few scenarios you can run to see each integration. We're waiting for your feedback. Enjoy!
