From fec597ba9fb51628b04206fb215ff9156084f535 Mon Sep 17 00:00:00 2001 From: Kunal Das Date: Fri, 11 Sep 2026 00:42:33 +0530 Subject: [PATCH] Share explicit scroll snap target selection across Android and iOS --- .github/workflows/test-kmp.yml | 12 + .../ScrollView/RCTEnhancedScrollView.mm | 39 +++ .../views/scroll/ReactHorizontalScrollView.kt | 59 ++-- .../views/scroll/ReactNestedScrollView.kt | 53 ++-- .../react/views/scroll/ReactScrollView.kt | 53 ++-- .../scroll/ReactScrollSnapOffsetsTest.kt | 295 ++++++++++++++++++ packages/react-native/ReactShared/README.md | 24 +- .../scripts/test-android-consumers.py | 5 +- .../ReactShared/scripts/test-apple-app.sh | 1 + .../scripts/test-apple-scroll-snap.py | 184 +++++++++++ .../react/shared/ScrollSnapOffsets.kt | 131 ++++++++ .../react/shared/ScrollSnapOffsetsTest.kt | 83 +++++ .../tests/AppleScrollSnapParity.mm | 181 +++++++++++ 13 files changed, 1048 insertions(+), 72 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollSnapOffsetsTest.kt create mode 100755 packages/react-native/ReactShared/scripts/test-apple-scroll-snap.py create mode 100644 packages/react-native/ReactShared/src/commonMain/kotlin/com/facebook/react/shared/ScrollSnapOffsets.kt create mode 100644 packages/react-native/ReactShared/src/commonTest/kotlin/com/facebook/react/shared/ScrollSnapOffsetsTest.kt create mode 100644 packages/react-native/ReactShared/tests/AppleScrollSnapParity.mm diff --git a/.github/workflows/test-kmp.yml b/.github/workflows/test-kmp.yml index a8c91f29eb2e..b29eb07c2dd5 100644 --- a/.github/workflows/test-kmp.yml +++ b/.github/workflows/test-kmp.yml @@ -34,6 +34,9 @@ on: - 'packages/react-native/ReactCommon/react/utils/ManagedObjectWrapper.*' - 'packages/react-native/React/React-RCTFabric.podspec' - 'packages/react-native/React-Core.podspec' + - 'packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/**' + - 'packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/**' + - 'packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/**' - 'packages/react-native/React/Base/RCTMultipartStreamReader.*' - 'packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt' - 'packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt' @@ -78,6 +81,9 @@ on: - 'packages/react-native/ReactCommon/react/utils/ManagedObjectWrapper.*' - 'packages/react-native/React/React-RCTFabric.podspec' - 'packages/react-native/React-Core.podspec' + - 'packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/**' + - 'packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/**' + - 'packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/**' - 'packages/react-native/React/Base/RCTMultipartStreamReader.*' - 'packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt' - 'packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt' @@ -155,6 +161,10 @@ jobs: run: | ./scripts/test-apple-multipart.sh python3 scripts/test-android-multipart.py --max-workers 2 + - name: Compare native and shared scroll snap adapters + if: ${{ !cancelled() && steps.shared.outcome == 'success' }} + working-directory: packages/react-native/ReactShared + run: python3 scripts/test-apple-scroll-snap.py --output "$RUNNER_TEMP/apple-scroll-snap" --benchmark-repeats 0 - name: Test packaged XCFramework consumption if: ${{ !cancelled() && steps.shared.outcome == 'success' }} working-directory: packages/react-native/ReactShared @@ -179,6 +189,8 @@ jobs: path: | packages/react-native/ReactShared/build/reports/tests packages/react-native/ReactShared/build/test-results + ${{ runner.temp }}/apple-scroll-snap/**/*.json + ${{ runner.temp }}/apple-scroll-snap/**/*.log packages/react-native/ReactShared/build/apple-multipart-test/**/*.log packages/react-native/ReactShared/build/android-multipart-test/build/test-results packages/react-native/ReactShared/build/apple-distribution/**/*.json diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm index b3481c1b98b4..3440d3799ca3 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm @@ -7,15 +7,44 @@ #import "RCTEnhancedScrollView.h" #import +#import #import +#if RCT_USE_KMP && TARGET_OS_IOS && !TARGET_OS_MACCATALYST +#define RCT_SCROLL_SNAP_USE_KMP 1 +#import +#else +#define RCT_SCROLL_SNAP_USE_KMP 0 +#endif + @interface RCTEnhancedScrollView () @end @implementation RCTEnhancedScrollView { __weak id _publicDelegate; BOOL _isSetContentOffsetDisabled; +#if RCT_SCROLL_SNAP_USE_KMP + RNSScrollSnapOffsets *_sharedSnapOffsets; +#endif +} + +#if RCT_SCROLL_SNAP_USE_KMP +@synthesize snapToOffsets = _snapToOffsets; + +- (void)setSnapToOffsets:(NSArray *)snapToOffsets +{ + _snapToOffsets = [snapToOffsets copy]; + _sharedSnapOffsets = nil; + if (_snapToOffsets.count > 0) { + // Convert once per property update, retaining the existing floatValue precision. + RNSKotlinDoubleArray *offsets = [RNSKotlinDoubleArray arrayWithSize:(int32_t)_snapToOffsets.count]; + for (NSUInteger i = 0; i < _snapToOffsets.count; i++) { + [offsets setIndex:(int32_t)i value:_snapToOffsets[i].floatValue]; + } + _sharedSnapOffsets = [[RNSScrollSnapOffsets alloc] initWithOffsets:offsets]; + } } +#endif + (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key { @@ -199,6 +228,15 @@ - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView // Calculate the snap offsets adjacent to the initial offset target CGFloat targetOffset = isHorizontal ? targetContentOffset->x : targetContentOffset->y; +#if RCT_SCROLL_SNAP_USE_KMP + targetOffset = [_sharedSnapOffsets resolveCurrentOffset:offsetAlongAxis + targetOffset:targetOffset + maximumOffset:maximumOffset + velocity:velocityAlongAxis + snapToStart:self.snapToStart + snapToEnd:self.snapToEnd] + .targetOffset; +#else CGFloat smallerOffset = 0.0; CGFloat largerOffset = maximumOffset; @@ -250,6 +288,7 @@ - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView // Make sure the new offset isn't out of bounds targetOffset = MIN(MAX(0, targetOffset), maximumOffset); +#endif // Set new targetContentOffset if (isHorizontal) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt index 1bfb84f45368..1000cad9cf66 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt @@ -35,6 +35,8 @@ import com.facebook.react.R import com.facebook.react.common.ReactConstants import com.facebook.react.common.build.ReactBuildConfig import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags +import com.facebook.react.shared.ScrollSnapDirection +import com.facebook.react.shared.ScrollSnapOffsets import com.facebook.react.uimanager.BackgroundStyleApplicator import com.facebook.react.uimanager.HasChildPressedStateDelay import com.facebook.react.uimanager.LengthPercentage @@ -169,6 +171,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : private var disableIntervalMomentum = false private var snapInterval = 0 private var snapOffsets: List? = null + private var sharedSnapOffsets: ScrollSnapOffsets? = null private var snapToStart = true private var snapToEnd = true private var snapToAlignment = SNAP_ALIGNMENT_DISABLED @@ -277,6 +280,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : disableIntervalMomentum = false snapInterval = 0 snapOffsets = null + sharedSnapOffsets = null snapToStart = true snapToEnd = true snapToAlignment = SNAP_ALIGNMENT_DISABLED @@ -388,6 +392,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : public open fun setSnapOffsets(snapOffsets: List?) { this.snapOffsets = snapOffsets + sharedSnapOffsets = snapOffsets?.let { ScrollSnapOffsets.fromIntegerOffsets(it) } } public open fun setSnapToStart(snapToStart: Boolean) { @@ -1223,8 +1228,8 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : var smallerOffset = 0 var largerOffset = maximumOffset - var firstOffset = 0 - var lastOffset = maximumOffset + val firstOffset = 0 + val lastOffset = maximumOffset val viewportWidth = width - paddingStart - paddingEnd // offsets are from the right edge in RTL layouts @@ -1233,26 +1238,21 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : velocityX = -velocityX } - // get the nearest snap points to the target offset - val offsets = snapOffsets - if (!offsets.isNullOrEmpty()) { - firstOffset = offsets[0] - lastOffset = offsets[offsets.size - 1] - - for (i in offsets.indices) { - val offset = offsets[i] - if (offset <= targetOffset) { - if (targetOffset - offset < targetOffset - smallerOffset) { - smallerOffset = offset - } - } - if (offset >= targetOffset) { - if (offset - targetOffset < largerOffset - targetOffset) { - largerOffset = offset - } - } - } - } else if (snapToAlignment != SNAP_ALIGNMENT_DISABLED) { + val snapResult = + sharedSnapOffsets + ?.takeIf { !snapOffsets.isNullOrEmpty() } + ?.resolve( + currentOffset = + (if (layoutDirection == LAYOUT_DIRECTION_RTL) maximumOffset - scrollX + else scrollX) + .toDouble(), + targetOffset = targetOffset.toDouble(), + maximumOffset = maximumOffset.toDouble(), + velocity = velocityX.toDouble(), + snapToStart = snapToStart, + snapToEnd = snapToEnd, + ) + if (snapResult == null && snapToAlignment != SNAP_ALIGNMENT_DISABLED) { if (snapInterval > 0) { val ratio = targetOffset.toDouble() / snapInterval smallerOffset = @@ -1303,7 +1303,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : smallerOffset = max(smallerOffset, smallerChildOffset) largerOffset = kotlin.math.min(largerOffset, largerChildOffset) } - } else { + } else if (snapResult == null) { val interval = getSnapInterval().toDouble() val ratio = targetOffset.toDouble() / interval smallerOffset = (floor(ratio) * interval).toInt() @@ -1321,7 +1321,18 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : if (layoutDirection == LAYOUT_DIRECTION_RTL) { currentOffset = maximumOffset - currentOffset } - if (!snapToEnd && targetOffset >= lastOffset) { + if (snapResult != null) { + if (!hasCustomizedFlingAnimator) { + when (snapResult.direction) { + ScrollSnapDirection.FORWARD -> + velocityX += ((snapResult.selectedOffset.toInt() - targetOffset) * 10.0).toInt() + ScrollSnapDirection.BACKWARD -> + velocityX -= ((targetOffset - snapResult.selectedOffset.toInt()) * 10.0).toInt() + ScrollSnapDirection.NONE -> {} + } + } + targetOffset = snapResult.targetOffset.toInt() + } else if (!snapToEnd && targetOffset >= lastOffset) { if (currentOffset >= lastOffset) { // free scrolling } else { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt index 45fee3cd19d1..b77233a0af61 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt @@ -37,6 +37,8 @@ import com.facebook.react.R import com.facebook.react.bridge.ReadableMap import com.facebook.react.common.ReactConstants import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags +import com.facebook.react.shared.ScrollSnapDirection +import com.facebook.react.shared.ScrollSnapOffsets import com.facebook.react.uimanager.BackgroundStyleApplicator import com.facebook.react.uimanager.HasChildPressedStateDelay import com.facebook.react.uimanager.LengthPercentage @@ -203,6 +205,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : private var disableIntervalMomentum = false private var snapInterval = 0 private var snapOffsets: List? = null + private var sharedSnapOffsets: ScrollSnapOffsets? = null private var snapToStart = true private var snapToEnd = true private var snapToAlignment = SNAP_ALIGNMENT_DISABLED @@ -252,6 +255,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : disableIntervalMomentum = false snapInterval = 0 snapOffsets = null + sharedSnapOffsets = null snapToStart = true snapToEnd = true snapToAlignment = SNAP_ALIGNMENT_DISABLED @@ -359,6 +363,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : open fun setSnapOffsets(snapOffsets: List?) { this.snapOffsets = snapOffsets + sharedSnapOffsets = snapOffsets?.let { ScrollSnapOffsets.fromIntegerOffsets(it) } } open fun setSnapToStart(snapToStart: Boolean) { @@ -923,29 +928,20 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : var smallerOffset = 0 var largerOffset = maximumOffset - var firstOffset = 0 - var lastOffset = maximumOffset + val firstOffset = 0 + val lastOffset = maximumOffset val viewportHeight = height - paddingBottom - paddingTop - val currentSnapOffsets = snapOffsets - if (currentSnapOffsets != null) { - firstOffset = currentSnapOffsets[0] - lastOffset = currentSnapOffsets[currentSnapOffsets.size - 1] - - for (i in currentSnapOffsets.indices) { - val offset = currentSnapOffsets[i] - if (offset <= targetOffset) { - if (targetOffset - offset < targetOffset - smallerOffset) { - smallerOffset = offset - } - } - if (offset >= targetOffset) { - if (offset - targetOffset < largerOffset - targetOffset) { - largerOffset = offset - } - } - } - } else if (snapToAlignment != SNAP_ALIGNMENT_DISABLED) { + val snapResult = + sharedSnapOffsets?.resolve( + currentOffset = scrollY.toDouble(), + targetOffset = targetOffset.toDouble(), + maximumOffset = maximumOffset.toDouble(), + velocity = velocityY.toDouble(), + snapToStart = snapToStart, + snapToEnd = snapToEnd, + ) + if (snapResult == null && snapToAlignment != SNAP_ALIGNMENT_DISABLED) { if (snapInterval > 0) { val ratio = targetOffset.toDouble() / snapInterval smallerOffset = @@ -998,7 +994,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : smallerOffset = max(smallerOffset, smallerChildOffset) largerOffset = min(largerOffset, largerChildOffset) } - } else { + } else if (snapResult == null) { val interval = getSnapInterval().toDouble() val ratio = targetOffset.toDouble() / interval smallerOffset = (floor(ratio) * interval).toInt() @@ -1009,7 +1005,18 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : if (abs(targetOffset - smallerOffset) < abs(largerOffset - targetOffset)) smallerOffset else largerOffset - if (!snapToEnd && targetOffset >= lastOffset) { + if (snapResult != null) { + if (!hasCustomizedFlingAnimator) { + when (snapResult.direction) { + ScrollSnapDirection.FORWARD -> + velocityY += ((snapResult.selectedOffset.toInt() - targetOffset) * 10.0).toInt() + ScrollSnapDirection.BACKWARD -> + velocityY -= ((targetOffset - snapResult.selectedOffset.toInt()) * 10.0).toInt() + ScrollSnapDirection.NONE -> {} + } + } + targetOffset = snapResult.targetOffset.toInt() + } else if (!snapToEnd && targetOffset >= lastOffset) { if (scrollY >= lastOffset) { // free scrolling } else { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt index b553f6af997d..1454fd3a7b99 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt @@ -29,6 +29,8 @@ import com.facebook.react.R import com.facebook.react.bridge.ReadableMap import com.facebook.react.common.ReactConstants import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags +import com.facebook.react.shared.ScrollSnapDirection +import com.facebook.react.shared.ScrollSnapOffsets import com.facebook.react.uimanager.BackgroundStyleApplicator import com.facebook.react.uimanager.HasChildPressedStateDelay import com.facebook.react.uimanager.LengthPercentage @@ -195,6 +197,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : private var disableIntervalMomentum = false private var snapInterval = 0 private var snapOffsets: List? = null + private var sharedSnapOffsets: ScrollSnapOffsets? = null private var snapToStart = true private var snapToEnd = true private var snapToAlignment = SNAP_ALIGNMENT_DISABLED @@ -244,6 +247,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : disableIntervalMomentum = false snapInterval = 0 snapOffsets = null + sharedSnapOffsets = null snapToStart = true snapToEnd = true snapToAlignment = SNAP_ALIGNMENT_DISABLED @@ -351,6 +355,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : public open fun setSnapOffsets(snapOffsets: List?) { this.snapOffsets = snapOffsets + sharedSnapOffsets = snapOffsets?.let { ScrollSnapOffsets.fromIntegerOffsets(it) } } public open fun setSnapToStart(snapToStart: Boolean) { @@ -915,29 +920,20 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : var smallerOffset = 0 var largerOffset = maximumOffset - var firstOffset = 0 - var lastOffset = maximumOffset + val firstOffset = 0 + val lastOffset = maximumOffset val viewportHeight = height - paddingBottom - paddingTop - val currentSnapOffsets = snapOffsets - if (currentSnapOffsets != null) { - firstOffset = currentSnapOffsets[0] - lastOffset = currentSnapOffsets[currentSnapOffsets.size - 1] - - for (i in currentSnapOffsets.indices) { - val offset = currentSnapOffsets[i] - if (offset <= targetOffset) { - if (targetOffset - offset < targetOffset - smallerOffset) { - smallerOffset = offset - } - } - if (offset >= targetOffset) { - if (offset - targetOffset < largerOffset - targetOffset) { - largerOffset = offset - } - } - } - } else if (snapToAlignment != SNAP_ALIGNMENT_DISABLED) { + val snapResult = + sharedSnapOffsets?.resolve( + currentOffset = scrollY.toDouble(), + targetOffset = targetOffset.toDouble(), + maximumOffset = maximumOffset.toDouble(), + velocity = velocityY.toDouble(), + snapToStart = snapToStart, + snapToEnd = snapToEnd, + ) + if (snapResult == null && snapToAlignment != SNAP_ALIGNMENT_DISABLED) { if (snapInterval > 0) { val ratio = targetOffset.toDouble() / snapInterval smallerOffset = @@ -990,7 +986,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : smallerOffset = max(smallerOffset, smallerChildOffset) largerOffset = min(largerOffset, largerChildOffset) } - } else { + } else if (snapResult == null) { val interval = getSnapInterval().toDouble() val ratio = targetOffset.toDouble() / interval smallerOffset = (floor(ratio) * interval).toInt() @@ -1001,7 +997,18 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : if (abs(targetOffset - smallerOffset) < abs(largerOffset - targetOffset)) smallerOffset else largerOffset - if (!snapToEnd && targetOffset >= lastOffset) { + if (snapResult != null) { + if (!hasCustomizedFlingAnimator) { + when (snapResult.direction) { + ScrollSnapDirection.FORWARD -> + velocityY += ((snapResult.selectedOffset.toInt() - targetOffset) * 10.0).toInt() + ScrollSnapDirection.BACKWARD -> + velocityY -= ((targetOffset - snapResult.selectedOffset.toInt()) * 10.0).toInt() + ScrollSnapDirection.NONE -> {} + } + } + targetOffset = snapResult.targetOffset.toInt() + } else if (!snapToEnd && targetOffset >= lastOffset) { if (scrollY >= lastOffset) { // free scrolling } else { diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollSnapOffsetsTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollSnapOffsetsTest.kt new file mode 100644 index 000000000000..a837024b9b0e --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollSnapOffsetsTest.kt @@ -0,0 +1,295 @@ +/* + * 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.graphics.Point +import android.view.View +import android.view.ViewGroup +import android.widget.OverScroller +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.MockedStatic +import org.mockito.Mockito +import org.mockito.stubbing.Answer +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Exercises the real flingAndSnap methods while recording their native physics calls. */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [28]) +class ReactScrollSnapOffsetsTest { + private val drivers = mutableListOf() + private lateinit var helper: MockedStatic + + @Before + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + helper = + Mockito.mockStatic( + ReactScrollViewHelper::class.java, + Answer { invocation -> + if (invocation.method.name == "predictFinalScrollPosition") { + val driver = drivers.single { it.view === invocation.arguments[0] } + if (driver.horizontal) Point(driver.predicted, 0) else Point(0, driver.predicted) + } else { + invocation.callRealMethod() + } + }, + ) + } + + @After + fun tearDown() { + helper.close() + } + + private fun eachView(block: (ScrollSnapTestDriver) -> Unit) { + for (name in listOf("ReactScrollView", "ReactNestedScrollView", "ReactHorizontalScrollView")) { + val driver = ScrollSnapTestDriver(name) + drivers.add(driver) + block(driver) + } + } + + @Test + fun explicitOffsetsPreserveNativeVelocityBoost() = eachView { driver -> + driver.setOffsets(listOf(20, 60, 100)) + driver.predicted = 40 + assertEquals(ScrollSnapObservation(60, 210, 0), driver.fling(10)) + assertEquals(ScrollSnapObservation(20, -210, 0), driver.fling(-10)) + assertEquals(ScrollSnapObservation(60, 60, 0), driver.fling(0)) + } + + @Test + fun customizedAnimatorReceivesOnlyTheSelectedTarget() = eachView { driver -> + driver.customAnimator = true + driver.setOffsets(listOf(20, 60, 100)) + driver.predicted = 40 + assertEquals(ScrollSnapObservation(60, null, null), driver.fling(10)) + assertEquals(ScrollSnapObservation(20, null, null), driver.fling(-10)) + } + + @Test + fun disabledBoundariesPreserveFreeScrollingAndCrossing() = eachView { driver -> + driver.setOffsets(listOf(20, 60, 100)) + driver.set("setSnapToStart", false) + driver.set("setSnapToEnd", false) + driver.current = 110 + driver.predicted = 120 + assertEquals(ScrollSnapObservation(120, 10, 0), driver.fling(10)) + driver.current = 90 + assertEquals(ScrollSnapObservation(100, 10, 0), driver.fling(10)) + driver.current = 10 + driver.predicted = 0 + assertEquals(ScrollSnapObservation(0, -10, 100), driver.fling(-10)) + driver.current = 30 + assertEquals(ScrollSnapObservation(20, -10, 0), driver.fling(-10)) + } + + @Test + fun disablingMomentumUsesCurrentOffsetBeforeSelecting() = eachView { driver -> + driver.setOffsets(listOf(20, 60, 100)) + driver.set("setDisableIntervalMomentum", true) + driver.current = 25 + driver.predicted = 90 + assertEquals(ScrollSnapObservation(60, 360, 0), driver.fling(10)) + } + + @Test + fun propertyChangesAndNativeMutationsRemainVisible() = eachView { driver -> + driver.customAnimator = true + val offsets = mutableListOf(20, 60, 100) + driver.setOffsets(offsets) + driver.predicted = 40 + offsets[1] = 90 + assertEquals(90, driver.fling(10).target) + offsets.add(50) + assertEquals(50, driver.fling(10).target) + driver.setOffsets(listOf(20, 80, 100)) + assertEquals(80, driver.fling(10).target) + driver.setOffsets(null) + driver.set("setSnapInterval", 50) + assertEquals(50, driver.fling(10).target) + } + + @Test + fun clearingRetainedListsPreservesPlatformEmptyListBehavior() = eachView { driver -> + val offsets = mutableListOf(20, 60, 100) + driver.setOffsets(offsets) + driver.set("setSnapInterval", 50) + driver.predicted = 40 + offsets.clear() + if (driver.horizontal) { + assertEquals(50, driver.fling(10).target) + } else { + val failure = + assertThrows(java.lang.reflect.InvocationTargetException::class.java) { driver.fling(10) } + assertTrue(failure.cause is IndexOutOfBoundsException) + } + } + + @Test + fun recyclingClearsTheSelectorBeforeNativeIntervalSnapping() = eachView { driver -> + driver.setOffsets(listOf(20, 60, 100)) + driver.recycle() + driver.set("setSnapInterval", 50) + driver.predicted = 40 + assertEquals(50, driver.fling(10).target) + } + + @Test + fun extremeOffsetsPreserveAndroidIntegerArithmetic() = eachView { driver -> + driver.setOffsets(listOf(Int.MIN_VALUE, 20, 100)) + driver.predicted = 150 + assertEquals(ScrollSnapObservation(300, 300, 100), driver.fling(0)) + } + + @Test + fun horizontalRtlKeepsCoordinatesAndVelocityInNativeSpace() { + val driver = ScrollSnapTestDriver("ReactHorizontalScrollView") + drivers.add(driver) + driver.rtl = true + driver.current = 280 + driver.predicted = 260 + driver.setOffsets(listOf(20, 60, 100)) + assertEquals(ScrollSnapObservation(240, -210, 0), driver.fling(-10)) + assertEquals(ScrollSnapObservation(280, 210, 0), driver.fling(10)) + } +} + +internal data class ScrollSnapObservation(val target: Int, val velocity: Int?, val overscroll: Int?) + +/** + * Native prediction/rendering are controlled; selection and velocity adjustment execute unchanged. + */ +internal class ScrollSnapTestDriver(name: String) { + val horizontal = name.contains("Horizontal") + var current = 0 + var predicted = 0 + var maximum = 300 + var rtl = false + var customAnimator = false + private var observation: ScrollSnapObservation? = null + private val type = + Class.forName("com.facebook.react.views.scroll.$name").asSubclass(ViewGroup::class.java) + private val defaultAnimator = Mockito.mock(ValueAnimator::class.java) + private val otherAnimator = Mockito.mock(ValueAnimator::class.java) + private val state = ReactScrollViewHelper.ReactScrollViewScrollState().apply { isFinished = true } + private val child = + Mockito.mock( + View::class.java, + Answer { invocation -> + when (invocation.method.name) { + "getHeight", + "getWidth", + "getBottom", + "getRight" -> maximum + 200 + else -> Mockito.RETURNS_DEFAULTS.answer(invocation) + } + }, + ) + private val scroller = + Mockito.mock( + OverScroller::class.java, + Answer { invocation -> + if (invocation.method.name == "fling") { + val args = invocation.arguments + observation = + ScrollSnapObservation( + args[if (horizontal) 4 else 6] as Int, + args[if (horizontal) 2 else 3] as Int, + args[if (horizontal) 8 else 9] as Int, + ) + null + } else { + Mockito.RETURNS_DEFAULTS.answer(invocation) + } + }, + ) + val view: ViewGroup = + Mockito.mock( + type, + Answer { invocation -> + when (invocation.method.name) { + "getFlingAnimator" -> if (customAnimator) otherAnimator else defaultAnimator + "getFlingExtrapolatedDistance" -> predicted - current + "getReactScrollViewScrollState" -> state + "getScrollX" -> if (horizontal) current else 0 + "getScrollY" -> if (horizontal) 0 else current + "getWidth", + "getHeight" -> 200 + "computeHorizontalScrollRange" -> maximum + 200 + "getPaddingStart", + "getPaddingEnd", + "getPaddingTop", + "getPaddingBottom" -> 0 + "getChildCount" -> 1 + "getChildAt" -> child + "getLayoutDirection" -> + if (rtl) View.LAYOUT_DIRECTION_RTL else View.LAYOUT_DIRECTION_LTR + "postInvalidateOnAnimation", "invalidate" -> null + "reactSmoothScrollTo" -> { + observation = + ScrollSnapObservation( + invocation.arguments[if (horizontal) 0 else 1] as Int, + null, + null, + ) + null + } + else -> invocation.callRealMethod() + } + }, + ) + + init { + for ((name, value) in + listOf( + "defaultFlingAnimator" to defaultAnimator, + "scroller" to scroller, + "contentView" to child, + )) { + type.declaredFields.find { it.name == name }?.apply { isAccessible = true }?.set(view, value) + } + set("setSnapToStart", true) + set("setSnapToEnd", true) + } + + fun set(name: String, value: Any) { + type.methods.single { it.name == name && it.parameterCount == 1 }.invoke(view, value) + } + + fun setOffsets(offsets: List?) { + type.getMethod("setSnapOffsets", List::class.java).invoke(view, offsets) + } + + fun recycle() { + type.declaredMethods + .single { it.name.startsWith("recycleView") } + .apply { isAccessible = true } + .invoke(view) + // Reattach the fixture's measured content after the real recycle reset. + type.getMethod("onChildViewAdded", View::class.java, View::class.java).invoke(view, view, child) + } + + fun fling(velocity: Int): ScrollSnapObservation { + observation = null + type + .getDeclaredMethod("flingAndSnap", Int::class.javaPrimitiveType) + .apply { isAccessible = true } + .invoke(view, velocity) + return checkNotNull(observation) + } +} diff --git a/packages/react-native/ReactShared/README.md b/packages/react-native/ReactShared/README.md index 3ca57609f314..12051499206b 100644 --- a/packages/react-native/ReactShared/README.md +++ b/packages/react-native/ReactShared/README.md @@ -45,6 +45,28 @@ JNI interface to this currently Kotlin/Objective-C utility. Neither approach removes platform I/O or Catalyst fallback. Measure application memory and real bundle-download behavior before broadening adoption. +## Explicit scroll offsets + +`ScrollSnapOffsets` selects a target from explicit offsets for three Android views +and Apple's enhanced scroll view. Target prediction, axis/RTL handling, density, +velocity adjustment, interval snapping and native physics remain in the views. +Android retains its caller-owned integer list and pixel arithmetic. Apple caches +a primitive offset array when the property changes, then calls the selector once +per fling. It does not call Kotlin on every animation frame. + +`python3 scripts/test-apple-scroll-snap.py --benchmark-repeats 0` compiles the +actual Apple view with native and KMP paths, checks their target outputs, and +checks Catalyst fallback. The runner also compiles a test-only cached native +control to separate storage/caching benefits from Kotlin's contribution. +Omit that option to collect timing samples; the framework includes all use cases +present in the checkout. The Android core test suite includes +`ReactScrollSnapOffsetsTest`, which exercises all three views' actual fling code. + +This is a Kotlin-first consumer with a small target/direction result on Apple. A shared +C++ selector or caching the native Apple offsets are both alternatives; faster +results against the original NSNumber loop alone would not establish a KMP +advantage. Evaluate first-use cost, offset updates and repeated calls separately. + ## Build and test The standalone build uses its own Gradle wrapper and Kotlin plugin so that it @@ -159,7 +181,7 @@ python3 scripts/benchmark-kmp-build.py ``` The app script copies RNTester into an isolated sibling directory, enables KMP, -and verifies the actual compiled gradient and multipart adapters. Simulator runs +and verifies the actual compiled gradient, multipart and scroll adapters. Simulator runs execute the RNTester test plan and launch the app; device and Catalyst runs are unsigned build checks. Catalyst is enabled in the copied Podfile and application project. Each run requires fresh build outputs. It preserves RNTester's existing hosted-test topology and removes diff --git a/packages/react-native/ReactShared/scripts/test-android-consumers.py b/packages/react-native/ReactShared/scripts/test-android-consumers.py index 0dd596b1974b..431df5a5da09 100644 --- a/packages/react-native/ReactShared/scripts/test-android-consumers.py +++ b/packages/react-native/ReactShared/scripts/test-android-consumers.py @@ -93,7 +93,8 @@ def inspect_aar(aar): and name.endswith(".class")) required = {f"com/facebook/react/shared/{name}.class" for name in ["GradientStops", "GradientStopInput", "ResolvedGradientStop", - "MultipartFraming", "MultipartChunk", "MultipartHeaders", "MultipartHeader"]} + "MultipartFraming", "MultipartChunk", "MultipartHeaders", "MultipartHeader", + "ScrollSnapOffsets", "ScrollSnapDirection", "ScrollSnapResult"]} if not required.issubset(classes) or any(count != 1 for count in classes.values()): raise AssertionError(f"Missing or duplicated shared classes in {aar}: {classes}") with tempfile.TemporaryDirectory(prefix="react-native-kmp-bytecode-") as directory: @@ -105,6 +106,8 @@ def inspect_aar(aar): "com.facebook.react.devsupport.MultipartStreamReader": ( "MultipartFraming.nextChunk", "MultipartHeaders.parse"), } + for view in ("ReactScrollView", "ReactHorizontalScrollView", "ReactNestedScrollView"): + adapters[f"com.facebook.react.views.scroll.{view}"] = ("ScrollSnapOffsets.resolve",) for adapter, methods in adapters.items(): bytecode = subprocess.check_output( ["javap", "-c", "-p", "-classpath", str(jar), adapter], text=True) diff --git a/packages/react-native/ReactShared/scripts/test-apple-app.sh b/packages/react-native/ReactShared/scripts/test-apple-app.sh index 20b314937614..5a849575d57a 100755 --- a/packages/react-native/ReactShared/scripts/test-apple-app.sh +++ b/packages/react-native/ReactShared/scripts/test-apple-app.sh @@ -207,6 +207,7 @@ expected_kmp = sys.argv[2] != 'catalyst' reports = [] for filename, classes in ( ('RCTGradientUtils.o', ('RNSGradientStops',)), + ('RCTEnhancedScrollView.o', ('RNSScrollSnapOffsets',)), ('RCTMultipartStreamReader.o', ('RNSMultipartFraming', 'RNSMultipartHeaders')), ): objects = list(pathlib.Path(sys.argv[1]).rglob(filename)) diff --git a/packages/react-native/ReactShared/scripts/test-apple-scroll-snap.py b/packages/react-native/ReactShared/scripts/test-apple-scroll-snap.py new file mode 100755 index 000000000000..925317278558 --- /dev/null +++ b/packages/react-native/ReactShared/scripts/test-apple-scroll-snap.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +# 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. + +"""Compile the real Apple scroll adapter in both modes, compare it, and measure Release calls. + +Uses an iOS simulator; does not build RNTester or change an existing simulator's state. +Measurements are isolated simulator observations, not device interaction or app startup results. +""" + +import argparse +import hashlib +import json +import os +import pathlib +import platform +import subprocess + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=pathlib.Path) + parser.add_argument("--build-only", action="store_true") + parser.add_argument("--benchmark-repeats", type=int, default=3) + args = parser.parse_args() + if not 0 <= args.benchmark_repeats <= 20: + parser.error("benchmark-repeats must be 0..20") + shared = pathlib.Path(__file__).resolve().parent.parent + react = shared.parent + output = (args.output or shared / "build/apple-scroll-snap").resolve() + output.mkdir(parents=True, exist_ok=False) + command_number = 0 + + def run(command, **kwargs): + nonlocal command_number + command_number += 1 + result = subprocess.run(command, capture_output=True, text=True, **kwargs) + (output / f"command-{command_number}.log").write_text( + repr(command) + "\n" + result.stdout + result.stderr + ) + if result.returncode: + raise RuntimeError(f"Command failed; see {output}/command-{command_number}.log\n{result.stderr[-4000:]}") + return result.stdout + + architecture = platform.machine() + task_target = {"arm64": "IosSimulatorArm64", "x86_64": "IosX64"}[architecture] + native_target = {"arm64": "iosSimulatorArm64", "x86_64": "iosX64"}[architecture] + run([str(shared / "gradlew"), "-p", str(shared), f"linkReleaseFramework{task_target}", + "--max-workers=2", "--console=plain"]) + framework_parent = shared / f"build/bin/{native_target}/releaseFramework" + include = output / "include/React" + include.mkdir(parents=True) + for header in (react / "React/Base").glob("*.h"): + (include / header.name).symlink_to(header) + scroll = react / "React/Fabric/Mounting/ComponentViews/ScrollView" + splitter = react / "React/Fabric/Utils" + for header in [scroll / "RCTEnhancedScrollView.h", splitter / "RCTGenericDelegateSplitter.h"]: + (include / header.name).symlink_to(header) + # EnhancedScrollView.h includes this header without using any of its declarations. + # Keep unrelated Fabric/Folly dependencies out of the standalone UIKit fixture. + (include / "RCTViewComponentView.h").write_text("// Unused transitive Fabric header in this standalone fixture.\n") + sdk = run(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"]).strip() + flags = ["-std=c++20", "-O2", "-fobjc-arc", "-DREACT_NATIVE_PRODUCTION", "-DRCTLOG_ENABLED=0", + "-target", f"{architecture}-apple-ios15.1-simulator", "-isysroot", sdk, + "-I", str(output / "include"), "-I", str(react / "ReactCommon"), "-F", str(framework_parent)] + frameworks = ["-framework", "Foundation", "-framework", "UIKit", "-framework", "QuartzCore", "-framework", "CoreGraphics"] + adapter = scroll / "RCTEnhancedScrollView.mm" + harness = shared / "tests/AppleScrollSnapParity.mm" + splitter_object = output / "splitter.o" + run(["xcrun", "clang++", *flags, "-c", str(splitter / "RCTGenericDelegateSplitter.mm"), "-o", str(splitter_object)]) + for mode, defines in { + "kmp": ["-DRCT_USE_KMP=1"], + "baseline": ["-DRCT_USE_KMP=0", "-DRCTEnhancedScrollView=RCTEnhancedScrollViewBaseline"], + "native": ["-DRCT_USE_KMP=0"], + }.items(): + run(["xcrun", "clang++", *flags, *defines, "-c", str(adapter), "-o", str(output / f"{mode}.o")]) + # Control for the benefit of caching alone: compile the actual native implementation + # with vector storage/access, leaving its selection algorithm unchanged. This staged + # file is a measurement fixture, never a production source edit. + cached_source = adapter.read_text() + edits = { + '#import "RCTEnhancedScrollView.h"': '#import \n#include ', + ' BOOL _isSetContentOffsetDisabled;': ' BOOL _isSetContentOffsetDisabled;\n std::vector _cachedNativeOffsets;', + '}\n\n#if RCT_SCROLL_SNAP_USE_KMP\n@synthesize': '''} +@synthesize snapToOffsets = _snapToOffsets; +- (void)setSnapToOffsets:(NSArray *)snapToOffsets +{ + _snapToOffsets = [snapToOffsets copy]; + _cachedNativeOffsets.clear(); + _cachedNativeOffsets.reserve(_snapToOffsets.count); + for (NSNumber *offset in _snapToOffsets) { + _cachedNativeOffsets.push_back(offset.floatValue); + } +} + +#if RCT_SCROLL_SNAP_USE_KMP +@synthesize''', + 'i < self.snapToOffsets.count': 'i < _cachedNativeOffsets.size()', + '[[self.snapToOffsets objectAtIndex:i] floatValue]': '_cachedNativeOffsets[i]', + '[[self.snapToOffsets firstObject] floatValue]': '_cachedNativeOffsets.front()', + '[[self.snapToOffsets lastObject] floatValue]': '_cachedNativeOffsets.back()', + } + for before, after in edits.items(): + if cached_source.count(before) != 1: + raise RuntimeError(f"Native cache control requires review after adapter change: {before}") + cached_source = cached_source.replace(before, after) + cached_path = output / "CachedNativeScrollView.mm" + cached_path.write_text(cached_source) + for name, defines in {"cached": [], "cached-parity": ["-DRCTEnhancedScrollView=RCTEnhancedScrollViewCached"]}.items(): + run(["xcrun", "clang++", *flags, "-DRCT_USE_KMP=0", *defines, "-c", str(cached_path), "-o", str(output / f"{name}.o")]) + symbols = {mode: run(["xcrun", "nm", "-u", str(output / f"{mode}.o")]) for mode in ("kmp", "native")} + if "RNSScrollSnapOffsets" not in symbols["kmp"] or "RNS" in symbols["native"]: + raise RuntimeError("Compiled adapters did not use the expected KMP/native paths") + parity = output / "AppleScrollSnapParity" + run(["xcrun", "clang++", *flags, str(harness), str(output / "kmp.o"), str(output / "baseline.o"), str(output / "cached-parity.o"), + str(splitter_object), *frameworks, "-framework", "ReactNativeShared", "-o", str(parity)]) + executables = {} + for mode in ("native", "cached", "kmp"): + executable = output / f"AppleScrollSnapBenchmark-{mode}" + run(["xcrun", "clang++", *flags, "-DRCT_SCROLL_BENCHMARK=1", str(harness), str(output / f"{mode}.o"), + str(splitter_object), *frameworks, *(["-framework", "ReactNativeShared"] if mode == "kmp" else []), + "-Wl,-dead_strip", "-o", str(executable)]) + executables[mode] = executable + mac_sdk = run(["xcrun", "--sdk", "macosx", "--show-sdk-path"]).strip() + catalyst = output / "catalyst.o" + run(["xcrun", "clang++", "-std=c++20", "-fobjc-arc", "-DRCT_USE_KMP=1", "-DREACT_NATIVE_PRODUCTION", + "-target", f"{architecture}-apple-ios15.1-macabi", "-isysroot", mac_sdk, + "-isystem", f"{mac_sdk}/System/iOSSupport/usr/include", + "-iframework", f"{mac_sdk}/System/iOSSupport/System/Library/Frameworks", + "-I", str(output / "include"), "-I", str(react / "ReactCommon"), "-c", str(adapter), "-o", str(catalyst)]) + if "RNS" in run(["xcrun", "nm", "-u", str(catalyst)]): + raise RuntimeError("Catalyst unexpectedly references Kotlin/Native") + sources = [adapter, harness, shared / "src/commonMain/kotlin/com/facebook/react/shared/ScrollSnapOffsets.kt"] + report = { + "configuration": "Release; actual UIKit adapter compiled with KMP enabled and disabled", + "platform": f"{architecture} iOS simulator", "sdk": sdk, + "cachedNativeControlSha256": hashlib.sha256(cached_path.read_bytes()).hexdigest(), + "sourceSha256": {str(path.relative_to(react)): hashlib.sha256(path.read_bytes()).hexdigest() for path in sources}, + "catalystNativeFallbackCompiled": True, "runtimeExecuted": False, + "executableBytes": {mode: path.stat().st_size for mode, path in executables.items()}, + "benchmarks": [], + "limitations": [ + "Not physical-device performance, gesture interaction, or full-application startup/size.", + "First property assignment includes snapshot conversion and lazy Kotlin initialization.", + "Repeated flings reuse the property snapshot; each sample includes an autorelease pool per call.", + "Cached-native control changes only property storage/access in a staged native adapter copy.", + "Runs share the host with unrelated processes; timing is observational, with no pass/fail threshold.", + "One unrelated unused Fabric header is omitted; RN logging sanitizer is replaced by a finite-input assertion.", + ], + } + simulator = os.environ.get("RCT_KMP_SIMULATOR_UDID") + owned = None + try: + if not args.build_only: + if not simulator: + inventory = json.loads(run(["xcrun", "simctl", "list", "-j"])) + runtime = next((item for item in inventory["runtimes"] + if item.get("isAvailable") and "iOS" in item["identifier"]), None) + device = next((item["identifier"] for item in (runtime or {}).get("supportedDeviceTypes", []) + if "iPhone" in item["name"]), None) + if not device: + raise RuntimeError("Install a compatible iOS simulator runtime") + owned = run(["xcrun", "simctl", "create", "KMP Scroll Snap Validation", device, runtime["identifier"]]).strip() + simulator = owned + report["parity"] = json.loads(run(["xcrun", "simctl", "spawn", "--standalone", simulator, str(parity)])) + report["runtimeExecuted"] = True + for repetition in range(args.benchmark_repeats): + for count in (3, 16, 256): + for mode in (("native", "cached", "kmp") if repetition % 2 == 0 else ("kmp", "cached", "native")): + measurement = json.loads(run(["xcrun", "simctl", "spawn", "--standalone", simulator, + str(executables[mode]), str(count)])) + measurement.update(mode=mode, repetition=repetition) + report["benchmarks"].append(measurement) + finally: + if owned: + run(["xcrun", "simctl", "delete", owned]) + (output / "results.json").write_text(json.dumps(report, indent=2) + "\n") + print(json.dumps(report, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/packages/react-native/ReactShared/src/commonMain/kotlin/com/facebook/react/shared/ScrollSnapOffsets.kt b/packages/react-native/ReactShared/src/commonMain/kotlin/com/facebook/react/shared/ScrollSnapOffsets.kt new file mode 100644 index 000000000000..753232d99f3d --- /dev/null +++ b/packages/react-native/ReactShared/src/commonMain/kotlin/com/facebook/react/shared/ScrollSnapOffsets.kt @@ -0,0 +1,131 @@ +/* + * 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.shared + +import kotlin.math.abs + +/** Why a fling selected an offset; platforms retain their own velocity adjustments. */ +public enum class ScrollSnapDirection { + NONE, + FORWARD, + BACKWARD, +} + +/** The bounded target and the original snap point used by native fling physics. */ +public class ScrollSnapResult +internal constructor( + public val targetOffset: Double, + public val selectedOffset: Double, + public val direction: ScrollSnapDirection, +) + +/** + * Selects explicit scroll snap offsets in platform-resolved coordinates. + * + * The DoubleArray constructor copies its input for an Apple property snapshot. Android uses + * [fromIntegerOffsets] to retain its existing native list and 32-bit pixel arithmetic. Inputs are + * never sorted: the first and last elements define free-scrolling boundaries. Callers retain + * density conversion, RTL coordinates, target prediction and interval snapping. + */ +public class ScrollSnapOffsets +private constructor( + private val fractionalOffsets: DoubleArray?, + private val integerOffsets: List?, +) { + public constructor(offsets: DoubleArray) : this(offsets.copyOf(), null) + + public companion object { + /** + * Retains the caller's list so native mutations remain visible on the next fling. The list must + * be nonempty when resolving; platforms keep their existing empty-list fallback policy. + */ + public fun fromIntegerOffsets(offsets: List): ScrollSnapOffsets = + ScrollSnapOffsets(null, offsets) + } + + private fun offsetAt(index: Int): Double = + if (integerOffsets != null) integerOffsets[index].toDouble() + else checkNotNull(fractionalOffsets)[index] + + private fun distance(left: Double, right: Double): Double = + if (integerOffsets != null) (left.toInt() - right.toInt()).toDouble() else left - right + + /** + * Coordinates must be finite and [maximumOffset] nonnegative. An empty DoubleArray leaves the + * predicted target unchanged apart from bounding it. A midpoint tie selects the larger offset. + * [ScrollSnapResult.selectedOffset] precedes bounding for Android's native velocity adjustment. + */ + public fun resolve( + currentOffset: Double, + targetOffset: Double, + maximumOffset: Double, + velocity: Double, + snapToStart: Boolean, + snapToEnd: Boolean, + ): ScrollSnapResult { + val count = integerOffsets?.size ?: checkNotNull(fractionalOffsets).size + if (integerOffsets == null && count == 0) { + return ScrollSnapResult( + targetOffset.coerceIn(0.0, maximumOffset), + targetOffset, + ScrollSnapDirection.NONE, + ) + } + val firstOffset = offsetAt(0) + val lastOffset = offsetAt(count - 1) + var smallerOffset = 0.0 + var largerOffset = maximumOffset + for (i in 0 until count) { + val offset = offsetAt(i) + if ( + offset <= targetOffset && + distance(targetOffset, offset) < distance(targetOffset, smallerOffset) + ) { + smallerOffset = offset + } + if ( + offset >= targetOffset && + distance(offset, targetOffset) < distance(largerOffset, targetOffset) + ) { + largerOffset = offset + } + } + + var selectedOffset = targetOffset + var direction = ScrollSnapDirection.NONE + if (!snapToEnd && targetOffset >= lastOffset) { + if (currentOffset < lastOffset) { + selectedOffset = lastOffset + } + } else if (!snapToStart && targetOffset <= firstOffset) { + if (currentOffset > firstOffset) { + selectedOffset = firstOffset + } + } else if (velocity > 0) { + selectedOffset = largerOffset + direction = ScrollSnapDirection.FORWARD + } else if (velocity < 0) { + selectedOffset = smallerOffset + direction = ScrollSnapDirection.BACKWARD + } else { + val preferSmaller = + if (integerOffsets != null) { + abs(targetOffset.toInt() - smallerOffset.toInt()) < + abs(largerOffset.toInt() - targetOffset.toInt()) + } else { + targetOffset - smallerOffset < largerOffset - targetOffset + } + selectedOffset = if (preferSmaller) smallerOffset else largerOffset + } + return ScrollSnapResult( + targetOffset = selectedOffset.coerceIn(0.0, maximumOffset), + selectedOffset = selectedOffset, + direction = direction, + ) + } +} diff --git a/packages/react-native/ReactShared/src/commonTest/kotlin/com/facebook/react/shared/ScrollSnapOffsetsTest.kt b/packages/react-native/ReactShared/src/commonTest/kotlin/com/facebook/react/shared/ScrollSnapOffsetsTest.kt new file mode 100644 index 000000000000..e8b75c435a89 --- /dev/null +++ b/packages/react-native/ReactShared/src/commonTest/kotlin/com/facebook/react/shared/ScrollSnapOffsetsTest.kt @@ -0,0 +1,83 @@ +/* + * 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.shared + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ScrollSnapOffsetsTest { + private val offsets = ScrollSnapOffsets(doubleArrayOf(20.0, 60.0, 100.0)) + + @Test + fun directionAndMidpointTies() { + assertEquals(20.0, offsets.resolve(20.0, 39.0, 120.0, 0.0, true, true).targetOffset) + assertEquals(60.0, offsets.resolve(20.0, 40.0, 120.0, 0.0, true, true).targetOffset) + val forward = offsets.resolve(20.0, 21.0, 120.0, 0.01, true, true) + assertEquals(60.0, forward.targetOffset) + assertEquals(ScrollSnapDirection.FORWARD, forward.direction) + val backward = offsets.resolve(100.0, 99.0, 120.0, -0.01, true, true) + assertEquals(60.0, backward.targetOffset) + assertEquals(ScrollSnapDirection.BACKWARD, backward.direction) + assertEquals(60.0, offsets.resolve(20.0, 60.0, 120.0, 1.0, true, true).targetOffset) + } + + @Test + fun disabledBoundariesAllowFreeScrollingOnlyAfterCrossing() { + assertEquals(20.0, offsets.resolve(30.0, 10.0, 120.0, -1.0, false, false).targetOffset) + val freeStart = offsets.resolve(20.0, 10.0, 120.0, -1.0, false, false) + assertEquals(10.0, freeStart.targetOffset) + assertEquals(ScrollSnapDirection.NONE, freeStart.direction) + assertEquals(100.0, offsets.resolve(90.0, 110.0, 120.0, 1.0, false, false).targetOffset) + val freeEnd = offsets.resolve(100.0, 110.0, 120.0, 1.0, false, false) + assertEquals(110.0, freeEnd.targetOffset) + assertEquals(ScrollSnapDirection.NONE, freeEnd.direction) + } + + @Test + fun boundingDoesNotChangeThePointUsedForVelocityAdjustment() { + val result = + ScrollSnapOffsets(doubleArrayOf(20.0, 140.0)).resolve(0.0, 150.0, 120.0, -1.0, true, true) + assertEquals(120.0, result.targetOffset) + assertEquals(140.0, result.selectedOffset) + assertEquals(ScrollSnapDirection.BACKWARD, result.direction) + assertEquals(0.0, offsets.resolve(0.0, -50.0, 0.0, 0.0, true, true).targetOffset) + } + + @Test + fun fractionalDuplicateAndEmptyOffsets() { + val fractional = ScrollSnapOffsets(doubleArrayOf(0.25, 0.25, 1.75)) + assertEquals(1.75, fractional.resolve(0.0, 1.0, 2.0, 0.0, true, true).targetOffset) + assertEquals(0.25, fractional.resolve(0.0, 0.25, 2.0, -1.0, true, true).targetOffset) + assertEquals( + 1.0, + ScrollSnapOffsets(doubleArrayOf()).resolve(0.0, 1.0, 2.0, 1.0, true, true).targetOffset, + ) + } + + @Test + fun propertySnapshotPreservesOrderAndIgnoresLaterInputMutation() { + val input = doubleArrayOf(100.0, 20.0, 60.0) + val snapshot = ScrollSnapOffsets(input) + input[0] = 0.0 + // The first property value is 100, not the sorted minimum of 20. + assertEquals(100.0, snapshot.resolve(110.0, 50.0, 120.0, 0.0, false, true).targetOffset) + assertEquals(60.0, snapshot.resolve(0.0, 50.0, 120.0, 0.0, true, true).targetOffset) + } + + @Test + fun integerListsRetainNativeMutationsAndWrappingArithmetic() { + val input = mutableListOf(20, 60, 100) + val retained = ScrollSnapOffsets.fromIntegerOffsets(input) + input[1] = 90 + assertEquals(90.0, retained.resolve(0.0, 40.0, 300.0, 1.0, true, true).targetOffset) + input.add(50) + assertEquals(50.0, retained.resolve(0.0, 40.0, 300.0, 1.0, true, true).targetOffset) + val extreme = ScrollSnapOffsets.fromIntegerOffsets(listOf(Int.MIN_VALUE, 20, 100)) + assertEquals(300.0, extreme.resolve(0.0, 150.0, 300.0, 0.0, true, true).targetOffset) + } +} diff --git a/packages/react-native/ReactShared/tests/AppleScrollSnapParity.mm b/packages/react-native/ReactShared/tests/AppleScrollSnapParity.mm new file mode 100644 index 000000000000..7151fbc1e3a9 --- /dev/null +++ b/packages/react-native/ReactShared/tests/AppleScrollSnapParity.mm @@ -0,0 +1,181 @@ +/* + * 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. + */ + +#import +#import +#import +#include +#include +#include +#include + +// This fixture links the actual scroll view and delegate splitter. The only omitted +// implementation is RN's logging sanitizer; every test coordinate is finite. +double RCTSanitizeNaNValue(double value, NSString *property) +{ + if (!std::isfinite(value)) { + std::abort(); + } + return value; +} + +static RCTEnhancedScrollView *makeView(NSString *className, BOOL horizontal) +{ + auto view = (RCTEnhancedScrollView *)[[NSClassFromString(className) alloc] initWithFrame:CGRectMake(0, 0, 200, 200)]; + if (!view) { + std::abort(); + } + view.contentSize = horizontal ? CGSizeMake(500, 200) : CGSizeMake(200, 500); + view.snapToStart = YES; + view.snapToEnd = YES; + return view; +} + +static CGPoint target(RCTEnhancedScrollView *view, BOOL horizontal, double predicted, double velocity) +{ + CGPoint result = horizontal ? CGPointMake(predicted, 17) : CGPointMake(17, predicted); + CGPoint speed = horizontal ? CGPointMake(velocity, 0) : CGPointMake(0, velocity); + [(id)view scrollViewWillEndDragging:view withVelocity:speed targetContentOffset:&result]; + return result; +} + +static void requireEqual(CGPoint actual, CGPoint expected, NSUInteger index) +{ + if (actual.x != expected.x || actual.y != expected.y) { + std::fprintf( + stderr, "case %lu: (%g, %g) != (%g, %g)\n", (unsigned long)index, actual.x, actual.y, expected.x, expected.y); + std::exit(1); + } +} + +static void checkViews( + RCTEnhancedScrollView *actual, + RCTEnhancedScrollView *baseline, + RCTEnhancedScrollView *cached, + BOOL horizontal, + double predicted, + double velocity, + NSUInteger index) +{ + auto expected = target(baseline, horizontal, predicted, velocity); + requireEqual(target(actual, horizontal, predicted, velocity), expected, index); + requireEqual(target(cached, horizontal, predicted, velocity), expected, index); +} + +static double nanos(uint64_t elapsed) +{ + mach_timebase_info_data_t scale; + mach_timebase_info(&scale); + return (double)elapsed * scale.numer / scale.denom; +} + +int main(int argc, const char **argv) +{ + @autoreleasepool { +#if RCT_SCROLL_BENCHMARK + const NSUInteger count = argc > 1 ? strtoul(argv[1], nullptr, 10) : 16; + const NSUInteger iterations = 10000; + auto view = makeView(@"RCTEnhancedScrollView", YES); + NSMutableArray *offsets = [NSMutableArray new]; + for (NSUInteger i = 0; i < count; i++) { + [offsets addObject:@(300.0 * i / MAX(count - 1, 1UL))]; + } + const auto propertyStart = mach_continuous_time(); + view.snapToOffsets = offsets; + const double firstPropertyNanos = nanos(mach_continuous_time() - propertyStart); + const auto firstStart = mach_continuous_time(); + auto first = target(view, YES, 151.0, 1.0); + const double firstFlingNanos = nanos(mach_continuous_time() - firstStart); + NSMutableArray *samples = [NSMutableArray new]; + double checksum = first.x; + for (NSUInteger sample = 0; sample < 7; sample++) { + const auto start = mach_continuous_time(); + for (NSUInteger i = 0; i < iterations; i++) { + @autoreleasepool { + checksum += target(view, YES, (double)(i % 301), (int)(i % 3) - 1).x; + } + } + [samples addObject:@(nanos(mach_continuous_time() - start) / iterations)]; + } + NSDictionary *report = @{ + @"offsetCount" : @(count), + @"iterationsPerSample" : @(iterations), + @"firstPropertyNanos" : @(firstPropertyNanos), + @"firstFlingNanos" : @(firstFlingNanos), + @"nanosPerFling" : samples, + @"checksum" : @(checksum), + }; +#else + NSUInteger cases = 0; + NSArray *> *lists = @[ + @[], + @[ @60 ], + @[ @20, @60, @100 ], + @[ @0.25, @0.25, @60.1, @100.75 ], + @[ @100, @20, @60 ], + @[ @-20, @60, @350 ], + ]; + for (BOOL horizontal : {NO, YES}) { + auto actual = makeView(@"RCTEnhancedScrollView", horizontal); + auto baseline = makeView(@"RCTEnhancedScrollViewBaseline", horizontal); + auto cached = makeView(@"RCTEnhancedScrollViewCached", horizontal); + for (double maximum : {0.0, 120.0, 300.0}) { + actual.contentSize = baseline.contentSize = cached.contentSize = + horizontal ? CGSizeMake(200 + maximum, 200) : CGSizeMake(200, 200 + maximum); + for (NSArray *offsets in lists) { + actual.snapToOffsets = baseline.snapToOffsets = cached.snapToOffsets = offsets; + for (int flags = 0; flags < 4; flags++) { + actual.snapToStart = baseline.snapToStart = cached.snapToStart = flags & 1; + actual.snapToEnd = baseline.snapToEnd = cached.snapToEnd = flags & 2; + for (double current : {-20.0, 0.0, 20.0, 60.0, 100.0, 150.0, 320.0}) { + actual.contentOffset = baseline.contentOffset = cached.contentOffset = + horizontal ? CGPointMake(current, 0) : CGPointMake(0, current); + for (double predicted : {-50.0, 0.0, 20.0, 39.9, 40.0, 60.0, 100.0, 120.0, 150.0, 320.0}) { + for (double velocity : {-1.0, 0.0, 1.0}) { + checkViews(actual, baseline, cached, horizontal, predicted, velocity, ++cases); + } + } + } + } + } + } + // Replacing/clearing the property must invalidate the cached selector. + NSMutableArray *mutableOffsets = [@[ @20, @60, @100 ] mutableCopy]; + actual.snapToOffsets = baseline.snapToOffsets = cached.snapToOffsets = mutableOffsets; + mutableOffsets[1] = @90; + checkViews(actual, baseline, cached, horizontal, 40, 1, ++cases); + for (NSArray *replacement in @[ @[ @30, @70 ], @[] ]) { + actual.snapToOffsets = baseline.snapToOffsets = cached.snapToOffsets = replacement; + checkViews(actual, baseline, cached, horizontal, 40, 1, ++cases); + } + [actual setValue:nil forKey:@"snapToOffsets"]; + [baseline setValue:nil forKey:@"snapToOffsets"]; + [cached setValue:nil forKey:@"snapToOffsets"]; + // Interval snapping remains a native operation, including alignment and momentum policy. + actual.contentSize = baseline.contentSize = cached.contentSize = + horizontal ? CGSizeMake(500, 200) : CGSizeMake(200, 500); + actual.snapToInterval = baseline.snapToInterval = cached.snapToInterval = 60; + actual.contentOffset = baseline.contentOffset = cached.contentOffset = + horizontal ? CGPointMake(75, 0) : CGPointMake(0, 75); + for (NSString *alignment in @[ @"start", @"center", @"end" ]) { + actual.snapToAlignment = baseline.snapToAlignment = cached.snapToAlignment = alignment; + for (BOOL disableMomentum : {NO, YES}) { + actual.disableIntervalMomentum = baseline.disableIntervalMomentum = cached.disableIntervalMomentum = + disableMomentum; + for (double velocity : {-1.0, 0.0, 1.0}) { + checkViews(actual, baseline, cached, horizontal, 130, velocity, ++cases); + } + } + } + } + NSDictionary *report = @{@"passedCases" : @(cases), @"cachedNativeControlCases" : @(cases), @"failedCases" : @0}; +#endif + NSData *json = [NSJSONSerialization dataWithJSONObject:report options:NSJSONWritingSortedKeys error:nil]; + std::puts([[NSString alloc] initWithData:json encoding:NSUTF8StringEncoding].UTF8String); + } + return 0; +}