Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/happy-app/app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export default {
},
plugins: [
require("./plugins/withEinkCompatibility.js"),
require("./plugins/withTransformAwareScrollView.js"),
[
"expo-router",
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ViewManager<in Nothing, in Nothing>> = listOf(TransformAwareScrollViewManager())

override fun getViewManagerNames(reactContext: ReactApplicationContext): Collection<String> =
listOf(ReactScrollViewManager.REACT_CLASS)

override fun createViewManager(
reactContext: ReactApplicationContext,
viewManagerName: String,
): ViewManager<in Nothing, in Nothing>? =
if (viewManagerName == ReactScrollViewManager.REACT_CLASS) TransformAwareScrollViewManager() else null
}
Original file line number Diff line number Diff line change
@@ -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
}
}
63 changes: 63 additions & 0 deletions packages/happy-app/plugins/withTransformAwareScrollView.js
Original file line number Diff line number Diff line change
@@ -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;
Loading