Skip to content
Draft
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
88 changes: 60 additions & 28 deletions UPSTREAM_REACT_NATIVE_SCREENS.md
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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.
16 changes: 16 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,21 @@ class ReactNativeScrollInteropPackage : ReactPackage {
reactContext: ReactApplicationContext,
): List<ViewManager<*, *>> {
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.
}
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"files": [
"android/build.gradle",
"android/src/main",
"android/src/reactNativeScreensInterop",
"plugin",
"src",
"index.ts",
Expand Down
27 changes: 25 additions & 2 deletions scripts/check-architecture-boundaries.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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');
2 changes: 2 additions & 0 deletions scripts/check-package-surface.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down Expand Up @@ -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}`);
}
Expand Down
Loading