Skip to content

Commit 843020f

Browse files
committed
Fix behavior on iOS too; add better handling for minIndex
1 parent 8676e52 commit 843020f

3 files changed

Lines changed: 199 additions & 19 deletions

File tree

‎packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-maintainVisibleContentPosition-itest.js‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,132 @@ test('maintainVisibleContentPosition with minIndexForVisible > 0 skips early ite
348348
expect(logs2.length).toBeGreaterThan(0);
349349
});
350350

351+
function renderItemWithZIndex(item: Item, height: number = ITEM_HEIGHT) {
352+
return (
353+
<View
354+
key={item.key}
355+
nativeID={`item_${item.id}`}
356+
style={{height, width: 100, zIndex: -item.id}}>
357+
<View
358+
nativeID={`inner_${item.id}`}
359+
style={{
360+
height: height - 2,
361+
width: 100 - 2,
362+
backgroundColor: '#4CAF50',
363+
}}
364+
/>
365+
</View>
366+
);
367+
}
368+
369+
// Trigger: z-index reorders native subviews so hierarchy index != layout order.
370+
// Expected: MVCP selects the topmost visible anchor by layout position, not hierarchy order.
371+
test('maintainVisibleContentPosition with zIndex preserves anchor on height change', () => {
372+
const root = Fantom.createRoot({
373+
viewportWidth: 100,
374+
viewportHeight: VIEWPORT_HEIGHT,
375+
});
376+
const nodeRef = createRef<HostInstance>();
377+
378+
const items = makeItems(NUM_ITEMS);
379+
const anchorId = 8;
380+
let anchorHeight = ITEM_HEIGHT;q
381+
382+
Fantom.runTask(() => {
383+
root.render(
384+
<ScrollView
385+
ref={nodeRef}
386+
style={{height: VIEWPORT_HEIGHT, width: 100}}
387+
maintainVisibleContentPosition={{minIndexForVisible: 0}}>
388+
{items.map(item =>
389+
item.id === anchorId
390+
? renderItemWithZIndex(item, anchorHeight)
391+
: renderItemWithZIndex(item),
392+
)}
393+
</ScrollView>,
394+
);
395+
});
396+
397+
root.takeMountingManagerLogs();
398+
399+
Fantom.scrollTo(nodeRef, {
400+
x: 0,
401+
y: ITEM_HEIGHT * anchorId,
402+
});
403+
404+
root.takeMountingManagerLogs();
405+
406+
anchorHeight = ITEM_HEIGHT * 2;
407+
408+
Fantom.runTask(() => {
409+
root.render(
410+
<ScrollView
411+
ref={nodeRef}
412+
style={{height: VIEWPORT_HEIGHT, width: 100}}
413+
maintainVisibleContentPosition={{minIndexForVisible: 0}}>
414+
{items.map(item =>
415+
item.id === anchorId
416+
? renderItemWithZIndex(item, anchorHeight)
417+
: renderItemWithZIndex(item),
418+
)}
419+
</ScrollView>,
420+
);
421+
});
422+
423+
const heightChangeLogs = root.takeMountingManagerLogs();
424+
expect(heightChangeLogs.length).toBeGreaterThan(0);
425+
expect(heightChangeLogs.some(log => log.includes(`item_${anchorId}`))).toBe(true);
426+
});
427+
428+
// Trigger: z-index reorders native subviews when minIndexForVisible > 0.
429+
// Expected: minIndex skip uses layout order, not native hierarchy order.
430+
test('maintainVisibleContentPosition with zIndex and minIndexForVisible > 0 skips early items', () => {
431+
const root = Fantom.createRoot({
432+
viewportWidth: 100,
433+
viewportHeight: VIEWPORT_HEIGHT,
434+
});
435+
const nodeRef = createRef<HostInstance>();
436+
437+
const items = makeItems(NUM_ITEMS);
438+
439+
Fantom.runTask(() => {
440+
root.render(
441+
<ScrollView
442+
ref={nodeRef}
443+
style={{height: VIEWPORT_HEIGHT, width: 100}}
444+
maintainVisibleContentPosition={{minIndexForVisible: 5}}>
445+
{items.map(renderItemWithZIndex)}
446+
</ScrollView>,
447+
);
448+
});
449+
450+
root.takeMountingManagerLogs();
451+
452+
Fantom.scrollTo(nodeRef, {
453+
x: 0,
454+
y: ITEM_HEIGHT * 8,
455+
});
456+
457+
root.takeMountingManagerLogs();
458+
459+
const itemsAfterPrepend = [...makeItems(3, NUM_ITEMS), ...items];
460+
461+
Fantom.runTask(() => {
462+
root.render(
463+
<ScrollView
464+
ref={nodeRef}
465+
style={{height: VIEWPORT_HEIGHT, width: 100}}
466+
maintainVisibleContentPosition={{minIndexForVisible: 5}}>
467+
{itemsAfterPrepend.map(renderItemWithZIndex)}
468+
</ScrollView>,
469+
);
470+
});
471+
472+
const prependLogs = root.takeMountingManagerLogs();
473+
expect(prependLogs.length).toBeGreaterThan(0);
474+
expect(prependLogs.some(log => log.includes('item_8'))).toBe(true);
475+
});
476+
351477
// Trigger: User actively dragging (touch-scrolling) when data change triggers MVCP.
352478
// Expected: MVCP correction may compete with user's scroll. Scroll skip guard on `patch/add-scrolling-guard`
353479
// branch would skip correction during user dragging, but this is NOT merged.

‎packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm‎

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1064,23 +1064,60 @@ - (void)_prepareForMaintainVisibleScrollPosition
10641064
}
10651065

10661066
BOOL horizontal = _scrollView.contentSize.width > self.frame.size.width;
1067-
int minIdx = props.maintainVisibleContentPosition.value().minIndexForVisible;
1068-
for (NSUInteger ii = minIdx; ii < _contentView.subviews.count; ++ii) {
1069-
// Find the first view that is partially or fully visible.
1070-
UIView *subview = _contentView.subviews[ii];
1071-
BOOL hasNewView = NO;
1072-
if (horizontal) {
1073-
hasNewView = subview.frame.origin.x + subview.frame.size.width > _scrollView.contentOffset.x;
1074-
} else {
1075-
hasNewView = subview.frame.origin.y + subview.frame.size.height > _scrollView.contentOffset.y;
1076-
}
1077-
if (hasNewView || ii == _contentView.subviews.count - 1) {
1078-
_prevFirstVisibleFrame = subview.frame;
1079-
_firstVisibleView = subview;
1080-
_firstVisibleViewTag = subview.tag;
1081-
break;
1067+
int minIndex = props.maintainVisibleContentPosition.value().minIndexForVisible;
1068+
NSArray<UIView *> *subviews =
1069+
minIndex > 0 ? [self _sortedContentSubviewsWithHorizontal:horizontal] : _contentView.subviews;
1070+
if ((NSUInteger)minIndex >= subviews.count) {
1071+
return;
1072+
}
1073+
1074+
CGFloat currentScroll = horizontal ? _scrollView.contentOffset.x : _scrollView.contentOffset.y;
1075+
UIView *firstVisibleView = nil;
1076+
// We cannot assume that the views will be in position order because of things like z-index
1077+
// which will change the order of views in their parent. This means we need to iterate through
1078+
// the full children array and find the view with the smallest position that is bigger than
1079+
// the scroll position.
1080+
CGFloat firstVisibleViewPosition = CGFLOAT_MAX;
1081+
for (NSUInteger index = minIndex; index < subviews.count; ++index) {
1082+
UIView *subview = subviews[index];
1083+
1084+
// Compute the position of the end of the child
1085+
CGFloat position = horizontal ? subview.frame.origin.x + subview.frame.size.width
1086+
: subview.frame.origin.y + subview.frame.size.height;
1087+
1088+
// If the child is partially visible or this is the last child, select it as the anchor.
1089+
if ((position > currentScroll && position < firstVisibleViewPosition) ||
1090+
(firstVisibleView == nil && index == subviews.count - 1)) {
1091+
firstVisibleView = subview;
1092+
firstVisibleViewPosition = position;
10821093
}
10831094
}
1095+
1096+
if (firstVisibleView == nil) {
1097+
return;
1098+
}
1099+
1100+
_prevFirstVisibleFrame = firstVisibleView.frame;
1101+
_firstVisibleView = firstVisibleView;
1102+
_firstVisibleViewTag = firstVisibleView.tag;
1103+
}
1104+
1105+
/// Returns scroll content subviews sorted by layout position along the scroll axis.
1106+
///
1107+
/// Call this only when minIndexForVisible > 0. In those cases, we need to skip in-order items, and
1108+
/// sorting allows us to do that.
1109+
- (NSArray<UIView *> *)_sortedContentSubviewsWithHorizontal:(BOOL)horizontal
1110+
{
1111+
NSArray<UIView *> *subviews = _contentView.subviews;
1112+
return [subviews sortedArrayUsingComparator:^NSComparisonResult(UIView *a, UIView *b) {
1113+
CGFloat offsetA = horizontal ? CGRectGetMinX(a.frame) : CGRectGetMinY(a.frame);
1114+
CGFloat offsetB = horizontal ? CGRectGetMinX(b.frame) : CGRectGetMinY(b.frame);
1115+
if (offsetA != offsetB) {
1116+
return offsetA < offsetB ? NSOrderedAscending : NSOrderedDescending;
1117+
}
1118+
// Stable ordering for ties.
1119+
return [@([subviews indexOfObject:a]) compare:@([subviews indexOfObject:b])];
1120+
}];
10841121
}
10851122

10861123
- (void)_adjustForMaintainVisibleContentPosition
@@ -1115,7 +1152,6 @@ - (void)_adjustForMaintainVisibleContentPosition
11151152

11161153
std::optional<int> autoscrollThreshold = props.maintainVisibleContentPosition.value().autoscrollToTopThreshold;
11171154
BOOL horizontal = _scrollView.contentSize.width > self.frame.size.width;
1118-
// TODO: detect and handle/ignore re-ordering
11191155
if (horizontal) {
11201156
CGFloat deltaX = _firstVisibleView.frame.origin.x - _prevFirstVisibleFrame.origin.x;
11211157
if (ABS(deltaX) > 0.5) {

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.kt‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,22 +125,40 @@ internal class MaintainVisibleScrollPositionHelper<ScrollViewT>(
125125
val scrollView = scrollView ?: return
126126
val contentView = contentView ?: return
127127

128+
val children = List(contentView.childCount) { contentView.getChildAt(it) }
129+
val subviews =
130+
if (config.minIndexForVisible > 0) {
131+
// We only need to sort if minIndexForVisible is non-zero. Normally, we just do a full
132+
// linear scan and find the earliest item.
133+
//
134+
// If minIndexForVisible is set though, we have to exclude the first `minIndexForVisible`
135+
// items, which requires sorting to do.
136+
children.sortedWith(
137+
compareBy<View>({ if (horizontal) it.x else it.y }, { contentView.indexOfChild(it) }))
138+
} else {
139+
children
140+
}
141+
142+
if (config.minIndexForVisible >= subviews.size) {
143+
return
144+
}
145+
128146
val currentScroll = if (horizontal) scrollView.scrollX else scrollView.scrollY
129147
var firstVisibleView: View? = null
130148
// We cannot assume that the views will be in position order because of things like z-index
131149
// which will change the order of views in their parent. This means we need to iterate through
132150
// the full children array and find the view with the smallest position that is bigger than
133151
// the scroll position.
134152
var firstVisibleViewPosition = Float.MAX_VALUE
135-
for (i in config.minIndexForVisible until contentView.childCount) {
136-
val child = contentView.getChildAt(i)
153+
for (i in config.minIndexForVisible until subviews.size) {
154+
val child = subviews[i]
137155

138156
// Compute the position of the end of the child
139157
val position = if (horizontal) child.x + child.width else child.y + child.height
140158

141159
// If the child is partially visible or this is the last child, select it as the anchor.
142160
if ((position > currentScroll && position < firstVisibleViewPosition) ||
143-
(firstVisibleView == null && i == contentView.childCount - 1)) {
161+
(firstVisibleView == null && i == subviews.size - 1)) {
144162
firstVisibleView = child
145163
firstVisibleViewPosition = position
146164
}

0 commit comments

Comments
 (0)