diff --git a/UPSTREAM_REACT_NATIVE_SCREENS.md b/UPSTREAM_REACT_NATIVE_SCREENS.md index 4da4d4e7..6af5f701 100644 --- a/UPSTREAM_REACT_NATIVE_SCREENS.md +++ b/UPSTREAM_REACT_NATIVE_SCREENS.md @@ -1,25 +1,21 @@ # react-native-screens upstream path -`react-native-scroll-interop` currently patches `react-native-screens 4.26.x` because a native-stack `Screen` is the real Android ancestor of the React Native vertical scroll source. The patch is intentionally a container adapter around the generic RN boundary; it is not Material3 behavior. +`react-native-scroll-interop` currently keeps a fail-closed compatibility patch for `react-native-screens 4.26.x`. The long-term target is an Android nested-scroll extension point owned by `react-native-screens`, with no dependency on this package or on Material3. -## Current adapter boundary +## What changed upstream -The local 4.26.x patch does only host ownership work: +Current `react-native-screens` development has already moved further than the 4.26.x architecture: -- `Screen` implements AndroidX `NestedScrollingParent3`; -- it creates `ReactNativeScreenNestedScrollBridge` with its native content root; -- attach/layout/detach lifecycle is forwarded to that bridge; -- Android nested-scroll callbacks are forwarded unchanged to that bridge; -- the patch has no direct controller/source-locator, Material3 or navigation-option knowledge; -- the patch fails closed outside the validated 4.26.x source shape. +- the stable path used by current React Navigation native-stack renders `ScreenStack` / `ScreenStackItem` and owns a `ScreensCoordinatorLayout` around each screen; +- the new Stack API owns a `StackHeaderCoordinatorLayout` around its screen; +- Stack v5 already uses Android nested scroll for its own Material3 AppBar behavior; +- `ScrollViewMarker`, `Container` and `ContainerItem` already let screens identify content scroll views for internal behavior. -`ReactNativeScreenNestedScrollBridge` is owned by the generic React Native boundary. It resolves the unique RN vertical source and delegates to `ReactNativeNestedScrollParentController`, which in turn sees native consumers only through neutral PRE/POST/observer participant ports. +Therefore the upstream proposal should not make `Screen` itself become a new nested-scroll parent. The correct seam is the CoordinatorLayout already owned by each stack path. -## Upstream-neutral seam +## Neutral seam -The upstream target should be owned by `react-native-screens` and expressed only in Android / AndroidX terms. It must not import `react-native-scroll-interop`, Material3, Expo Router or React Navigation. - -A suitable shape is an optional screen-owned nested-scroll delegate: +The prototype exposes an optional AndroidX-only delegate: ```kotlin interface ScreenNestedScrollDelegate : NestedScrollingParent3 { @@ -29,27 +25,63 @@ interface ScreenNestedScrollDelegate : NestedScrollingParent3 { } ``` -`Screen` remains the actual `NestedScrollingParent3` ancestor and forwards lifecycle plus nested-scroll callbacks to an optional delegate. The mechanism used to provide that delegate must be a `react-native-screens` API, not a dependency on this repository. +`react-native-screens` owns the container and always processes its existing behaviors first. Only the remaining nested-scroll distance is offered to the optional external delegate. + +The same internal forwarding base is used by: + +```text +current native-stack + ScreenStack / ScreenStackItem + -> ScreensCoordinatorLayout + -> existing screens behaviors first + -> optional external delegate + +new Stack API + StackScreen + -> StackHeaderCoordinatorLayout + -> existing screens Material/AppBar behavior first + -> optional external delegate +``` + +With no delegate installed, behavior is unchanged. + +## External integration + +`react-native-scroll-interop` can supply the optional delegate by adapting its existing `ReactNativeScreenNestedScrollBridge`. Source discovery, transaction accounting and native consumers remain outside `react-native-screens`. + +This is the intended ownership split: + +```text +react-native-screens + owns screen/container/navigation behavior + owns first right to consume its own nested-scroll behavior + +optional external integration + receives the remaining real Android nested-scroll transaction + does not replace React Native scroll physics +``` -A host integration such as `react-native-scroll-interop` can then supply a delegate that owns source discovery and transaction dispatch. Other libraries can provide different delegates or none at all. +The external adapter is compiled only when the installed `react-native-screens` source actually contains the upstream seam. The existing 4.26.x compatibility path remains available until an official screens release ships the new API. -## Required upstream invariants +## Required invariants Any upstream implementation must preserve these properties: -1. The React Native scroll view remains the touch, position and fling-physics owner. -2. `Screen` does not call `scrollBy`, `scrollTo` or reconstruct momentum from JS events. -3. Android nested-scroll `type` (`TOUCH` / `NON_TOUCH`) is forwarded unchanged. -4. Parent3 `consumed` accounting is forwarded unchanged. -5. Attach/detach/layout and source replacement remain explicit lifecycle events. -6. The API contains no Material3, Expo Router or React Navigation concepts. -7. With no delegate installed, existing `react-native-screens` behavior is unchanged. +1. React Native remains the owner of touch handling, source position and fling physics. +2. `react-native-screens` does not call `scrollBy`, `scrollTo` or reconstruct momentum from JS events for this integration. +3. Existing screens-owned AppBar, form-sheet and bottom-sheet behavior runs before an external delegate. +4. Android nested-scroll `type` (`TOUCH` / `NON_TOUCH`) is preserved. +5. Parent3 consumed-distance accounting is preserved; an external delegate receives only distance still available after screens-owned behavior. +6. Attach, layout and detach are explicit lifecycle events. +7. The public seam contains no Material3, Expo Router, React Navigation or `react-native-scroll-interop` concepts. +8. With no delegate installed, existing behavior is unchanged. ## Migration plan 1. Keep the current fail-closed 4.26.x patcher as the certified compatibility adapter. -2. Propose the neutral delegate seam upstream to `react-native-screens`. -3. Add a version-gated integration for the first upstream release containing that seam. -4. Remove source patching for supported upstream versions while retaining the 4.26.x compatibility path for the alpha line. +2. Validate the neutral container-level seam against both current native-stack and the new Stack API. +3. Propose the minimal generic change upstream to `react-native-screens`. +4. Add a release-gated external adapter once an official screens version contains the seam. +5. Stop source-patching supported new screens versions while retaining the 4.26.x compatibility route for the alpha line. -This document is the upstream contract: the eventual upstream change is a generic Android nested-scroll extension point, while transaction semantics and consumers remain outside `react-native-screens`. +The goal is not to upstream a toolbar. The goal is to make the real Android nested-scroll transaction available at the navigation container boundary so independent native integrations can participate without `react-native-screens` knowing what those integrations are. diff --git a/android/build.gradle b/android/build.gradle index 3f483e71..974f0ec4 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -21,6 +21,14 @@ apply plugin: 'org.jetbrains.kotlin.plugin.compose' group = 'com.reactnativescroll.interop' version = '0.1.0-alpha.1' +def reactNativeScreensProject = rootProject.findProject(':react-native-screens') +def reactNativeScreensNestedScrollInteropAvailable = + reactNativeScreensProject != null && + new File( + reactNativeScreensProject.projectDir, + 'src/main/java/com/swmansion/rnscreens/common/nestedscroll/ScreenNestedScrollInterop.kt' + ).exists() + android { namespace 'com.reactnativescroll.interop' compileSdkVersion rootProject.ext.has('compileSdkVersion') @@ -53,11 +61,19 @@ android { lintOptions { abortOnError false } + + if (reactNativeScreensNestedScrollInteropAvailable) { + sourceSets.main.java.srcDir('src/reactNativeScreensInterop/java') + } } dependencies { implementation("com.facebook.react:react-android") + if (reactNativeScreensNestedScrollInteropAvailable) { + compileOnly reactNativeScreensProject + } + implementation 'androidx.compose.material3:material3:1.5.0-alpha17' implementation 'io.coil-kt.coil3:coil-compose:3.3.0' implementation 'io.coil-kt.coil3:coil-network-okhttp:3.3.0' diff --git a/android/src/main/java/com/reactnativescroll/interop/reactnative/ReactNativeScrollInteropPackage.kt b/android/src/main/java/com/reactnativescroll/interop/reactnative/ReactNativeScrollInteropPackage.kt index 59f9d927..91d30cde 100644 --- a/android/src/main/java/com/reactnativescroll/interop/reactnative/ReactNativeScrollInteropPackage.kt +++ b/android/src/main/java/com/reactnativescroll/interop/reactnative/ReactNativeScrollInteropPackage.kt @@ -23,10 +23,21 @@ class ReactNativeScrollInteropPackage : ReactPackage { reactContext: ReactApplicationContext, ): List> { ReactNativeNestedScrollParticipants.install(Material3NestedScrollParticipantProvider) + installReactNativeScreensNestedScrollInteropIfAvailable() return listOf( ReactNativeNestedScrollHostManager(), MaterialTopAppBarManager(), MaterialToolbarManager(), ) } + + private fun installReactNativeScreensNestedScrollInteropIfAvailable() { + try { + Class.forName( + "com.reactnativescroll.interop.rnscreens.ReactNativeScreensNestedScrollInstaller", + ).getMethod("install").invoke(null) + } catch (_: ClassNotFoundException) { + // react-native-screens is absent or does not expose the upstream nested-scroll seam. + } + } } diff --git a/android/src/reactNativeScreensInterop/java/com/reactnativescroll/interop/rnscreens/ReactNativeScreensNestedScrollInstaller.kt b/android/src/reactNativeScreensInterop/java/com/reactnativescroll/interop/rnscreens/ReactNativeScreensNestedScrollInstaller.kt new file mode 100644 index 00000000..781a4f92 --- /dev/null +++ b/android/src/reactNativeScreensInterop/java/com/reactnativescroll/interop/rnscreens/ReactNativeScreensNestedScrollInstaller.kt @@ -0,0 +1,134 @@ +package com.reactnativescroll.interop.rnscreens + +import android.view.View +import android.view.ViewGroup +import com.reactnativescroll.interop.reactnative.ReactNativeScreenNestedScrollBridge +import com.swmansion.rnscreens.common.nestedscroll.ScreenNestedScrollDelegate +import com.swmansion.rnscreens.common.nestedscroll.ScreenNestedScrollDelegateFactory +import com.swmansion.rnscreens.common.nestedscroll.ScreenNestedScrollInterop + +object ReactNativeScreensNestedScrollInstaller { + private val factory = + ScreenNestedScrollDelegateFactory { screen -> + BridgeDelegate( + ReactNativeScreenNestedScrollBridge( + owner = screen, + isEnabled = { true }, + sourceRoot = { screen }, + ), + ) + } + + @JvmStatic + fun install() { + ScreenNestedScrollInterop.installFactory(factory) + } +} + +private class BridgeDelegate( + private val bridge: ReactNativeScreenNestedScrollBridge, +) : ScreenNestedScrollDelegate { + override fun onScreenAttached(screen: ViewGroup) = bridge.onOwnerAttached() + + override fun onScreenDetached(screen: ViewGroup) = bridge.onOwnerDetached() + + override fun onScreenLayout(screen: ViewGroup) = bridge.onOwnerLayout() + + override fun onStartNestedScroll( + child: View, + target: View, + axes: Int, + ): Boolean = bridge.onStartNestedScroll(child, target, axes) + + override fun onNestedScrollAccepted( + child: View, + target: View, + axes: Int, + ) = bridge.onNestedScrollAccepted(child, target, axes) + + override fun onStopNestedScroll(target: View) = bridge.onStopNestedScroll(target) + + override fun onNestedPreScroll( + target: View, + dx: Int, + dy: Int, + consumed: IntArray, + ) = bridge.onNestedPreScroll(target, dx, dy, consumed) + + override fun onNestedScroll( + target: View, + dxConsumed: Int, + dyConsumed: Int, + dxUnconsumed: Int, + dyUnconsumed: Int, + ) = bridge.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed) + + override fun onNestedPreFling( + target: View, + velocityX: Float, + velocityY: Float, + ): Boolean = bridge.onNestedPreFling(target, velocityX, velocityY) + + override fun onNestedFling( + target: View, + velocityX: Float, + velocityY: Float, + consumed: Boolean, + ): Boolean = bridge.onNestedFling(target, velocityX, velocityY, consumed) + + override fun getNestedScrollAxes(): Int = bridge.getNestedScrollAxes() + + override fun onStartNestedScroll( + child: View, + target: View, + axes: Int, + type: Int, + ): Boolean = bridge.onStartNestedScroll(child, target, axes, type) + + override fun onNestedScrollAccepted( + child: View, + target: View, + axes: Int, + type: Int, + ) = bridge.onNestedScrollAccepted(child, target, axes, type) + + override fun onStopNestedScroll( + target: View, + type: Int, + ) = bridge.onStopNestedScroll(target, type) + + override fun onNestedPreScroll( + target: View, + dx: Int, + dy: Int, + consumed: IntArray, + type: Int, + ) = bridge.onNestedPreScroll(target, dx, dy, consumed, type) + + override fun onNestedScroll( + target: View, + dxConsumed: Int, + dyConsumed: Int, + dxUnconsumed: Int, + dyUnconsumed: Int, + type: Int, + ) = bridge.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type) + + override fun onNestedScroll( + target: View, + dxConsumed: Int, + dyConsumed: Int, + dxUnconsumed: Int, + dyUnconsumed: Int, + type: Int, + consumed: IntArray, + ) = bridge.onNestedScroll( + target, + dxConsumed, + dyConsumed, + dxUnconsumed, + dyUnconsumed, + type, + consumed, + ) +} diff --git a/package.json b/package.json index 01a96295..d5934926 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "files": [ "android/build.gradle", "android/src/main", + "android/src/reactNativeScreensInterop", "plugin", "src", "index.ts", diff --git a/scripts/check-architecture-boundaries.mjs b/scripts/check-architecture-boundaries.mjs index 6fa9266d..e30dc576 100644 --- a/scripts/check-architecture-boundaries.mjs +++ b/scripts/check-architecture-boundaries.mjs @@ -73,7 +73,23 @@ for (const forbidden of ['TopAppBar', 'FloatingToolbar', 'Material3NestedScroll' forbid(corePath, controllerCore, forbidden, `consumer-specific controller symbol ${forbidden}`); } -// react-native-screens knows one RN-neutral bridge only. +// The shipped Android main source set remains screens-independent. Only the optional source set may +// import the upstream screens seam. +for (const file of filesUnder('android/src/main/java', '.kt')) { + forbid(file, read(file), 'com.swmansion.rnscreens', 'react-native-screens main-source dependency'); +} +const upstreamScreensAdapterPath = + 'android/src/reactNativeScreensInterop/java/com/reactnativescroll/interop/rnscreens/ReactNativeScreensNestedScrollInstaller.kt'; +const upstreamScreensAdapter = read(upstreamScreensAdapterPath); +for (const marker of [ + 'ReactNativeScreenNestedScrollBridge', + 'ScreenNestedScrollDelegate', + 'ScreenNestedScrollDelegateFactory', + 'ScreenNestedScrollInterop.installFactory(factory)', +]) requireMarker(upstreamScreensAdapterPath, upstreamScreensAdapter, marker); +forbid(upstreamScreensAdapterPath, upstreamScreensAdapter, 'com.reactnativescroll.interop.material3', 'Material3 screens-adapter coupling'); + +// react-native-screens 4.26 compatibility patch knows one RN-neutral bridge only. const screensPath = 'plugin/reactNativeScreensInteropPatch.js'; const screens = read(screensPath); requireMarker(screensPath, screens, 'ReactNativeScreenNestedScrollBridge'); @@ -105,7 +121,13 @@ requireMarker( packageRoot, 'ReactNativeNestedScrollParticipants.install(Material3NestedScrollParticipantProvider)' ); +requireMarker( + packagePath, + packageRoot, + 'installReactNativeScreensNestedScrollInteropIfAvailable()' +); forbid(packagePath, packageRoot, 'expo.modules', 'Expo composition dependency'); +forbid(packagePath, packageRoot, 'import com.swmansion.rnscreens', 'direct screens composition dependency'); // Navigation semantics live in one navigator-neutral mapper/header renderer. const mapperPath = 'src/navigation/material3NavigationMapper.ts'; @@ -169,7 +191,8 @@ if (violations.length) { console.log('Architecture boundary invariant: PASS'); console.log(' neutral core has no RN/Material/Expo dependency'); console.log(' RN transport has no Material3/screens/Expo dependency'); -console.log(' react-native-screens integrates one neutral RN screen bridge'); +console.log(' screens upstream integration is isolated to the optional Android source set'); +console.log(' react-native-screens 4.26 compatibility patch integrates one neutral RN screen bridge'); console.log(' Material3 is installed through the neutral participant provider'); console.log(' Expo Router and React Navigation share mapper/header semantics'); console.log(' Expo Modules runtime/implementation tree remains absent'); diff --git a/scripts/check-package-surface.mjs b/scripts/check-package-surface.mjs index e28ab29f..a2e3219a 100644 --- a/scripts/check-package-surface.mjs +++ b/scripts/check-package-surface.mjs @@ -37,6 +37,7 @@ expect(pkg.peerDependencies?.['react-native'] === '>=0.86.0 <0.87.0 || >=0.87.0- expect(gradle.includes("namespace 'com.reactnativescroll.interop'"), 'Android namespace must be neutral'); expect(!gradle.includes('expo-module-gradle-plugin'), 'Expo Modules Gradle plugin must stay removed'); expect(rnConfig.includes('ReactNativeScrollInteropPackage'), 'standard RN autolinking package missing'); +expect(pkg.files?.includes('android/src/reactNativeScreensInterop'), 'optional screens upstream adapter source must ship'); expect(!pkg.files?.includes('navigation.ts'), 'shared navigation mapper must not add a third public entry point'); for (const obsolete of [ @@ -99,6 +100,7 @@ for (const required of [ 'android/src/main/java/com/reactnativescroll/interop/material3/ui/MaterialToolbarView.kt', 'android/src/main/java/com/reactnativescroll/interop/material3/ui/MaterialToolbarManager.kt', 'android/src/main/java/com/reactnativescroll/interop/material3/ui/NativeNestedScrollRegistry.kt', + 'android/src/reactNativeScreensInterop/java/com/reactnativescroll/interop/rnscreens/ReactNativeScreensNestedScrollInstaller.kt', ]) { expect(files.has(required), `missing package file: ${required}`); }