From 8676e525ee1185512b20cba74244cf6c4d0901e2 Mon Sep 17 00:00:00 2001 From: Harry Yu Date: Thu, 13 Aug 2026 18:43:02 -0700 Subject: [PATCH] Fix Android MVCP anchor selection when z-index reorders children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Fabric reorders scroll content children due to z-index, the old computeTargetView logic picked the first child in hierarchy order whose end position exceeded the scroll offset. That could anchor to the wrong item, so height changes kept the bottom edge fixed instead of the top. Scan all children and select the topmost visible anchor instead. Extend the RNTester AppendingList example with negative z-index values and a "Change height at id" control to reproduce the bug. Test plan (Android): 1. Open RNTester → ScrollView → "smooth bi-directional content loading" 2. Add a few items and scroll so there are items above and below the current item 3. Use "Change height at id" to change the height of an item in the middle 4. Verify the top edge of that item stays in place, rather than the bottom edge jumping Co-authored-by: Cursor --- .../MaintainVisibleScrollPositionHelper.kt | 26 +- .../examples/ScrollView/ScrollViewExample.js | 270 +++++++++++------- 2 files changed, 182 insertions(+), 114 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.kt index cca66c7a7334..cdd0d8d2cf44 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.kt @@ -83,6 +83,7 @@ internal class MaintainVisibleScrollPositionHelper( return } isListening = false + firstVisibleViewRef = null uIManager.removeUIManagerEventListener(this) } @@ -125,6 +126,12 @@ internal class MaintainVisibleScrollPositionHelper( val contentView = contentView ?: return val currentScroll = if (horizontal) scrollView.scrollX else scrollView.scrollY + var firstVisibleView: View? = null + // We cannot assume that the views will be in position order because of things like z-index + // which will change the order of views in their parent. This means we need to iterate through + // the full children array and find the view with the smallest position that is bigger than + // the scroll position. + var firstVisibleViewPosition = Float.MAX_VALUE for (i in config.minIndexForVisible until contentView.childCount) { val child = contentView.getChildAt(i) @@ -132,14 +139,21 @@ internal class MaintainVisibleScrollPositionHelper( val position = if (horizontal) child.x + child.width else child.y + child.height // If the child is partially visible or this is the last child, select it as the anchor. - if (position > currentScroll || i == contentView.childCount - 1) { - firstVisibleViewRef = WeakReference(child) - val frame = Rect() - child.getHitRect(frame) - prevFirstVisibleFrame = frame - break + if ((position > currentScroll && position < firstVisibleViewPosition) || + (firstVisibleView == null && i == contentView.childCount - 1)) { + firstVisibleView = child + firstVisibleViewPosition = position } } + + if (firstVisibleView == null) { + return + } + + firstVisibleViewRef = WeakReference(firstVisibleView) + val frame = Rect() + firstVisibleView.getHitRect(frame) + prevFirstVisibleFrame = frame } // UIManagerListener diff --git a/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js index 11abb88cde7a..3e1b5a0b927d 100644 --- a/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js +++ b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js @@ -15,7 +15,7 @@ import RNTesterText from '../../components/RNTesterText'; import ScrollViewPressableStickyHeaderExample from './ScrollViewPressableStickyHeaderExample'; import nullthrows from 'nullthrows'; import * as React from 'react'; -import {cloneElement, useCallback, useRef, useState} from 'react'; +import {useCallback, useRef, useState} from 'react'; import { Platform, RefreshControl, @@ -62,114 +62,153 @@ class EnableDisableList extends React.Component<{}, {scrollEnabled: boolean}> { } let AppendingListItemCount = 6; -class AppendingList extends React.Component< - {}, - {items: Array>>}, -> { - state: {items: Array>>} = { - items: [...Array(AppendingListItemCount)].map((_, ii) => ( - - )), - }; - render(): React.Node { - return ( - - >(() => + [...Array(AppendingListItemCount)].map((_, ii) => ({ + id: ii, + })), + ); + + const renderItem = (item: ItemInfo, horizontal: boolean) => ( + + ); + + return ( + + + {items.map(item => renderItem(item, false))} + + + {items.map(item => renderItem(item, true))} + + +