From f2dcd89443b4b3e153d950d75c9b9987644400c8 Mon Sep 17 00:00:00 2001 From: Harry Yu Date: Thu, 13 Aug 2026 18:43:02 -0700 Subject: [PATCH 1/3] 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 0b9082233f61..7afb44464ced 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 @@ -84,6 +84,7 @@ internal class MaintainVisibleScrollPositionHelper( return } isListening = false + firstVisibleViewRef = null uIManager.removeUIManagerEventListener(this) } @@ -126,6 +127,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) @@ -133,14 +140,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 72580d9c0485..5e68863e1764 100644 --- a/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js +++ b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js @@ -14,7 +14,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, @@ -63,114 +63,153 @@ class EnableDisableList extends React.Component<{}, {scrollEnabled: boolean}> { } let AppendingListItemCount = 6; -class AppendingList extends React.Component< - {}, - {items: ReadonlyArray}, -> { - state: {items: ReadonlyArray} = { - 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))} + + +