Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -1092,11 +1092,27 @@ - (void)_adjustForMaintainVisibleContentPosition
return;
}

if (ReactNativeFeatureFlags::enableViewCulling()) {
// Abort if the first visible view has changed (different tag)
if (_firstVisibleView && _firstVisibleView.tag != _firstVisibleViewTag) {
return;
}
// Abort if no first visible view (e.g., list was empty during mount)
if (!_firstVisibleView) {
return;
}

// Abort if the first visible view has been recycled for a different item.
// The tag was captured in _prepareForMaintainVisibleScrollPosition (before
// mounting), and RCTComponentViewRegistry assigns new tags during dequeue
// (mounting) and resets them to 0 during enqueue (unmounting). When items
// are removed and re-added, recycled views get new tags based on their
// position, so the view at position 0 may have a different tag than before.
// If the tag changed, we bail out to avoid applying the MVCP delta to the
// wrong view, which would produce incorrect scroll offsets.
if (_firstVisibleView.tag != _firstVisibleViewTag) {
return;
}

// Abort if the first visible view was deleted during mount (not recycled)
// This prevents MVCP from applying a delta after scrollToOffset(0) during reset/clear
if (_firstVisibleView.superview != _contentView) {
return;
}

std::optional<int> autoscrollThreshold = props.maintainVisibleContentPosition.value().autoscrollToTopThreshold;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import com.facebook.react.bridge.UiThreadUtil.runOnUiThread
import com.facebook.react.common.annotations.UnstableReactNativeAPI
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.common.UIManagerType
import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollEventThrottle
import com.facebook.react.views.scroll.ReactScrollViewHelper.HasSmoothScroll
import com.facebook.react.views.scroll.ReactScrollViewHelper.emitScrollEventNoThrottle
import com.facebook.react.views.view.ReactViewGroup
import java.lang.ref.WeakReference

Expand All @@ -31,7 +33,7 @@ import java.lang.ref.WeakReference
internal class MaintainVisibleScrollPositionHelper<ScrollViewT>(
private val scrollView: ScrollViewT,
private val horizontal: Boolean,
) : UIManagerListener where ScrollViewT : HasSmoothScroll?, ScrollViewT : ViewGroup? {
) : UIManagerListener where ScrollViewT : HasScrollEventThrottle?, ScrollViewT : HasSmoothScroll?, ScrollViewT : ViewGroup? {

var config: Config? = null
private var firstVisibleViewRef: WeakReference<View>? = null
Expand Down Expand Up @@ -98,6 +100,7 @@ internal class MaintainVisibleScrollPositionHelper<ScrollViewT>(
val scrollX = scrollView.scrollX
scrollView.scrollToPreservingMomentum(scrollX + deltaX, scrollView.scrollY)
this.prevFirstVisibleFrame = newFrame
emitScrollEventNoThrottle(scrollView, 0f, 0f)
if (config.autoScrollToTopThreshold != null && scrollX <= config.autoScrollToTopThreshold) {
scrollView.reactSmoothScrollTo(0, scrollView.scrollY)
}
Expand All @@ -108,6 +111,7 @@ internal class MaintainVisibleScrollPositionHelper<ScrollViewT>(
val scrollY = scrollView.scrollY
scrollView.scrollToPreservingMomentum(scrollView.scrollX, scrollY + deltaY)
this.prevFirstVisibleFrame = newFrame
emitScrollEventNoThrottle(scrollView, 0f, 0f)
if (config.autoScrollToTopThreshold != null && scrollY <= config.autoScrollToTopThreshold) {
scrollView.reactSmoothScrollTo(scrollView.scrollX, 0)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,17 @@ public object ReactScrollViewHelper {
@JvmStatic
public fun <T> emitScrollEvent(scrollView: T, xVelocity: Float, yVelocity: Float)
where T : HasScrollEventThrottle?, T : ViewGroup {
emitScrollEvent(scrollView, ScrollEventType.SCROLL, xVelocity, yVelocity)
emitScrollEvent(scrollView, ScrollEventType.SCROLL, xVelocity, yVelocity, false)
}

/**
* Emits a scroll event without throttling. Used by MVCP to ensure scroll position updates reach
* JS immediately when the scroll position is adjusted programmatically.
*/
@JvmStatic
public fun <T> emitScrollEventNoThrottle(scrollView: T, xVelocity: Float, yVelocity: Float)
where T : HasScrollEventThrottle?, T : ViewGroup {
emitScrollEvent(scrollView, ScrollEventType.SCROLL, xVelocity, yVelocity, true)
}

@JvmStatic
Expand Down Expand Up @@ -102,20 +112,22 @@ public object ReactScrollViewHelper {

private fun <T> emitScrollEvent(scrollView: T, scrollEventType: ScrollEventType)
where T : HasScrollEventThrottle?, T : ViewGroup {
emitScrollEvent(scrollView, scrollEventType, 0f, 0f)
emitScrollEvent(scrollView, scrollEventType, 0f, 0f, false)
}

private fun <T> emitScrollEvent(
scrollView: T,
scrollEventType: ScrollEventType,
xVelocity: Float,
yVelocity: Float,
skipThrottle: Boolean = false,
) where T : HasScrollEventThrottle?, T : ViewGroup {
val now = System.currentTimeMillis()
// Throttle the scroll event if scrollEventThrottle is set to be equal or more than 17 ms.
// We limit the delta to 17ms so that small throttles intended to enable 60fps updates will not
// inadvertently filter out any scroll events.
if (
!skipThrottle &&
scrollEventType == ScrollEventType.SCROLL &&
scrollView.scrollEventThrottle >= max(17, now - scrollView.lastScrollDispatchTime)
) {
Expand Down Expand Up @@ -274,9 +286,9 @@ public object ReactScrollViewHelper {
* by calculate the "would be" initial velocity with internal friction to move to the point (x,
* y), then apply that to the animator.
*/
@JvmStatic
public fun <T> smoothScrollTo(scrollView: T, x: Int, y: Int)
where T : HasFlingAnimator?, T : HasScrollState?, T : HasStateWrapper?, T : ViewGroup {
@JvmStatic
public fun <T> smoothScrollTo(scrollView: T, x: Int, y: Int)
where T : HasFlingAnimator?, T : HasScrollEventThrottle?, T : HasScrollState?, T : HasStateWrapper?, T : ViewGroup {
if (DEBUG_MODE) {
FLog.i(TAG, "smoothScrollTo[%d] x %d y %d", scrollView.id, x, y)
}
Expand Down Expand Up @@ -444,7 +456,7 @@ public object ReactScrollViewHelper {
}

public fun <T> registerFlingAnimator(scrollView: T)
where T : HasFlingAnimator?, T : HasScrollState?, T : HasStateWrapper?, T : ViewGroup {
where T : HasFlingAnimator?, T : HasScrollState?, T : HasStateWrapper?, T : HasScrollEventThrottle?, T : ViewGroup {
scrollView
.getFlingAnimator()
.addListener(
Expand All @@ -459,6 +471,8 @@ public object ReactScrollViewHelper {
scrollView.reactScrollViewScrollState.isFinished = true
notifyUserDrivenScrollEnded(scrollView)
updateFabricScrollState(scrollView)
// Dispatch an unthrottled scroll event to ensure JS state is updated after animation
emitScrollEventNoThrottle(scrollView, 0f, 0f)
}

override fun onAnimationCancel(animator: Animator) {
Expand Down
65 changes: 65 additions & 0 deletions packages/rn-tester/.maestro/flatlist-append-maintainvisible.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Test FlatList maintainVisibleContentPosition with append (baseline)
# Appending items should NOT affect scroll offset (delta ~0)
appId: ${APP_ID}
---
- launchApp
# Change to portrait
- setOrientation: portrait
- waitForAnimationToEnd:
timeout: 3000
# Find test
- assertVisible: "Components"
- scrollUntilVisible:
element:
id: "FlatList"
direction: DOWN
speed: 40
- tapOn:
id: "FlatList"
- scrollUntilVisible:
element:
id: "maintainVisibleContentPosition"
direction: DOWN
speed: 40
- tapOn:
id: "maintainVisibleContentPosition"
# Scroll to offset 500
- tapOn:
text: "ScrollToOffset 500"
- waitForAnimationToEnd:
timeout: 2000
# Append item (should NOT affect scroll offset)
- copyTextFrom:
id: "scroll-offset-display"
- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())}
- tapOn:
text: "Add 1 item at bottom"
- waitForAnimationToEnd:
timeout: 2000
- copyTextFrom:
id: "scroll-offset-display"
- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())}
- assertTrue: ${output.offsetAfter - output.offsetBefore >= -5 && output.offsetAfter - output.offsetBefore <= 5}
# Multiple appends
- copyTextFrom:
id: "scroll-offset-display"
- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())}
- tapOn:
text: "Add 1 item at bottom"
- waitForAnimationToEnd:
timeout: 2000
- copyTextFrom:
id: "scroll-offset-display"
- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())}
- assertTrue: ${output.offsetAfter - output.offsetBefore >= -5 && output.offsetAfter - output.offsetBefore <= 5}
- copyTextFrom:
id: "scroll-offset-display"
- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())}
- tapOn:
text: "Reset"
- waitForAnimationToEnd:
timeout: 2000
- copyTextFrom:
id: "scroll-offset-display"
- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())}
- assertTrue: ${output.offsetAfter >= 0 && output.offsetAfter <= 50}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Test FlatList maintainVisibleContentPosition — complex concurrent mutations
# Tests prepend + append + delete in sequence
appId: ${APP_ID}
---
- launchApp
- setOrientation: portrait
- waitForAnimationToEnd:
timeout: 3000
- assertVisible: "Components"
- scrollUntilVisible:
element:
id: "FlatList"
direction: DOWN
speed: 40
- tapOn:
id: "FlatList"
- scrollUntilVisible:
element:
id: "maintainVisibleContentPosition"
direction: DOWN
speed: 40
- tapOn:
id: "maintainVisibleContentPosition"
# Scroll to offset 200
- tapOn:
text: "ScrollToOffset 100"
- waitForAnimationToEnd:
timeout: 2000
# Record offset before mutations
- copyTextFrom:
id: "scroll-offset-display"
- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())}
# Add items at top and bottom (simulates complex mutations)
- tapOn:
text: "Add 1 item at top"
- waitForAnimationToEnd:
timeout: 2000
- tapOn:
text: "Add 1 item at bottom"
- waitForAnimationToEnd:
timeout: 2000
# Record offset after mutations
- copyTextFrom:
id: "scroll-offset-display"
- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())}
# Delta should be ~44px (only top prepend affects anchor)
- assertTrue: ${output.offsetAfter - output.offsetBefore >= 42 && output.offsetAfter - output.offsetBefore <= 46}
- tapOn:
text: "Reset"
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Test FlatList maintainVisibleContentPosition — delete anchor item
# When the anchor item (first visible) is deleted, MVCP should select a new anchor
appId: ${APP_ID}
---
- launchApp
- setOrientation: portrait
- waitForAnimationToEnd:
timeout: 3000
- assertVisible: "Components"
- scrollUntilVisible:
element:
id: "FlatList"
direction: DOWN
speed: 40
- tapOn:
id: "FlatList"
- scrollUntilVisible:
element:
id: "maintainVisibleContentPosition"
direction: DOWN
speed: 40
- tapOn:
id: "maintainVisibleContentPosition"
# Scroll to offset 200 (item 5 should be visible)
- tapOn:
text: "ScrollToOffset 100"
- waitForAnimationToEnd:
timeout: 2000
# Record offset before delete
- copyTextFrom:
id: "scroll-offset-display"
- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())}
# Clear the list (simulates delete of all items including anchor)
- tapOn:
text: "Clear (empty list)"
- waitForAnimationToEnd:
timeout: 2000
# Reset to restore items
- tapOn:
text: "Reset"
- waitForAnimationToEnd:
timeout: 2000
# Verify list is restored
- assertVisible: "0"
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Test FlatList maintainVisibleContentPosition — delete from middle
# When items are deleted from the middle, MVCP should adjust scroll offset
appId: ${APP_ID}
---
- launchApp
- setOrientation: portrait
- waitForAnimationToEnd:
timeout: 3000
- assertVisible: "Components"
- scrollUntilVisible:
element:
id: "FlatList"
direction: DOWN
speed: 40
- tapOn:
id: "FlatList"
- scrollUntilVisible:
element:
id: "maintainVisibleContentPosition"
direction: DOWN
speed: 40
- tapOn:
id: "maintainVisibleContentPosition"
# Scroll to offset 400 (item 10 should be visible)
- tapOn:
text: "ScrollToOffset 500"
- waitForAnimationToEnd:
timeout: 2000
# Record offset before delete
- copyTextFrom:
id: "scroll-offset-display"
- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())}
# Add items at top (shifts middle items up)
- tapOn:
text: "Add 3 items at top"
- waitForAnimationToEnd:
timeout: 2000
# Record offset after prepend
- copyTextFrom:
id: "scroll-offset-display"
- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())}
# Delta should be ~132px (3 items × 44px)
- assertTrue: ${output.offsetAfter - output.offsetBefore >= 128 && output.offsetAfter - output.offsetBefore <= 136}
- tapOn:
text: "Reset"
Loading