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
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.reactnativescroll.interop.reactnative

import android.view.View
import android.view.ViewGroup

/**
* POC boundary for a Compose scrollable that participates in Android View nested scrolling through
* rememberNestedScrollInteropConnection().
*
* Compose's default interop connection is created from LocalView.current. In a hosted composition
* that callback target is commonly the internal AndroidComposeView, while the outer Android view
* hierarchy still contains the enclosing ComposeView. Keep this detection reflection-free and
* compile-time Compose-free by recognizing the target through its ancestor chain.
*/
internal object ComposeVerticalScrollSourceInterop {
private const val COMPOSE_VIEW_CLASS = "androidx.compose.ui.platform.ComposeView"
private const val ANDROID_COMPOSE_VIEW_CLASS = "androidx.compose.ui.platform.AndroidComposeView"

fun asSupported(source: View): ViewGroup? {
val group = source as? ViewGroup ?: return null
return group.takeIf { isComposeNestedScrollTarget(source) }
}

private fun isComposeNestedScrollTarget(source: View): Boolean {
if (hasClassInHierarchy(source, COMPOSE_VIEW_CLASS) ||
hasClassInHierarchy(source, ANDROID_COMPOSE_VIEW_CLASS)
) {
return true
}

var ancestor = source.parent as? View
while (ancestor != null) {
if (hasClassInHierarchy(ancestor, COMPOSE_VIEW_CLASS)) return true
ancestor = ancestor.parent as? View
}
return false
}

private fun hasClassInHierarchy(view: View, expectedName: String): Boolean {
var type: Class<*>? = view.javaClass
while (type != null) {
if (type.name == expectedName) return true
type = type.superclass
}
return false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,10 @@ internal class ReactNativeNestedScrollControllerCore(
}

private fun beginSession(target: View, type: Int) {
val capabilities = ReactVerticalScrollSourceInterop.resolve(target) ?: return
val source = capabilities.view
val reactCapabilities = ReactVerticalScrollSourceInterop.resolve(target)
val source = reactCapabilities?.view
?: ComposeVerticalScrollSourceInterop.asSupported(target)
?: return
val replacement = lifecycle.begin(source, type)
if (replacement != null) {
flushPendingLedger("source-replaced")
Expand All @@ -238,7 +240,7 @@ internal class ReactNativeNestedScrollControllerCore(
flushPendingLedger("session-rebind")
preCount = 0
postCount = 0
activeCapabilities = capabilities
activeCapabilities = reactCapabilities
activeSession = ReactNativeNestedScrollParticipants.bind(source)
val session = activeSession ?: return
dispatcher.bindParticipants(
Expand Down
60 changes: 60 additions & 0 deletions examples/expo/app/expo-ui-lazy-column.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Column, Host, LazyColumn, Text } from '@expo/ui/jetpack-compose';
import {
fillMaxSize,
fillMaxWidth,
height,
paddingAll,
} from '@expo/ui/jetpack-compose/modifiers';
import type { ComponentProps } from 'react';
import { StyleSheet, View } from 'react-native';

import { MaterialToolbar, NativeScrollHost } from 'react-native-scroll-interop';

const ROWS = Array.from({ length: 80 }, (_, index) => `Compose row ${index + 1}`);

export default function ExpoUiLazyColumnPoc() {
const hostProps = { style: styles.host } as unknown as ComponentProps<typeof NativeScrollHost>;

return (
<View style={styles.root}>
<NativeScrollHost {...hostProps}>
<Host style={styles.host}>
<LazyColumn
contentPadding={{ top: 20, bottom: 140 }}
verticalArrangement={{ spacedBy: 4 }}
modifiers={[fillMaxSize()]}
>
{ROWS.map((row) => (
<Column
key={row}
verticalArrangement="center"
modifiers={[fillMaxWidth(), height(72), paddingAll(20)]}
>
<Text style={{ typography: 'titleMedium' }}>{row}</Text>
<Text style={{ typography: 'bodySmall' }}>
Expo UI LazyColumn · native Compose scroll
</Text>
</Column>
))}
</LazyColumn>
</Host>
</NativeScrollHost>

<MaterialToolbar.Root placement="bottom" insets="none" scrollBehavior="exitAlways">
<MaterialToolbar.Content>
<MaterialToolbar.TextButton accessibilityLabel="LazyColumn POC" selected>
<MaterialToolbar.Text>LazyColumn</MaterialToolbar.Text>
</MaterialToolbar.TextButton>
<MaterialToolbar.TextButton accessibilityLabel="Expo UI">
<MaterialToolbar.Text>Expo UI</MaterialToolbar.Text>
</MaterialToolbar.TextButton>
</MaterialToolbar.Content>
</MaterialToolbar.Root>
</View>
);
}

const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: '#101318' },
host: { flex: 1 },
});
3 changes: 3 additions & 0 deletions examples/expo/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export default function ExampleIndex() {
<Pressable style={styles.link} onPress={() => router.push('/standalone')}>
<Text style={styles.linkText}>Standalone fallback</Text>
</Pressable>
<Pressable style={styles.link} onPress={() => router.push('/expo-ui-lazy-column')}>
<Text style={styles.linkText}>Expo UI LazyColumn POC</Text>
</Pressable>
</View>
);
}
Expand Down
15 changes: 13 additions & 2 deletions examples/expo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,21 @@
"main": "expo-router/entry",
"scripts": {
"start": "expo start --dev-client",
"android": "expo run:android",
"prebuild": "expo prebuild -p android --clean"
"postinstall": "node ./scripts/patch-expo-ui-nested-scroll-interop.mjs",
"android": "node ./scripts/patch-expo-ui-nested-scroll-interop.mjs && expo run:android",
"prebuild": "node ./scripts/patch-expo-ui-nested-scroll-interop.mjs && expo prebuild -p android --clean"
},
"expo": {
"autolinking": {
"android": {
"buildFromSource": [
"@expo/ui"
]
}
}
},
"dependencies": {
"@expo/ui": "57.0.12",
"expo": "~57.0.15",
"expo-constants": "~57.0.13",
"expo-dev-client": "~57.0.14",
Expand Down
185 changes: 185 additions & 0 deletions examples/expo/scripts/patch-expo-ui-nested-scroll-interop.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';

const here = path.dirname(fileURLToPath(import.meta.url));
const exampleRoot = path.resolve(here, '..');
const requireFromExample = createRequire(path.join(exampleRoot, 'package.json'));
const packageRoots = new Set();

function addPackageRoot(candidate, reason) {
if (!candidate || !fs.existsSync(candidate)) return;
const resolved = fs.realpathSync(candidate);
packageRoots.add(resolved);
console.log(`[expo-ui-poc] ${reason}: ${resolved}`);
}

// First use Node's own resolution from the example app. This should match Metro's package choice.
try {
const packageJsonPath = requireFromExample.resolve('@expo/ui/package.json');
addPackageRoot(path.dirname(packageJsonPath), 'node-resolved @expo/ui');
} catch (error) {
console.warn(`[expo-ui-poc] Node could not resolve @expo/ui: ${error.message}`);
}

// Then ask Expo Autolinking which native package path it discovered. This is the important one for Gradle.
const autolinkingBin = path.join(
exampleRoot,
'node_modules',
'.bin',
process.platform === 'win32' ? 'expo-modules-autolinking.cmd' : 'expo-modules-autolinking'
);

if (fs.existsSync(autolinkingBin)) {
try {
const output = execFileSync(autolinkingBin, ['search'], {
cwd: exampleRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
const searchResult = JSON.parse(output);
const expoUi = searchResult['@expo/ui'];
addPackageRoot(expoUi?.path, 'autolink-resolved @expo/ui');
for (const duplicate of expoUi?.duplicates ?? []) {
addPackageRoot(duplicate?.path, 'autolink duplicate @expo/ui');
}
} catch (error) {
console.warn(`[expo-ui-poc] Expo Autolinking search failed: ${error.message}`);
}
} else {
console.warn(`[expo-ui-poc] Expo Autolinking binary not found at ${autolinkingBin}`);
}

// Keep explicit fallbacks for SDK 57 layouts where @expo/ui is hoisted or nested under expo-router.
addPackageRoot(path.join(exampleRoot, 'node_modules', '@expo', 'ui'), 'top-level fallback');
addPackageRoot(
path.join(exampleRoot, 'node_modules', 'expo-router', 'node_modules', '@expo', 'ui'),
'expo-router fallback'
);

if (packageRoots.size === 0) {
throw new Error(
'Expo UI was not found. Run npm install in examples/expo before running this POC.'
);
}

function ensureImport(source, line, marker) {
if (source.includes(`${line}\n`)) return source;
if (!source.includes(marker)) {
throw new Error(`[expo-ui-poc] import marker changed while adding ${line}`);
}
return source.replace(marker, `${line}\n${marker}`);
}

function patchLazyColumn(packageRoot) {
const target = path.join(
packageRoot,
'android',
'src',
'main',
'java',
'expo',
'modules',
'ui',
'LazyColumnView.kt'
);
if (!fs.existsSync(target)) return false;

let source = fs.readFileSync(target, 'utf8');
if (source.includes('EXPO_UI_LAZY_INTEROP attached')) {
console.log(`[expo-ui-poc] LazyColumn interop already patched: ${target}`);
return true;
}

source = ensureImport(source, 'import android.util.Log', 'import android.view.View\n');
source = ensureImport(source, 'import androidx.compose.runtime.remember', 'import androidx.compose.runtime.mutableStateOf\n');
source = ensureImport(source, 'import androidx.compose.ui.geometry.Offset', 'import androidx.compose.ui.Alignment\n');
source = ensureImport(
source,
'import androidx.compose.ui.input.nestedscroll.NestedScrollConnection',
'import androidx.compose.ui.unit.dp\n'
);
source = ensureImport(
source,
'import androidx.compose.ui.input.nestedscroll.NestedScrollSource',
'import androidx.compose.ui.unit.dp\n'
);
source = ensureImport(
source,
'import androidx.compose.ui.input.nestedscroll.nestedScroll',
'import androidx.compose.ui.unit.dp\n'
);
source = ensureImport(
source,
'import androidx.compose.ui.platform.rememberNestedScrollInteropConnection',
'import androidx.compose.ui.unit.dp\n'
);
source = ensureImport(source, 'import androidx.compose.ui.unit.Velocity', 'import androidx.compose.ui.unit.dp\n');

const marker =
' val padding = props.contentPadding.value\n\n' +
' LazyColumn(\n' +
' modifier = ModifierRegistry.applyModifiers(props.modifiers.value, appContext, this@Content, globalEventDispatcher),\n';

if (!source.includes(marker)) {
throw new Error(`[expo-ui-poc] SDK 57 LazyColumnView marker changed at ${target}`);
}

const replacement =
' val padding = props.contentPadding.value\n\n' +
' val interop = rememberNestedScrollInteropConnection()\n' +
' val tracedInterop = remember(interop) {\n' +
' object : NestedScrollConnection {\n' +
' override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {\n' +
' Log.d("ReactNativeScrollInterop", "EXPO_UI_LAZY_INTEROP pre availableY=${available.y} source=$source")\n' +
' return interop.onPreScroll(available, source)\n' +
' }\n\n' +
' override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {\n' +
' Log.d("ReactNativeScrollInterop", "EXPO_UI_LAZY_INTEROP post consumedY=${consumed.y} availableY=${available.y} source=$source")\n' +
' return interop.onPostScroll(consumed, available, source)\n' +
' }\n\n' +
' override suspend fun onPreFling(available: Velocity): Velocity {\n' +
' Log.d("ReactNativeScrollInterop", "EXPO_UI_LAZY_INTEROP preFling y=${available.y}")\n' +
' return interop.onPreFling(available)\n' +
' }\n\n' +
' override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {\n' +
' Log.d("ReactNativeScrollInterop", "EXPO_UI_LAZY_INTEROP postFling consumedY=${consumed.y} availableY=${available.y}")\n' +
' return interop.onPostFling(consumed, available)\n' +
' }\n' +
' }\n' +
' }\n' +
' Log.d("ReactNativeScrollInterop", "EXPO_UI_LAZY_INTEROP attached")\n\n' +
' LazyColumn(\n' +
' modifier = ModifierRegistry.applyModifiers(props.modifiers.value, appContext, this@Content, globalEventDispatcher)\n' +
' .nestedScroll(tracedInterop),\n';

source = source.replace(marker, replacement);
fs.writeFileSync(target, source);
console.log(`[expo-ui-poc] patched LazyColumn interop: ${target}`);
return true;
}

const patchedRoots = [...packageRoots].filter(patchLazyColumn);
if (patchedRoots.length === 0) {
throw new Error('Expo UI LazyColumnView.kt was not found in any resolved @expo/ui package.');
}

for (const packageRoot of patchedRoots) {
const target = path.join(
packageRoot,
'android',
'src',
'main',
'java',
'expo',
'modules',
'ui',
'LazyColumnView.kt'
);
const verified = fs.readFileSync(target, 'utf8').includes('EXPO_UI_LAZY_INTEROP attached');
if (!verified) throw new Error(`[expo-ui-poc] patch verification failed: ${target}`);
}

console.log(`[expo-ui-poc] verified ${patchedRoots.length} Expo UI native package path(s)`);
Loading