Skip to content

Share explicit scroll snap selection with Kotlin Multiplatform - #6

Open
kunal26das wants to merge 1 commit into
codex/kmp-native-multipartfrom
codex/kmp-native-scroll-snap
Open

Share explicit scroll snap selection with Kotlin Multiplatform#6
kunal26das wants to merge 1 commit into
codex/kmp-native-multipartfrom
codex/kmp-native-scroll-snap

Conversation

@kunal26das

@kunal26das kunal26das commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Summary

Architectural discussion: Discussion #1020 is the current venue for evaluating architectural fit and downstream costs. This fork stack is an experimental implementation reference; upstream KMP integration is awaiting architectural agreement.

Share explicit snapToOffsets target selection between Android's vertical, horizontal and nested scroll views and Apple's enhanced scroll view. This is the third use case after the KMP foundation upstream #58472, gradients upstream #58456 and multipart framing upstream #58474. Each use case is independently reviewable; there is no Compose dependency.

This native fork PR mirrors the upstream explicit scroll offsets submission as layer 6 of six. This layer is based on shared multipart parsing. The registered fork stack uses dependent PR bases, so this PR shows its incremental diff.

Stack order: FoundationRNTester dependenciesGradientsNative multipart boundsShared multipart parsingScroll offsets.

This mirror retains the exact validated source tree of the corresponding upstream submission. The existing Okio test annotation is inherited from the native-bounds layer; its earlier placement changes ancestry, not this layer’s final files. The results below identify the revisions actually tested; restacking is not a new test run.

The common selector is 131 lines and returns a small object containing target, selected offset and direction; Apple reads its target offset. Each view retains native prediction, velocity adjustment, overscroll, density, RTL and axis handling, interval snapping and scrolling physics. Apple caches a primitive offset array when the property changes and calls Kotlin once per fling. Android retains the original caller-owned integer list, including subsequent mutations, and preserves its integer overflow behavior. Empty-list behavior is preserved separately for horizontal versus vertical/nested views. Catalyst retains its native path. Apple source builds remain opt-in with RCT_USE_KMP=1.

Why consider this boundary?

The selector is duplicated across three Kotlin views and an Objective-C++ view, and its small target/direction result keeps the interop boundary small. Common tests centralize decision behavior while platform adapters keep their differences explicit. This is a maintenance proposal, not a claim that Kotlin is faster than a well-tuned native implementation. React Native's existing C++ infrastructure and a native caching change are both credible alternatives.

The benchmark therefore includes the original Apple implementation, a test-only cached native control, and KMP. Each uses the actual view delegate method and matched inputs. Medians across three fresh processes per configuration, in nanoseconds per warm call:

Offset count Original native Cached native KMP
3 224 72 204
16 443 102 215
256 5,682 595 868

Caching explains much of the improvement over the original NSNumber loop. The cached native control is faster than KMP in every configuration. KMP's first property assignment measured 130–207 µs versus 1.2–3.6 µs for cached native; its first fling measured 14.5–27.6 µs. The isolated KMP executable was 832,432 bytes larger than the original native executable, but includes the runtime and all three shared use cases. This is neither an incremental scroll-only size nor an installed application size.

These optimized ARM64 simulator probes measure method calls, not frame timing, physical gestures or device performance. Common-source ownership and shared regression coverage must justify the added runtime/build burden; the results do not establish a performance reason to adopt KMP.

Changelog:

[GENERAL] [CHANGED] - Share explicit scroll snap-offset target selection on Android and opt-in KMP iOS source builds.

Test Plan

The scroll implementation is byte-identical to reviewed head 9cc8379; the validated upstream layer also inherits the gradient-layer hosted-runtime fix, which this mirror retains unchanged. The inherited multipart graph passes 112 configurations on each of CocoaPods 1.14.2 and 1.15.2. Fresh focused runtime checks are recorded in the prerequisite PRs.

Runtime-ownership coverage includes both nested and sibling hosted XCTest bundles. Hosted tests use the application’s Kotlin runtime, while standalone tests keep their own runtime. The retained three-use-case library/static-framework runs at 9cc8379 each passed 154 active tests under Address Sanitizer and launched successfully.

From packages/react-native/ReactShared:

./gradlew jvmTest iosSimulatorArm64Test --max-workers=2
python3 scripts/test-apple-scroll-snap.py --benchmark-repeats 0
# Omit --benchmark-repeats 0 for the three-implementation cost probe.

The full ReactAndroid suite was run from the repository with:

./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --rerun \
  -PreactNativeArchitectures=arm64-v8a -Preact.internal.useHermesStable=true \
  --max-workers=2 --console=plain --offline
  • All 38 common tests pass on both JVM and ARM64 iOS simulator, including six selector tests; all six Debug/Release iOS device and simulator frameworks build, including Intel simulator slices. Nine checked-in Android regressions pass. The regressions call the real three views' fling paths and cover velocity/direction, free ranges, ties, axes/RTL, mutable lists, empty lists, endpoints and integer overflow.

  • An additional Robolectric differential fixture compiles the full original and changed Android view classes and passes 80,640 target/velocity/overscroll comparisons with controlled prediction and drawing. These are separate from the 18 ART assertions across API 24/26/37. Its two local baseline tests are not included in the nine checked-in tests.

  • The external direct-fling fixture initially reproduced the same nested-scroll bookkeeping issue with both native and KMP AARs. Initializing that state through AndroidX’s public API made the unchanged 80/96 assertions pass for both; no production code or expected target was changed. This fixture covers zero-velocity LTR snapping and retained-list updates, not general gesture/RTL physics.

  • Actual Apple native/KMP view comparison: 30,282 cases passed. The cached native control independently passed the same 30,282 comparisons. Catalyst compiles without Kotlin references.

  • Cost probe: 27 fresh processes across three implementations, three list sizes and three repeats; all checksums agree. No other task-owned benchmark/build ran during the measured window. Source hashes identify the combined shared framework used.

  • The assembled stack passed the full ReactAndroid suite: 623 tests across 94 suites, zero failures/errors/skips; RNTester ARM64 Release and 16 KiB alignment passed.

  • Previously validated local Maven consumers and packed-npm source consumers passed Debug and R8 Release. Source consumption builds standard Hermes from source. Five AARs contain each of the 12 shared class entries exactly once, and bytecode checks confirm actual calls from all three use cases.

  • Both Release consumers passed real gradient and multipart assertions on ARM64 API 24, 26 and 37. A separate R8 fixture passed six explicit-offset assertions per API using the real vertical, horizontal and nested views, including retained-list updates. RNTester exercised ten gradient routes and three normal scroll routes on each API 26/37, plus two gradient routes on API 24. These are targeted assertions and UI smoke, not exhaustive gestures or pixel comparisons.

  • Retained static-library and static-framework RNTester runs at 9cc8379 each passed 154 tests, with 16 upstream skips and zero failures under Address Sanitizer, and launched. These repeat the same suite, not 308 distinct tests. Compiled adapters call all three shared APIs. The application owns one runtime; hosted integration tests import it, while standalone unit tests own their runtime separately.

  • Universal Catalyst source build and actual ARM64/Rosetta x86_64 launches passed representative linear/radial rendering checks. All six compiled adapter objects use native fallback, and the final binaries have no shared Kotlin runtime. This is Catalyst fallback coverage, not Intel iOS simulator execution.

  • Four independent Kotlin runtime/static-dynamic combinations passed. An empty Objective-C owner linked with -ObjC retains all shared APIs. A separate licensed XCFramework/SwiftPM distribution consumer builds for device/simulator/Catalyst and passes one ARM64 simulator test, reusing the matching Release framework archives. This does not enable complete React Native core SwiftPM/prebuilt adoption.

  • The final assembled package includes all 44 shared-module files and excludes shared generated build outputs and caches.

  • Retained unsigned iOS ARM64 Release source build at 9cc8379 passed. All three actual adapter objects call their shared APIs, and the app executable owns one Kotlin runtime. No physical-device execution is claimed.

  • Dynamic-framework RNTester on ARM64 iOS simulator passed 154 tests, with 16 upstream skips and no failures, and launched. The actual compiled scroll object references RNSScrollSnapOffsets, alongside the gradient and multipart adapters; React.framework owns the runtime once in the app load graph.

The cost run used 1d628d8e0d6; Android application validation used 586f67bafb46 plus a multipart test annotation. Their production algorithms, adapters and relevant build inputs match this layer. Retained Apple static-library, static-framework, unsigned device and Catalyst validation uses 9cc8379d0ad8. Updated hosted-test resolution comes from the gradient prerequisite; the scroll implementation and adapters remain unchanged. Dynamic execution is retained from 586f67bafb46: the changed CocoaPods hosted-test logic is outside its unchanged dynamic path, the dynamic runtime owner is unchanged, and the preserved binaries passed the ownership guard. It is not a new final-commit dynamic build or run. Common/framework validation at 265de92 and separate distribution/runtime checks at 772c54b have unchanged production inputs. These app tests cover the combined stack, rather than a scroll-only application.

Intel simulator execution remains deferred, and physical-device execution is unavailable. Full React Native core SwiftPM/prebuilt adoption is still guarded. Correctness/integration passes do not establish an accepted product performance budget.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant