diff --git a/android/src/main/java/com/reactnativescroll/interop/reactnative/ComposeVerticalScrollSourceInterop.kt b/android/src/main/java/com/reactnativescroll/interop/reactnative/ComposeVerticalScrollSourceInterop.kt new file mode 100644 index 00000000..e9daf533 --- /dev/null +++ b/android/src/main/java/com/reactnativescroll/interop/reactnative/ComposeVerticalScrollSourceInterop.kt @@ -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 + } +} diff --git a/android/src/main/java/com/reactnativescroll/interop/reactnative/ReactNativeNestedScrollControllerCore.kt b/android/src/main/java/com/reactnativescroll/interop/reactnative/ReactNativeNestedScrollControllerCore.kt index ddee10c1..3cc95c0a 100644 --- a/android/src/main/java/com/reactnativescroll/interop/reactnative/ReactNativeNestedScrollControllerCore.kt +++ b/android/src/main/java/com/reactnativescroll/interop/reactnative/ReactNativeNestedScrollControllerCore.kt @@ -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") @@ -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( diff --git a/examples/expo/app/expo-ui-lazy-column.tsx b/examples/expo/app/expo-ui-lazy-column.tsx new file mode 100644 index 00000000..5fc50b70 --- /dev/null +++ b/examples/expo/app/expo-ui-lazy-column.tsx @@ -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; + + return ( + + + + + {ROWS.map((row) => ( + + {row} + + Expo UI LazyColumn ยท native Compose scroll + + + ))} + + + + + + + + LazyColumn + + + Expo UI + + + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: '#101318' }, + host: { flex: 1 }, +}); diff --git a/examples/expo/app/index.tsx b/examples/expo/app/index.tsx index e973b81c..fc198798 100644 --- a/examples/expo/app/index.tsx +++ b/examples/expo/app/index.tsx @@ -13,6 +13,9 @@ export default function ExampleIndex() { router.push('/standalone')}> Standalone fallback + router.push('/expo-ui-lazy-column')}> + Expo UI LazyColumn POC + ); } diff --git a/examples/expo/package.json b/examples/expo/package.json index 4f3d3549..b6ea75c2 100644 --- a/examples/expo/package.json +++ b/examples/expo/package.json @@ -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", diff --git a/examples/expo/scripts/patch-expo-ui-nested-scroll-interop.mjs b/examples/expo/scripts/patch-expo-ui-nested-scroll-interop.mjs new file mode 100644 index 00000000..0d4ac5d1 --- /dev/null +++ b/examples/expo/scripts/patch-expo-ui-nested-scroll-interop.mjs @@ -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)`);