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()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc @sammy-SC is this check safe to remove?

// 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,6 +18,7 @@ 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.view.ReactViewGroup
import java.lang.ref.WeakReference
Expand All @@ -31,7 +32,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
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,11 +471,15 @@ 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cancellations should get the same treatment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Done.

}

override fun onAnimationCancel(animator: Animator) {
scrollView.reactScrollViewScrollState.isCanceled = true
notifyUserDrivenScrollEnded(scrollView)
// Dispatch an unthrottled scroll event to ensure JS state is updated after cancellation
emitScrollEventNoThrottle(scrollView, 0f, 0f)
}

override fun onAnimationRepeat(animator: Animator) = Unit
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

package com.facebook.react.views.scroll

import android.animation.ValueAnimator
import android.content.Context
import android.view.View
import android.view.ViewGroup
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.ReactTestHelper
import com.facebook.react.uimanager.StateWrapper
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.events.Event
import com.facebook.react.uimanager.events.EventDispatcher
import com.facebook.testutils.shadows.ShadowNativeLoader
import com.facebook.testutils.shadows.ShadowSoLoader
import com.facebook.react.views.scroll.ReactScrollViewHelper.HasFlingAnimator
import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollEventThrottle
import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollState
import com.facebook.react.views.scroll.ReactScrollViewHelper.HasStateWrapper
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.MockedStatic
import org.mockito.Mockito.`when`
import org.mockito.Mockito.mock
import org.mockito.Mockito.mockStatic
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.LooperMode
import org.robolectric.RuntimeEnvironment

/**
* Tests for [ReactScrollViewHelper.registerFlingAnimator], verifying that unthrottled scroll
* events are dispatched when fling animations end or are cancelled.
*
* These events ensure JS state is updated with the final scroll position after programmatic
* scroll animations complete, preventing stale scroll position data.
*/
@RunWith(RobolectricTestRunner::class)
@LooperMode(LooperMode.Mode.PAUSED)
@Config(shadows = [ShadowSoLoader::class, ShadowNativeLoader::class])
class ReactScrollViewHelperFlingAnimatorTest {

private lateinit var mockScrollView: MockScrollView
private lateinit var mockAnimator: ValueAnimator
private lateinit var mockChild: View
private lateinit var mockEventDispatcher: EventDispatcher
private lateinit var mockContext: ReactContext
private lateinit var uiManagerHelperMock: MockedStatic<UIManagerHelper>
private val scrollListener = TestScrollListener()

@Before
@Suppress("UNCHECKED_CAST")
fun setUp() {
mockChild = mock()
mockAnimator = ValueAnimator()
mockEventDispatcher = mock()
mockContext = ReactTestHelper.createCatalystContextForTest()

mockScrollView = mock<MockScrollView>()

`when`(mockScrollView.context).thenReturn(mockContext)
`when`(mockScrollView.id).thenReturn(42)
`when`(mockScrollView.scrollX).thenReturn(0)
`when`(mockScrollView.scrollY).thenReturn(0)
`when`(mockScrollView.width).thenReturn(500)
`when`(mockScrollView.height).thenReturn(800)
`when`(mockScrollView.paddingStart).thenReturn(0)
`when`(mockScrollView.paddingEnd).thenReturn(0)
`when`(mockScrollView.paddingTop).thenReturn(0)
`when`(mockScrollView.paddingBottom).thenReturn(0)
`when`(mockScrollView.scrollEventThrottle).thenReturn(0)
`when`(mockScrollView.lastScrollDispatchTime).thenReturn(0L)
`when`(mockScrollView.stateWrapper).thenReturn(null)
`when`(mockScrollView.reactScrollViewScrollState).thenReturn(
ReactScrollViewHelper.ReactScrollViewScrollState()
)
`when`(mockScrollView.getChildAt(0)).thenReturn(mockChild)
`when`(mockChild.width).thenReturn(1000)
`when`(mockChild.height).thenReturn(2000)
`when`(mockScrollView.getFlingAnimator()).thenReturn(mockAnimator)

uiManagerHelperMock = mockStatic(UIManagerHelper::class.java)
uiManagerHelperMock.`when`<ReactContext> { UIManagerHelper.getReactContext(any()) }
.thenReturn(mockContext)
uiManagerHelperMock.`when`<Int> { UIManagerHelper.getSurfaceId(any<Context>()) }
.thenReturn(1)
uiManagerHelperMock.`when`<EventDispatcher?> { UIManagerHelper.getEventDispatcher(any()) }
.thenReturn(mockEventDispatcher)

ReactScrollViewHelper.addScrollListener(scrollListener)
}

@After
fun tearDown() {
ReactScrollViewHelper.removeScrollListener(scrollListener)
uiManagerHelperMock.close()
}

@Test
fun registerFlingAnimator_emitsScrollEventOnAnimationEnd() {
ReactScrollViewHelper.registerFlingAnimator(mockScrollView)

mockAnimator.setIntValues(0, 100)
mockAnimator.start()
mockAnimator.end()

val captor = argumentCaptor<Event<*>>()
verify(mockEventDispatcher, org.mockito.kotlin.atLeast(1)).dispatchEvent(captor.capture())

val scrollEvents = captor.allValues.filterIsInstance<ScrollEvent>()
assert(scrollEvents.isNotEmpty())
assert(scrollEvents.all { it.eventName == "topScroll" })
}

@Test
fun registerFlingAnimator_emitsScrollEventOnAnimationCancel() {
ReactScrollViewHelper.registerFlingAnimator(mockScrollView)

mockAnimator.setIntValues(0, 100)
mockAnimator.start()
mockAnimator.cancel()

val captor = argumentCaptor<Event<*>>()
verify(mockEventDispatcher, org.mockito.kotlin.atLeast(1)).dispatchEvent(captor.capture())

val scrollEvents = captor.allValues.filterIsInstance<ScrollEvent>()
assert(scrollEvents.isNotEmpty())
assert(scrollEvents.all { it.eventName == "topScroll" })
}

@Test
fun registerFlingAnimator_onAnimationEnd_notifiesScrollListener() {
ReactScrollViewHelper.registerFlingAnimator(mockScrollView)

mockAnimator.setIntValues(0, 100)
mockAnimator.start()
mockAnimator.end()

assert(scrollListener.scrollEventType == ScrollEventType.SCROLL)
assert(scrollListener.xVelocity == 0f)
assert(scrollListener.yVelocity == 0f)
}

@Test
fun registerFlingAnimator_onAnimationCancel_notifiesScrollListener() {
ReactScrollViewHelper.registerFlingAnimator(mockScrollView)

mockAnimator.setIntValues(0, 100)
mockAnimator.start()
mockAnimator.cancel()

assert(scrollListener.scrollEventType == ScrollEventType.SCROLL)
assert(scrollListener.xVelocity == 0f)
assert(scrollListener.yVelocity == 0f)
}

private class TestScrollListener : ReactScrollViewHelper.ScrollListener {
var scrollEventType: ScrollEventType? = null
var xVelocity: Float = 0f
var yVelocity: Float = 0f

override fun onScroll(
scrollView: ViewGroup?,
scrollEventType: ScrollEventType?,
xVelocity: Float,
yVelocity: Float,
) {
this.scrollEventType = scrollEventType
this.xVelocity = xVelocity
this.yVelocity = yVelocity
}

override fun onLayout(scrollView: ViewGroup?) {
// no-op
}
}

private class MockScrollView :
ViewGroup(RuntimeEnvironment.getApplication()),
HasFlingAnimator,
HasScrollEventThrottle,
HasScrollState,
HasStateWrapper {

override var reactScrollViewScrollState =
ReactScrollViewHelper.ReactScrollViewScrollState()
override var scrollEventThrottle: Int = 0
override var lastScrollDispatchTime: Long = 0
override var stateWrapper: StateWrapper? = null
private val _animator: ValueAnimator = ValueAnimator()

override fun startFlingAnimator(start: Int, end: Int) {
_animator.setIntValues(start, end)
_animator.start()
}

override fun getFlingAnimator(): ValueAnimator = _animator

override fun getFlingExtrapolatedDistance(velocity: Int): Int = 0

init {
super.setLayoutParams(
ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
)
}

override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
// no-op
}
}
}
Loading