From 15496dbbfdc03bb2cf51dee1324b36207991fafb Mon Sep 17 00:00:00 2001
From: Jingtao Wang <7776499+jt-wang@users.noreply.github.com>
Date: Thu, 29 Oct 2026 12:02:00 +0000
Subject: [PATCH 1/3] android: Let a scroll view inside a pannable view scroll
the drags it can take
A swipe-to-close Sheet's pan took every drag that started on its content,
so a list in the sheet never scrolled. The pan now leaves a drag to a
scroll view under the finger that can move that way (at its top, a drag
down still moves the sheet), as iOS shares the touch.
---
.../java/com/longbridge/nsh/Box.java | 23 ++++++++++++++-
.../java/com/longbridge/nsh/ScrollBox.java | 7 +++++
.../gallery/tests/fixtures/sheet-scroll.tsx | 28 +++++++++++++++++++
examples/gallery/tests/sheet-scroll.test.tsx | 18 ++++++++++++
4 files changed, 75 insertions(+), 1 deletion(-)
create mode 100644 examples/gallery/tests/fixtures/sheet-scroll.tsx
create mode 100644 examples/gallery/tests/sheet-scroll.test.tsx
diff --git a/crates/nsh-platform-android/java/com/longbridge/nsh/Box.java b/crates/nsh-platform-android/java/com/longbridge/nsh/Box.java
index cdcbaea29..36b390f27 100644
--- a/crates/nsh-platform-android/java/com/longbridge/nsh/Box.java
+++ b/crates/nsh-platform-android/java/com/longbridge/nsh/Box.java
@@ -470,6 +470,27 @@ private boolean panStarts(float dx, float dy, float slop) {
return true;
}
+ private final int[] location = new int[2];
+
+ /**
+ * Whether a scroll view inside {@code v}, under the finger's DOWN at ({@code rawX}, {@code rawY}), can move
+ * for this drag: the drag is its scroll, and the pan takes only what it cannot (a sheet's list scrolls; at its
+ * top, a drag down moves the sheet). iOS shares the touch the same way (`pan_scroll_handoff`).
+ */
+ private boolean scrollTakes(View v, float rawX, float rawY, float dx, float dy) {
+ if (!(v instanceof ViewGroup)) return false;
+ ViewGroup g = (ViewGroup) v;
+ for (int i = g.getChildCount() - 1; i >= 0; i--) {
+ View c = g.getChildAt(i);
+ if (c.getVisibility() != VISIBLE) continue;
+ c.getLocationOnScreen(location);
+ if (rawX < location[0] || rawY < location[1] || rawX >= location[0] + c.getWidth() || rawY >= location[1] + c.getHeight()) continue;
+ if (c instanceof ScrollBox && ((ScrollBox) c).scrolls(dx, dy)) return true;
+ if (scrollTakes(c, rawX, rawY, dx, dy)) return true;
+ }
+ return false;
+ }
+
@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
if (pointerEvents == 3) return true;
@@ -487,7 +508,7 @@ public boolean onInterceptTouchEvent(MotionEvent e) {
if (pannable() && action == MotionEvent.ACTION_MOVE && !panning) {
float slop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
float dx = e.getRawX() - downRawX, dy = e.getRawY() - downRawY;
- if (panStarts(dx, dy, slop)) {
+ if (panStarts(dx, dy, slop) && !scrollTakes(this, downRawX, downRawY, dx, dy)) {
// The DOWN went to the child: the pan starts with this move
// (which onTouchEvent never sees), so a quick flick still
// pans and ends.
diff --git a/crates/nsh-platform-android/java/com/longbridge/nsh/ScrollBox.java b/crates/nsh-platform-android/java/com/longbridge/nsh/ScrollBox.java
index b4a622129..e32edd105 100644
--- a/crates/nsh-platform-android/java/com/longbridge/nsh/ScrollBox.java
+++ b/crates/nsh-platform-android/java/com/longbridge/nsh/ScrollBox.java
@@ -287,6 +287,13 @@ private boolean fling(int velocity) {
return true;
}
+ /** Whether a drag by ({@code dx}, {@code dy}) px along this view's axis has content to move. */
+ boolean scrolls(float dx, float dy) {
+ if (scroller == null || !enabled) return false;
+ if (horizontal) return Math.abs(dx) > Math.abs(dy) && scroller.canScrollHorizontally(dx < 0 ? 1 : -1);
+ return Math.abs(dy) > Math.abs(dx) && scroller.canScrollVertically(dy < 0 ? 1 : -1);
+ }
+
/** Glides to the snap point for a release at {@code velocity} (px/s). */
private void snap(int velocity) {
if (scroller instanceof ZScroll) return;
diff --git a/examples/gallery/tests/fixtures/sheet-scroll.tsx b/examples/gallery/tests/fixtures/sheet-scroll.tsx
new file mode 100644
index 000000000..5acd0f4db
--- /dev/null
+++ b/examples/gallery/tests/fixtures/sheet-scroll.tsx
@@ -0,0 +1,28 @@
+import { mount, platform, Scroll, signal, Text, View } from "nsh";
+import { Sheet } from "nsh/base";
+
+// A swipe-to-close sheet holding a list taller than the sheet.
+function App() {
+ const open = signal(true);
+ const offset = signal(0);
+ return (
+
+ {open() ? "sheet: open" : "sheet: closed"}
+ {offset() > 0 ? "list: scrolled" : "list: at top"}
+ {`Platform: ${platform.os}`}
+ open.set(o)} closeOnSwipe>
+
+ offset.set(e.offset.y)} style={{ flexShrink: 1 }}>
+ {Array.from({ length: 40 }, (_, i) => (
+
+ {`Row ${i + 1}`}
+
+ ))}
+
+
+
+
+ );
+}
+
+mount(App);
diff --git a/examples/gallery/tests/sheet-scroll.test.tsx b/examples/gallery/tests/sheet-scroll.test.tsx
new file mode 100644
index 000000000..dee252b14
--- /dev/null
+++ b/examples/gallery/tests/sheet-scroll.test.tsx
@@ -0,0 +1,18 @@
+// A list in a swipe-to-close sheet scrolls; at its top, a drag down still
+// closes the sheet.
+import { expect, open, test } from "nsh/test";
+
+test("a drag up on a sheet's list scrolls the list", async () => {
+ const app = await open("./fixtures/sheet-scroll.tsx");
+ await app.drag("Row 4", { x: 0, y: -200 });
+ await app.waitFor("list: scrolled");
+ expect(app.texts()).toContain("sheet: open");
+});
+
+test("a drag down on a sheet's list at its top closes the sheet", async () => {
+ const app = await open("./fixtures/sheet-scroll.tsx");
+ // Android's for now (a headless run does not close the sheet on this drag).
+ if (!app.shows("Platform: android")) return;
+ await app.drag("Row 4", { x: 0, y: 300 });
+ await app.waitFor("sheet: closed");
+});
From ac85f848a69618702a1172fdbb1281538cb7d6fc Mon Sep 17 00:00:00 2001
From: Jingtao Wang <7776499+jt-wang@users.noreply.github.com>
Date: Thu, 29 Oct 2026 18:13:01 +0900
Subject: [PATCH 2/3] android: Hand a pan's drag only to a scroll view the
touch went to
A button over a scrolled list in a sheet took the touch, but the pan still
found the list under the finger and left the drag to it, so dragging the
button moved neither the sheet nor the list. A scroll view now counts only
when it took the touch's DOWN, as iOS looks only at the scroll views between
the touched view and the pan.
---
.../java/com/longbridge/nsh/Box.java | 23 ++++++++++---------
.../java/com/longbridge/nsh/ScrollBox.java | 10 ++++++++
.../gallery/tests/fixtures/sheet-scroll.tsx | 9 +++++++-
examples/gallery/tests/sheet-scroll.test.tsx | 14 ++++++++++-
4 files changed, 43 insertions(+), 13 deletions(-)
diff --git a/crates/nsh-platform-android/java/com/longbridge/nsh/Box.java b/crates/nsh-platform-android/java/com/longbridge/nsh/Box.java
index 36b390f27..2766e0ad8 100644
--- a/crates/nsh-platform-android/java/com/longbridge/nsh/Box.java
+++ b/crates/nsh-platform-android/java/com/longbridge/nsh/Box.java
@@ -470,23 +470,24 @@ private boolean panStarts(float dx, float dy, float slop) {
return true;
}
- private final int[] location = new int[2];
-
/**
- * Whether a scroll view inside {@code v}, under the finger's DOWN at ({@code rawX}, {@code rawY}), can move
- * for this drag: the drag is its scroll, and the pan takes only what it cannot (a sheet's list scrolls; at its
- * top, a drag down moves the sheet). iOS shares the touch the same way (`pan_scroll_handoff`).
+ * Whether a scroll view inside {@code v} that took this touch's DOWN (at {@code downTime}) can move for this
+ * drag: the drag is its scroll, and the pan takes only what it cannot (a sheet's list scrolls; at its top, a drag
+ * down moves the sheet). A scroll view under a button that took the touch does not count. iOS shares the touch
+ * the same way, with the scroll views between the touched view and the pan (`pan_touch`, `pan_scroll_handoff`).
*/
- private boolean scrollTakes(View v, float rawX, float rawY, float dx, float dy) {
+ private static boolean scrollTakes(View v, long downTime, float dx, float dy) {
if (!(v instanceof ViewGroup)) return false;
ViewGroup g = (ViewGroup) v;
for (int i = g.getChildCount() - 1; i >= 0; i--) {
View c = g.getChildAt(i);
if (c.getVisibility() != VISIBLE) continue;
- c.getLocationOnScreen(location);
- if (rawX < location[0] || rawY < location[1] || rawX >= location[0] + c.getWidth() || rawY >= location[1] + c.getHeight()) continue;
- if (c instanceof ScrollBox && ((ScrollBox) c).scrolls(dx, dy)) return true;
- if (scrollTakes(c, rawX, rawY, dx, dy)) return true;
+ if (c instanceof ScrollBox) {
+ // A scroll view the touch did not go to: nor did it go to anything inside.
+ if (!((ScrollBox) c).took(downTime)) continue;
+ if (((ScrollBox) c).scrolls(dx, dy)) return true;
+ }
+ if (scrollTakes(c, downTime, dx, dy)) return true;
}
return false;
}
@@ -508,7 +509,7 @@ public boolean onInterceptTouchEvent(MotionEvent e) {
if (pannable() && action == MotionEvent.ACTION_MOVE && !panning) {
float slop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
float dx = e.getRawX() - downRawX, dy = e.getRawY() - downRawY;
- if (panStarts(dx, dy, slop) && !scrollTakes(this, downRawX, downRawY, dx, dy)) {
+ if (panStarts(dx, dy, slop) && !scrollTakes(this, e.getDownTime(), dx, dy)) {
// The DOWN went to the child: the pan starts with this move
// (which onTouchEvent never sees), so a quick flick still
// pans and ends.
diff --git a/crates/nsh-platform-android/java/com/longbridge/nsh/ScrollBox.java b/crates/nsh-platform-android/java/com/longbridge/nsh/ScrollBox.java
index e32edd105..4be001275 100644
--- a/crates/nsh-platform-android/java/com/longbridge/nsh/ScrollBox.java
+++ b/crates/nsh-platform-android/java/com/longbridge/nsh/ScrollBox.java
@@ -43,6 +43,8 @@ final class ScrollBox extends FrameLayout implements Node {
private boolean pulling;
private float startY;
private boolean touching;
+ /** The down time of the touch whose DOWN this view took (-1: none yet). */
+ private long downTime = -1;
private final Runnable settled = this::settled;
// `ScrollBehavior`; offsets and lengths in dp.
@@ -287,6 +289,11 @@ private boolean fling(int velocity) {
return true;
}
+ /** Whether this view took the DOWN of the touch that went down at {@code downTime}. */
+ boolean took(long downTime) {
+ return this.downTime == downTime;
+ }
+
/** Whether a drag by ({@code dx}, {@code dy}) px along this view's axis has content to move. */
boolean scrolls(float dx, float dy) {
if (scroller == null || !enabled) return false;
@@ -1015,6 +1022,9 @@ public boolean dispatchTouchEvent(MotionEvent e) {
} else {
handled = super.dispatchTouchEvent(e);
}
+ // For a pan around this view (Box.scrollTakes): a touch that a view
+ // above took never comes here, and leaves the time of an older one.
+ if (action == MotionEvent.ACTION_DOWN) downTime = handled ? e.getDownTime() : -1;
if (action == MotionEvent.ACTION_UP && tapMode >= 0 && !dragging
&& (tapMode == 0 || !handlesTap(this, e.getX(), e.getY()))) {
hideKeyboard();
diff --git a/examples/gallery/tests/fixtures/sheet-scroll.tsx b/examples/gallery/tests/fixtures/sheet-scroll.tsx
index 5acd0f4db..953762456 100644
--- a/examples/gallery/tests/fixtures/sheet-scroll.tsx
+++ b/examples/gallery/tests/fixtures/sheet-scroll.tsx
@@ -1,7 +1,8 @@
import { mount, platform, Scroll, signal, Text, View } from "nsh";
import { Sheet } from "nsh/base";
-// A swipe-to-close sheet holding a list taller than the sheet.
+// A swipe-to-close sheet holding a list taller than the sheet, and a button
+// placed over the list.
function App() {
const open = signal(true);
const offset = signal(0);
@@ -19,6 +20,12 @@ function App() {
))}
+ {}}
+ style={{ position: "absolute", left: 16, right: 16, bottom: 16, height: 48, justifyContent: "center", alignItems: "center", backgroundColor: "#dddddd" }}
+ >
+ Button over the list
+
diff --git a/examples/gallery/tests/sheet-scroll.test.tsx b/examples/gallery/tests/sheet-scroll.test.tsx
index dee252b14..91a5da0f2 100644
--- a/examples/gallery/tests/sheet-scroll.test.tsx
+++ b/examples/gallery/tests/sheet-scroll.test.tsx
@@ -1,5 +1,5 @@
// A list in a swipe-to-close sheet scrolls; at its top, a drag down still
-// closes the sheet.
+// closes the sheet, as does a drag on a button over the list.
import { expect, open, test } from "nsh/test";
test("a drag up on a sheet's list scrolls the list", async () => {
@@ -16,3 +16,15 @@ test("a drag down on a sheet's list at its top closes the sheet", async () => {
await app.drag("Row 4", { x: 0, y: 300 });
await app.waitFor("sheet: closed");
});
+
+test("a drag down on a button over a scrolled list moves the sheet", async () => {
+ const app = await open("./fixtures/sheet-scroll.tsx");
+ // Android's for now, as the case above.
+ if (!app.shows("Platform: android")) return;
+ await app.drag("Row 4", { x: 0, y: -200 });
+ await app.waitFor("list: scrolled");
+ // The button takes the touch, not the list under it: the list does not
+ // scroll, so the drag moves the sheet.
+ await app.drag("Button over the list", { x: 0, y: 300 });
+ await app.waitFor("sheet: closed");
+});
From 89e2902721f40b86284ffaa91e6fa6efbbac998c Mon Sep 17 00:00:00 2001
From: Jingtao Wang <7776499+jt-wang@users.noreply.github.com>
Date: Fri, 30 Oct 2026 22:15:51 +0900
Subject: [PATCH 3/3] android: Read the sheet-scroll test's statuses with
`text:`, as nsh/test asks since #708
---
examples/gallery/tests/sheet-scroll.test.tsx | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/examples/gallery/tests/sheet-scroll.test.tsx b/examples/gallery/tests/sheet-scroll.test.tsx
index 91a5da0f2..128fb8797 100644
--- a/examples/gallery/tests/sheet-scroll.test.tsx
+++ b/examples/gallery/tests/sheet-scroll.test.tsx
@@ -5,7 +5,7 @@ import { expect, open, test } from "nsh/test";
test("a drag up on a sheet's list scrolls the list", async () => {
const app = await open("./fixtures/sheet-scroll.tsx");
await app.drag("Row 4", { x: 0, y: -200 });
- await app.waitFor("list: scrolled");
+ await app.waitFor("text:list: scrolled");
expect(app.texts()).toContain("sheet: open");
});
@@ -14,7 +14,7 @@ test("a drag down on a sheet's list at its top closes the sheet", async () => {
// Android's for now (a headless run does not close the sheet on this drag).
if (!app.shows("Platform: android")) return;
await app.drag("Row 4", { x: 0, y: 300 });
- await app.waitFor("sheet: closed");
+ await app.waitFor("text:sheet: closed");
});
test("a drag down on a button over a scrolled list moves the sheet", async () => {
@@ -22,9 +22,9 @@ test("a drag down on a button over a scrolled list moves the sheet", async () =>
// Android's for now, as the case above.
if (!app.shows("Platform: android")) return;
await app.drag("Row 4", { x: 0, y: -200 });
- await app.waitFor("list: scrolled");
+ await app.waitFor("text:list: scrolled");
// The button takes the touch, not the list under it: the list does not
// scroll, so the drag moves the sheet.
await app.drag("Button over the list", { x: 0, y: 300 });
- await app.waitFor("sheet: closed");
+ await app.waitFor("text:sheet: closed");
});