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..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,6 +470,28 @@ private boolean panStarts(float dx, float dy, float slop) { return true; } + /** + * 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 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; + 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; + } + @Override public boolean onInterceptTouchEvent(MotionEvent e) { if (pointerEvents == 3) return true; @@ -487,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)) { + 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 b4a622129..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,18 @@ 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; + 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; @@ -1008,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 new file mode 100644 index 000000000..953762456 --- /dev/null +++ b/examples/gallery/tests/fixtures/sheet-scroll.tsx @@ -0,0 +1,35 @@ +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, and a button +// placed over the list. +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}`} + + ))} + + {}} + style={{ position: "absolute", left: 16, right: 16, bottom: 16, height: 48, justifyContent: "center", alignItems: "center", backgroundColor: "#dddddd" }} + > + Button over the list + + + + + ); +} + +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..128fb8797 --- /dev/null +++ b/examples/gallery/tests/sheet-scroll.test.tsx @@ -0,0 +1,30 @@ +// A list in a swipe-to-close sheet scrolls; at its top, a drag down still +// 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 () => { + const app = await open("./fixtures/sheet-scroll.tsx"); + await app.drag("Row 4", { x: 0, y: -200 }); + await app.waitFor("text: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("text: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("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("text:sheet: closed"); +});