From b9c616efe173083f9072965fba391cb0f6af8659 Mon Sep 17 00:00:00 2001 From: Hyunwoo Jung Date: Sun, 27 Sep 2026 15:08:52 +0900 Subject: [PATCH] fix(app): keep the Android chat still when answer text is tapped or long-pressed Tapping selectable text in a long agent answer, or long-pressing it to copy, scrolled the chat away: text near the top of an answer sent the list to the bottom, text near its end sent it back up. Selectable text takes focus on a tap and on a long-press, and the ScrollView then brings the focused view into view. It works out where that view is from layout positions alone (ReactScrollView.scrollToChild, View.requestRectangleOnScreen), skipping every transform. An inverted FlashList rotates each row by 180 degrees, so inside a row the position comes out mirrored across the row's middle. In an answer taller than the screen the mirrored position is off screen, and the list jumps there. The vertical ScrollView is now a ReactScrollView subclass. When a transform sits between the focused view and the scroll view, it maps rectangles through each view's matrix, so dragging a selection handle still scrolls toward where the text really is. In touch mode it does not scroll for a focus change at all: focus lands under the finger, or the framework moves it on its own when a recycled row takes the focused text with it, handing it to the first focusable view, far from the reader. Keyboard navigation still scrolls, mapped the same way. Scroll views with no transform in the path behave exactly as before. A config plugin copies the Kotlin sources into the generated project and registers the package ahead of MainReactPackage, because React Native uses the first package that answers for "RCTScrollView". This needs a native build; an OTA update cannot carry it. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Happy --- packages/happy-app/app.config.js | 1 + .../TransformAwareScrollPackage.kt | 36 ++++ .../TransformAwareScrollView.kt | 159 ++++++++++++++++++ .../plugins/withTransformAwareScrollView.js | 63 +++++++ 4 files changed, 259 insertions(+) create mode 100644 packages/happy-app/plugins/transformAwareScrollView/TransformAwareScrollPackage.kt create mode 100644 packages/happy-app/plugins/transformAwareScrollView/TransformAwareScrollView.kt create mode 100644 packages/happy-app/plugins/withTransformAwareScrollView.js diff --git a/packages/happy-app/app.config.js b/packages/happy-app/app.config.js index 698afda27ad..3aec2dcb117 100644 --- a/packages/happy-app/app.config.js +++ b/packages/happy-app/app.config.js @@ -135,6 +135,7 @@ export default { }, plugins: [ require("./plugins/withEinkCompatibility.js"), + require("./plugins/withTransformAwareScrollView.js"), [ "expo-router", { diff --git a/packages/happy-app/plugins/transformAwareScrollView/TransformAwareScrollPackage.kt b/packages/happy-app/plugins/transformAwareScrollView/TransformAwareScrollPackage.kt new file mode 100644 index 00000000000..782ce87d045 --- /dev/null +++ b/packages/happy-app/plugins/transformAwareScrollView/TransformAwareScrollPackage.kt @@ -0,0 +1,36 @@ +package engineering.happy.scroll + +import com.facebook.react.ReactPackage +import com.facebook.react.ViewManagerOnDemandReactPackage +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewManager +import com.facebook.react.views.scroll.ReactScrollView +import com.facebook.react.views.scroll.ReactScrollViewManager + +/** The stock vertical ScrollView manager, creating [TransformAwareScrollView] instead. */ +class TransformAwareScrollViewManager : ReactScrollViewManager() { + override fun createViewInstance(context: ThemedReactContext): ReactScrollView = + TransformAwareScrollView(context) +} + +/** + * Answers for "RCTScrollView", the vertical ScrollView. React Native's bridgeless + * resolver takes the first on-demand package that answers for a name, so this + * package has to come before MainReactPackage in the package list; + * withTransformAwareScrollView inserts it at index 0. + */ +class TransformAwareScrollPackage : ReactPackage, ViewManagerOnDemandReactPackage { + override fun createViewManagers( + reactContext: ReactApplicationContext, + ): List> = listOf(TransformAwareScrollViewManager()) + + override fun getViewManagerNames(reactContext: ReactApplicationContext): Collection = + listOf(ReactScrollViewManager.REACT_CLASS) + + override fun createViewManager( + reactContext: ReactApplicationContext, + viewManagerName: String, + ): ViewManager? = + if (viewManagerName == ReactScrollViewManager.REACT_CLASS) TransformAwareScrollViewManager() else null +} diff --git a/packages/happy-app/plugins/transformAwareScrollView/TransformAwareScrollView.kt b/packages/happy-app/plugins/transformAwareScrollView/TransformAwareScrollView.kt new file mode 100644 index 00000000000..ad51738235e --- /dev/null +++ b/packages/happy-app/plugins/transformAwareScrollView/TransformAwareScrollView.kt @@ -0,0 +1,159 @@ +package engineering.happy.scroll + +import android.content.Context +import android.graphics.Rect +import android.graphics.RectF +import android.view.View +import com.facebook.react.views.scroll.ReactScrollView + +/** + * A ReactScrollView that accounts for view transforms when it scrolls a focused + * descendant into view. + * + * Android works out where a descendant sits from layout positions alone. Both + * ReactScrollView.scrollToChild (offsetDescendantRectToMyCoords) and + * View.requestRectangleOnScreen add up each view's left/top and skip its + * rotation and scale. An inverted FlashList rotates every row by 180 degrees, so + * inside a row that math mirrors the descendant across the row's middle. + * Selectable text takes focus on a tap and on a long-press, and in a message + * taller than the screen the mirrored position is off screen: the list jumps to + * the far end of the message, taking the text out from under the finger. + * + * When a transform sits between the focused view and this scroll view: + * - Rectangle requests from the focused view (a selection handle being dragged, + * a text cursor) are mapped through every view's matrix, so the scroll aims at + * where the text is actually drawn. + * - A focus change scrolls nothing in touch mode. There, focus arrives either + * under the finger, already on screen, or by the framework's own choice: when + * a recycled row takes the focused text with it, ViewGroup.removeViewInternal + * hands focus to the first focusable view it finds, which in a long chat is + * usually nowhere near the reader. Keyboard and D-pad navigation, outside + * touch mode, still bring the newly focused view into view, mapped the same way. + * With no transform in the way, every override defers to ReactScrollView unchanged. + */ +class TransformAwareScrollView(context: Context) : ReactScrollView(context) { + + private var hideFocusFromResize = false + + override fun requestChildFocus(child: View, focused: View) { + if (!hasTransformBetween(focused)) { + super.requestChildFocus(child, focused) + return + } + if (!isInTouchMode) scrollFocusedIntoView(focused) + requestChildFocusWithoutScroll(child, focused) + } + + override fun requestChildRectangleOnScreen(child: View, rectangle: Rect, immediate: Boolean): Boolean { + val focused = super.findFocus() + val drawn = if (focused != null && focused !== this && hasTransformBetween(focused)) { + drawnRectOfFocusedRequest(focused, child, rectangle) + } else { + null + } + if (drawn == null) { + return super.requestChildRectangleOnScreen(child, rectangle, immediate) + } + val delta = computeScrollDeltaToGetChildRectOnScreen(drawn) + if (delta != 0) { + if (immediate) scrollBy(0, delta) else smoothScrollBy(0, delta) + } + return delta != 0 + } + + override fun findFocus(): View? = if (hideFocusFromResize) null else super.findFocus() + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + val focused = super.findFocus() + if (focused == null || focused === this || !hasTransformBetween(focused)) { + super.onSizeChanged(w, h, oldw, oldh) + return + } + // ScrollView keeps a focused descendant visible across a resize with the + // same layout-only math, so it would aim at the mirrored position. Hide + // the focus from that one pass; everything else in the resize still runs. + hideFocusFromResize = true + try { + super.onSizeChanged(w, h, oldw, oldh) + } finally { + hideFocusFromResize = false + } + } + + /** ReactScrollView.scrollToChild, with the rectangle mapped through transforms. */ + private fun scrollFocusedIntoView(focused: View) { + // Like ReactScrollView, bring the outermost nested scroll view into view + // rather than the focused view itself; that one scrolls its own content. + var target = focused + var parent = focused.parent + while (parent is View && parent !== this) { + if (parent is ReactScrollView) target = parent + parent = parent.parent + } + val rect = RectF(0f, 0f, target.width.toFloat(), target.height.toFloat()) + if (!mapToContent(target, rect)) return + val delta = computeScrollDeltaToGetChildRectOnScreen(rect.toOuterRect()) + if (delta != 0) scrollBy(0, delta) + } + + /** + * [rectangle] arrives in [child]'s content coordinates, placed there by + * View.requestRectangleOnScreen adding layout offsets only. When it came from + * [focused], undo those offsets and redo the walk through each view's matrix. + * Returns null when the rectangle is not inside [focused], so another view + * made the request and the caller keeps the default behavior. + */ + private fun drawnRectOfFocusedRequest(focused: View, child: View, rectangle: Rect): Rect? { + var dx = 0f + var dy = 0f + var current: View = focused + while (current !== child) { + dx += (current.left - current.scrollX).toFloat() + dy += (current.top - current.scrollY).toFloat() + current = current.parent as? View ?: return null + } + // Back in the focused view's own, untransformed coordinates. + val local = RectF(rectangle) + local.offset(-dx - focused.scrollX, -dy - focused.scrollY) + val bounds = RectF(-REQUEST_SLOP, -REQUEST_SLOP, focused.width + REQUEST_SLOP, focused.height + REQUEST_SLOP) + if (!RectF.intersects(local, bounds)) return null + if (!mapToContent(focused, local)) return null + return local.toOuterRect() + } + + /** + * Maps [rect] from [view]'s local coordinates into this scroll view's content + * coordinates, applying every transform on the way. False if [view] is not + * inside this scroll view. + */ + private fun mapToContent(view: View, rect: RectF): Boolean { + var current = view + while (true) { + val matrix = current.matrix + if (!matrix.isIdentity) matrix.mapRect(rect) + rect.offset(current.left.toFloat(), current.top.toFloat()) + val parent = current.parent as? View ?: return false + if (parent === this) return true + rect.offset(-parent.scrollX.toFloat(), -parent.scrollY.toFloat()) + current = parent + } + } + + private fun hasTransformBetween(view: View): Boolean { + var current = view + while (true) { + if (!current.matrix.isIdentity) return true + val parent = current.parent as? View ?: return false + if (parent === this) return false + current = parent + } + } + + private fun RectF.toOuterRect(): Rect = Rect().also { roundOut(it) } + + private companion object { + // TextView.bringPointIntoView pads its cursor rectangle a few pixels past + // the text, so a request from the focused view can touch its edges. + const val REQUEST_SLOP = 8f + } +} diff --git a/packages/happy-app/plugins/withTransformAwareScrollView.js b/packages/happy-app/plugins/withTransformAwareScrollView.js new file mode 100644 index 00000000000..fb0fce79dc7 --- /dev/null +++ b/packages/happy-app/plugins/withTransformAwareScrollView.js @@ -0,0 +1,63 @@ +const fs = require('fs'); +const path = require('path'); +const { withDangerousMod, withMainApplication } = require('@expo/config-plugins'); + +/** + * Replaces the Android vertical ScrollView with one that accounts for view + * transforms when it scrolls a focused descendant into view. + * + * Without it, tapping or long-pressing selectable text inside an inverted + * FlashList (every row rotated 180 degrees) scrolls the chat to the far end of a + * long message, because Android computes the text's position as if the row were + * not rotated. TransformAwareScrollView.kt has the details. + * + * Two edits to the generated project: + * 1. copy the Kotlin sources next to MainApplication's package root; + * 2. register TransformAwareScrollPackage ahead of MainReactPackage, because + * React Native uses the first package that answers for "RCTScrollView". + */ +const SOURCE_DIR = path.join(__dirname, 'transformAwareScrollView'); +const TARGET_PACKAGE_DIR = ['engineering', 'happy', 'scroll']; +const PACKAGE_CLASS = 'engineering.happy.scroll.TransformAwareScrollPackage'; +const PACKAGE_LIST_ANCHOR = 'PackageList(this).packages.apply {'; + +const withTransformAwareScrollView = (config) => { + config = withDangerousMod(config, [ + 'android', + (modConfig) => { + const target = path.join( + modConfig.modRequest.platformProjectRoot, + 'app', 'src', 'main', 'java', + ...TARGET_PACKAGE_DIR, + ); + fs.mkdirSync(target, { recursive: true }); + for (const file of fs.readdirSync(SOURCE_DIR)) { + if (file.endsWith('.kt')) { + fs.copyFileSync(path.join(SOURCE_DIR, file), path.join(target, file)); + } + } + return modConfig; + }, + ]); + + return withMainApplication(config, (modConfig) => { + const { contents, language } = modConfig.modResults; + if (contents.includes(PACKAGE_CLASS)) { + return modConfig; + } + // Fail the prebuild rather than ship a build that silently lacks the fix. + if (language !== 'kt' || !contents.includes(PACKAGE_LIST_ANCHOR)) { + throw new Error( + `withTransformAwareScrollView: MainApplication no longer contains "${PACKAGE_LIST_ANCHOR}"; ` + + 'update the plugin to register TransformAwareScrollPackage before MainReactPackage.' + ); + } + modConfig.modResults.contents = contents.replace( + PACKAGE_LIST_ANCHOR, + `${PACKAGE_LIST_ANCHOR}\n // First, so its ScrollView manager is used instead of MainReactPackage's.\n add(0, ${PACKAGE_CLASS}())`, + ); + return modConfig; + }); +}; + +module.exports = withTransformAwareScrollView;