From ac636f8348f09b81d56ac8bf2330ca2f248c3c80 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Tue, 3 Nov 2026 13:39:04 +0000 Subject: [PATCH] Let a gesture area's pan trigger when let go far enough, on iOS and Android A bound pan axis with pan-x-trigger / pan-y-trigger (from the gesture area's trigger-x / trigger-y in mobile-air) is a pull, the swipe-to-reply pattern: - it only starts on a drag the trigger's way, so a drag the other way is left to an enclosing scroll view or iOS's swipe back; - it follows the finger from rest to the trigger point, then with a third of the finger's movement past it, and never the other way past rest; - each time it goes out past the point it plays a light haptic (UIImpactFeedbackGenerator .light on iOS, GestureThresholdActivate on Android), and coming back under plays nothing; - on release it springs back to rest with the snap points' 350 ms ease-out, and fires on_trigger (a PRESS event) if it was past the point when the finger lifted. A cancelled pan doesn't fire. Children follow the pull through the same SharedValue, so whatever comes in behind runs on the UI thread with the finger. A PHP write-back springs a trigger axis as it does a snapping one. Only one-axis pans take a trigger. GestureAreaTriggerTest covers the Android side in the Robolectric module. Co-Authored-By: Claude Opus 5.5 --- resources/android/GestureAreaRenderer.kt | 186 ++++++++++++++++-- .../ios/NativeUIGestureAreaRenderer.swift | 154 ++++++++++++--- tests/android/README.md | 7 + .../src/test/kotlin/GestureAreaTriggerTest.kt | 168 ++++++++++++++++ 4 files changed, 477 insertions(+), 38 deletions(-) create mode 100644 tests/android/src/test/kotlin/GestureAreaTriggerTest.kt diff --git a/resources/android/GestureAreaRenderer.kt b/resources/android/GestureAreaRenderer.kt index b11ac0a..f8a8d0f 100644 --- a/resources/android/GestureAreaRenderer.kt +++ b/resources/android/GestureAreaRenderer.kt @@ -5,27 +5,37 @@ import androidx.compose.animation.core.animate import androidx.compose.animation.core.tween import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation +import androidx.compose.foundation.gestures.awaitVerticalTouchSlopOrCancellation import androidx.compose.foundation.gestures.calculateCentroid import androidx.compose.foundation.gestures.calculatePan import androidx.compose.foundation.gestures.calculateZoom import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.gestures.horizontalDrag +import androidx.compose.foundation.gestures.verticalDrag import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp import com.nativephp.mobile.ui.nativerender.NativeUIBridge import com.nativephp.mobile.ui.nativerender.NativeUINode @@ -57,6 +67,19 @@ import kotlin.math.abs * with resistance past them, springs to * the nearest on release, and springs to a * PHP write-back too. + * - `pan-x-trigger` / + * `pan-y-trigger` (float) — how far from rest a pull on a bound + * axis goes to trigger, its sign the way + * it goes (see PanTrigger): the drag only + * starts that way, follows the finger to + * the point with resistance past it, plays + * a light haptic each time it goes out + * past it, and springs back to rest on + * release. Takes the place of snap points; + * one-axis pans only. + * - `on_trigger` (int) — callback fired when a pull is let go + * past its trigger point (rides the PRESS + * format, which carries nothing). * - `on_drag_end` (int) — callback fired when a drag ends, * carrying the final "x,y" translation * (dp) as text (rides the TEXT_CHANGE @@ -131,6 +154,15 @@ object GestureAreaRenderer { val snapX = PanSnap.parse(node.props.getString("pan-x-snap")) val snapY = PanSnap.parse(node.props.getString("pan-y-snap")) val snapping = remember { SnapPan() } + val triggerX = PanTrigger(node.props.getFloat("pan-x-trigger", 0f)) + val triggerY = PanTrigger(node.props.getFloat("pan-y-trigger", 0f)) + val onTrigger = node.props.getInt("on_trigger", 0) + val haptics = LocalHapticFeedback.current + // Where a trigger axis rests, as PHP last published it. Read when a + // pull moves and ends rather than keying the gesture on it, so a + // write-back doesn't cut a pull short. + val restX by rememberUpdatedState(panXInitial) + val restY by rememberUpdatedState(panYInitial) // Seed during COMPOSITION (inline, before children compose) rather // than in a LaunchedEffect: effects run after children have @@ -144,8 +176,8 @@ object GestureAreaRenderer { // on a persistent SharedValue. That is an explicit // write-back: push it into the store even though the id // already has a live value (a reset button, snap-to, etc.). - SyncBinding(panXId, panXInitial, if (snapX.isEmpty) null else { value -> snapping.to(panXId, value, scope) }) - SyncBinding(panYId, panYInitial, if (snapY.isEmpty) null else { value -> snapping.to(panYId, value, scope) }) + SyncBinding(panXId, panXInitial, if (snapX.isEmpty && triggerX.isEmpty) null else { value -> snapping.to(panXId, value, scope) }) + SyncBinding(panYId, panYInitial, if (snapY.isEmpty && triggerY.isEmpty) null else { value -> snapping.to(panYId, value, scope) }) SyncBinding(pinchId, pinchInitial) Box( @@ -154,7 +186,7 @@ object GestureAreaRenderer { .onSizeChanged { size -> areaSize = with(density) { Size(size.width.toDp().value, size.height.toDp().value) } } - .pointerInput(panXId, panYId, onDragEnd, bounds, snapX, snapY) { + .pointerInput(panXId, panYId, onDragEnd, bounds, snapX, snapY, triggerX, triggerY, onTrigger) { if (panXId == 0 && panYId == 0 && onDragEnd == 0) return@pointerInput if (bounds.enabled) { // The undamped pan the finger asked for, and what was @@ -215,6 +247,11 @@ object GestureAreaRenderer { var rawX = 0f var rawY = 0f val velocity = VelocityTracker() + // A one-axis pan's trigger, if it has one, and whether the + // pull is past its point. + val pullsX = panXId != 0 && panYId == 0 && !triggerX.isEmpty + val pullsY = panYId != 0 && panXId == 0 && !triggerY.isEmpty + var past = false val onStart: (Offset) -> Unit = { snapping.stop() rawX = if (panXId != 0) SharedValueStore.valueOf(panXId) else 0f @@ -226,18 +263,47 @@ object GestureAreaRenderer { gestureY += raw.y.toDp().value rawX += bound.x.toDp().value rawY += bound.y.toDp().value + // A pull is measured from rest, so one caught on its way + // back carries on from where it is. if (panXId != 0) { - SharedValueStore.set(panXId, if (snapX.isEmpty) SharedValueStore.valueOf(panXId) + bound.x.toDp().value else snapX.resisted(rawX)) + SharedValueStore.set(panXId, when { + pullsX -> restX + triggerX.resisted(rawX - restX) + snapX.isEmpty -> SharedValueStore.valueOf(panXId) + bound.x.toDp().value + else -> snapX.resisted(rawX) + }) } if (panYId != 0) { - SharedValueStore.set(panYId, if (snapY.isEmpty) SharedValueStore.valueOf(panYId) + bound.y.toDp().value else snapY.resisted(rawY)) + SharedValueStore.set(panYId, when { + pullsY -> restY + triggerY.resisted(rawY - restY) + snapY.isEmpty -> SharedValueStore.valueOf(panYId) + bound.y.toDp().value + else -> snapY.resisted(rawY) + }) + } + if (pullsX || pullsY) { + val now = if (pullsX) triggerX.passed(SharedValueStore.valueOf(panXId) - restX) else triggerY.passed(SharedValueStore.valueOf(panYId) - restY) + if (now && !past) haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + past = now } } - val onEnd: () -> Unit = { - // px per second, into dp like the pan itself. + // Whether the finger lifted, rather than the drag being cancelled. + val onEnd: (Boolean) -> Unit = { lifted -> + // px per second, into dp like the pan itself. A pull + // always goes back to rest. val fling = velocity.calculateVelocity() - val x = if (panXId != 0) snapping.settle(panXId, snapX, fling.x.toDp().value, scope) else gestureX - val y = if (panYId != 0) snapping.settle(panYId, snapY, fling.y.toDp().value, scope) else gestureY + val x = when { + panXId == 0 -> gestureX + pullsX -> restX.also { snapping.to(panXId, it, scope) } + else -> snapping.settle(panXId, snapX, fling.x.toDp().value, scope) + } + val y = when { + panYId == 0 -> gestureY + pullsY -> restY.also { snapping.to(panYId, it, scope) } + else -> snapping.settle(panYId, snapY, fling.y.toDp().value, scope) + } + if (past && lifted && onTrigger != 0) { + NativeUIBridge.sendPressEvent(onTrigger, node.id) + } + past = false if (onDragEnd != 0) { NativeUIBridge.sendTextChangeEvent(onDragEnd, node.id, "$x,$y") } @@ -245,29 +311,40 @@ object GestureAreaRenderer { gestureY = 0f } when { + // A pull: the same as below, but only a drag its own way. + pullsX || pullsY -> detectPull( + horizontal = pullsX, + way = if (pullsX) triggerX.distance else triggerY.distance, + onStart = onStart, + onEnd = onEnd, + ) { change, dragAmount -> + velocity.addPosition(change.uptimeMillis, change.position) + val raw = change.position - change.previousPosition + onDrag(if (pullsX) Offset(dragAmount, 0f) else Offset(0f, dragAmount), raw) + } // One axis bound: detect on that axis only, so the // other direction stays free for an enclosing // scroll container. panXId != 0 && panYId == 0 -> detectHorizontalDragGestures( onDragStart = onStart, - onDragEnd = onEnd, - onDragCancel = onEnd, + onDragEnd = { onEnd(true) }, + onDragCancel = { onEnd(false) }, ) { change, dragAmount -> velocity.addPosition(change.uptimeMillis, change.position) onDrag(Offset(dragAmount, 0f), change.position - change.previousPosition) } panXId == 0 && panYId != 0 -> detectVerticalDragGestures( onDragStart = onStart, - onDragEnd = onEnd, - onDragCancel = onEnd, + onDragEnd = { onEnd(true) }, + onDragCancel = { onEnd(false) }, ) { change, dragAmount -> velocity.addPosition(change.uptimeMillis, change.position) onDrag(Offset(0f, dragAmount), change.position - change.previousPosition) } else -> detectDragGestures( onDragStart = onStart, - onDragEnd = onEnd, - onDragCancel = onEnd, + onDragEnd = { onEnd(true) }, + onDragCancel = { onEnd(false) }, ) { change, dragAmount -> velocity.addPosition(change.uptimeMillis, change.position) onDrag(dragAmount, dragAmount) @@ -434,6 +511,52 @@ object GestureAreaRenderer { } } + /** + * A one-axis drag that only starts once the finger passes touch slop + * the way of [way]'s sign, as a trigger's pull goes, and otherwise + * runs like detectHorizontalDragGestures. A drag the other way isn't + * taken, so whatever else wants it can have it. + */ + private suspend fun PointerInputScope.detectPull( + horizontal: Boolean, + way: Float, + onStart: (Offset) -> Unit, + onEnd: (Boolean) -> Unit, + onDrag: (PointerInputChange, Float) -> Unit, + ) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + var overSlop = 0f + // Left unconsumed, a drag the other way past slop starts the + // slop over from there rather than taking the gesture. + val pulls: (PointerInputChange, Float) -> Unit = { change, over -> + if (over * way > 0f) { + change.consume() + overSlop = over + } + } + val start = if (horizontal) { + awaitHorizontalTouchSlopOrCancellation(down.id, pulls) + } else { + awaitVerticalTouchSlopOrCancellation(down.id, pulls) + } ?: return@awaitEachGesture + onStart(start.position) + onDrag(start, overSlop) + val lifted = if (horizontal) { + horizontalDrag(start.id) { change -> + onDrag(change, change.positionChange().x) + change.consume() + } + } else { + verticalDrag(start.id) { change -> + onDrag(change, change.positionChange().y) + change.consume() + } + } + onEnd(lifted) + } + } + /** * Keep a SharedValue binding in sync with what PHP published, during * composition. Unknown id → seed (don't stomp a live gesture value); @@ -588,7 +711,38 @@ object GestureAreaRenderer { } } - /** Springs a snapping axis to where it rests, on release or a PHP write-back. */ + /** + * A pull that acts when let go far enough (`pan-x-trigger` / + * `pan-y-trigger`): how far from rest it goes to trigger, its sign the + * way it goes. Zero when the axis doesn't trigger. A data class, so a + * pointerInput keyed on it restarts only when the distance changes. + */ + private data class PanTrigger(val distance: Float) { + val isEmpty: Boolean get() = distance == 0f + + /** + * What the pull shows for the finger's offset from rest: nothing + * the other way, the finger itself as far as the point, and past it + * a third of the finger's movement, as past the outer snap points. + */ + fun resisted(offset: Float): Float { + val sign = if (distance < 0f) -1f else 1f + val reach = abs(distance) + val toward = offset * sign + val shown = when { + toward <= 0f -> 0f + toward <= reach -> toward + else -> reach + (toward - reach) / 3f + } + return shown * sign + } + + /** Whether a pull this far from rest is at the point or past it. */ + fun passed(offset: Float): Boolean = + !isEmpty && if (distance < 0f) offset <= distance else offset >= distance + } + + /** Springs a snapping or trigger axis to where it rests, on release or a PHP write-back. */ private class SnapPan { private var job: Job? = null diff --git a/resources/ios/NativeUIGestureAreaRenderer.swift b/resources/ios/NativeUIGestureAreaRenderer.swift index aef292c..20ba7cb 100644 --- a/resources/ios/NativeUIGestureAreaRenderer.swift +++ b/resources/ios/NativeUIGestureAreaRenderer.swift @@ -17,6 +17,19 @@ import UIKit /// and highest with resistance past them, /// springs to the nearest on release, and /// springs to a PHP write-back too. +/// - `pan-x-trigger` / +/// `pan-y-trigger` (float) — how far from rest a pull on a bound +/// axis goes to trigger, its sign the way +/// it goes (see NativeUIPanTrigger): the +/// pan only starts that way, follows the +/// finger to the point with resistance +/// past it, plays a light haptic each time +/// it goes out past it, and springs back +/// to rest on release. Takes the place of +/// snap points; one-axis pans only. +/// - `on_trigger` (int) — callback fired when a pull is let go +/// past its trigger point (rides the +/// PRESS format, which carries nothing). /// - `on_drag_end` (int) — callback fired when a drag ends, /// carrying the final "x,y" translation /// (points) as text; an unbound axis @@ -81,6 +94,9 @@ struct NativeUIGestureAreaRenderer: View { @State private var writtenPan: CGSize = .zero @State private var pinching = false @State private var settle = NativeUIPanSettle() + // A trigger axis only: the haptic at the point, and whether the pull + // is past it. + @State private var pull = NativeUIPanPull() var body: some View { let panXId = node.props.getInt("pan-x-id", default: 0) @@ -105,6 +121,9 @@ struct NativeUIGestureAreaRenderer: View { let a11yHint = node.props.getString("a11y_hint") let snapX = NativeUIPanSnap(node.props.getString("pan-x-snap")) let snapY = NativeUIPanSnap(node.props.getString("pan-y-snap")) + let triggerX = NativeUIPanTrigger(distance: CGFloat(node.props.getFloat("pan-x-trigger", default: 0))) + let triggerY = NativeUIPanTrigger(distance: CGFloat(node.props.getFloat("pan-y-trigger", default: 0))) + let onTrigger = node.props.getInt("on_trigger", default: 0) // One bound axis and nothing else moving the pan: a UIKit pan that // only takes drags along that axis, so an enclosing scroll view // keeps the other (see AxisPanRecognizer). SwiftUI's drag below @@ -161,27 +180,44 @@ struct NativeUIGestureAreaRenderer: View { ) .modifier(AxisPanModifier( axis: axisPan, + pulls: axisPan == .horizontal ? triggerX.distance : triggerY.distance, began: { settle.stop() + if !(axisPan == .horizontal ? triggerX : triggerY).isEmpty { pull.begin() } if axisPan == .horizontal { dragStartX = store.value(for: panXId) } else { dragStart = store.value(for: panYId) } }, changed: { translation in - if axisPan == .horizontal { - store.set(snapX.resisted(dragStartX + translation), for: panXId) - } else { - store.set(snapY.resisted(dragStart + translation), for: panYId) + let horizontal = axisPan == .horizontal + let id = horizontal ? panXId : panYId + let start = horizontal ? dragStartX : dragStart + let trigger = horizontal ? triggerX : triggerY + guard !trigger.isEmpty else { + store.set((horizontal ? snapX : snapY).resisted(start + translation), for: id) + return } + // Measured from rest, so a pull caught on its way back + // carries on from where it is. + let rest = horizontal ? panXInitial : panYInitial + let shown = trigger.resisted(start - rest + translation) + store.set(rest + shown, for: id) + pull.update(past: trigger.passed(shown)) }, - ended: { velocity, across in + ended: { velocity, across, lifted in let horizontal = axisPan == .horizontal let id = horizontal ? panXId : panYId - let snap = horizontal ? snapX : snapY + let trigger = horizontal ? triggerX : triggerY let current = store.value(for: id) - let rest = snap.target(from: current, velocity: velocity) + // A trigger axis always goes back to rest. + let rest = trigger.isEmpty + ? (horizontal ? snapX : snapY).target(from: current, velocity: velocity) + : (horizontal ? panXInitial : panYInitial) if rest != current { settle.run([.init(id: id, from: current, to: rest)]) } if horizontal { dragStartX = rest } else { dragStart = rest } + if pull.end(), lifted, onTrigger != 0 { + NativeElementBridge.sendPressEvent(onTrigger, nodeId: node.id) + } guard onDragEnd != 0 else { return } // The other axis isn't bound, so it reports the finger's // raw movement across, as the drag above does. @@ -235,7 +271,7 @@ struct NativeUIGestureAreaRenderer: View { } .onChange(of: panXInitial) { _, newInitial in guard panXId != 0 else { return } - writeBack(newInitial, for: panXId, snapping: !snapX.isEmpty) + writeBack(newInitial, for: panXId, snapping: !snapX.isEmpty || !triggerX.isEmpty) dragStartX = newInitial } .onChange(of: pinchId) { _, newId in @@ -243,7 +279,7 @@ struct NativeUIGestureAreaRenderer: View { } .onChange(of: panYInitial) { _, newInitial in guard panYId != 0 else { return } - writeBack(newInitial, for: panYId, snapping: !snapY.isEmpty) + writeBack(newInitial, for: panYId, snapping: !snapY.isEmpty || !triggerY.isEmpty) dragStart = newInitial } .onChange(of: pinchInitial) { _, newInitial in @@ -254,9 +290,9 @@ struct NativeUIGestureAreaRenderer: View { .modifier(A11yHintModifier(hint: a11yHint)) } - /// PHP set a persistent SharedValue (`setValue()`): a snapping axis - /// springs there, as it does on release, so closing a revealed row - /// from PHP slides it shut; any other value jumps. + /// PHP set a persistent SharedValue (`setValue()`): a snapping or + /// trigger axis springs there, as it does on release, so closing a + /// revealed row from PHP slides it shut; any other value jumps. private func writeBack(_ value: CGFloat, for id: Int, snapping: Bool) { guard snapping else { store.set(value, for: id) @@ -628,17 +664,79 @@ struct NativeUIPanSnap { } } +// MARK: - Trigger pull + +/// A pull that acts when let go far enough (`pan-x-trigger` / +/// `pan-y-trigger`): how far from rest it goes to trigger, its sign the +/// way it goes. Zero when the axis doesn't trigger. +struct NativeUIPanTrigger { + let distance: CGFloat + + var isEmpty: Bool { distance == 0 } + + /// What the pull shows for the finger's offset from rest: nothing the + /// other way, the finger itself as far as the point, and past it a + /// third of the finger's movement, as past the outer snap points. + func resisted(_ offset: CGFloat) -> CGFloat { + let sign: CGFloat = distance < 0 ? -1 : 1 + let reach = abs(distance) + let toward = offset * sign + let shown = toward <= 0 ? 0 : toward <= reach ? toward : reach + (toward - reach) / 3 + return shown * sign + } + + /// Whether a pull this far from rest is at the point or past it. + func passed(_ offset: CGFloat) -> Bool { + !isEmpty && (distance < 0 ? offset <= distance : offset >= distance) + } +} + +/// What a trigger pull keeps while the finger is down: the light haptic +/// that plays each time it goes out past the point, and whether it's past +/// it now. Coming back under the point plays nothing. +final class NativeUIPanPull { + private var haptic: UIImpactFeedbackGenerator? + private var past = false + + func begin() { + past = false + haptic = UIImpactFeedbackGenerator(style: .light) + haptic?.prepare() + } + + func update(past now: Bool) { + if now && !past { + haptic?.impactOccurred() + haptic?.prepare() + } + past = now + } + + /// Whether the pull was past the point when it ended. + func end() -> Bool { + defer { + past = false + haptic = nil + } + return past + } +} + /// Attaches AxisPanRecognizer when the area pans on one axis only. private struct AxisPanModifier: ViewModifier { let axis: Axis? + /// Which way along the axis the pan may start: either way when zero, + /// otherwise only the way of its sign, as a trigger's pull goes. + let pulls: CGFloat let began: () -> Void let changed: (CGFloat) -> Void - /// The velocity along the axis, then the translation across it. - let ended: (CGFloat, CGFloat) -> Void + /// The velocity along the axis, the translation across it, and whether + /// the finger lifted rather than the pan being cancelled. + let ended: (CGFloat, CGFloat, Bool) -> Void func body(content: Content) -> some View { if let axis { - content.gesture(AxisPanRecognizer(axis: axis, began: began, changed: changed, ended: ended)) + content.gesture(AxisPanRecognizer(axis: axis, pulls: pulls, began: began, changed: changed, ended: ended)) } else { content } @@ -651,15 +749,22 @@ private struct AxisPanModifier: ViewModifier { /// drag along it doesn't: the scroll view waits for this pan to decide, /// as does iOS 26's swipe back from mid-screen. The edge swipe back still /// wins, since this pan waits for it. A drag that takes the pan cancels -/// the taps of whatever it started on. +/// the taps of whatever it started on. A one-way pan (`pulls`) doesn't +/// begin on a drag the other way, which leaves it to the scroll view or +/// the swipe back. private struct AxisPanRecognizer: UIGestureRecognizerRepresentable { let axis: Axis + let pulls: CGFloat let began: () -> Void let changed: (CGFloat) -> Void - let ended: (CGFloat, CGFloat) -> Void + let ended: (CGFloat, CGFloat, Bool) -> Void func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { - Coordinator(axis: axis) + Coordinator(axis: axis, pulls: pulls) + } + + func updateUIGestureRecognizer(_ recognizer: UIPanGestureRecognizer, context: Context) { + context.coordinator.pulls = pulls } func makeUIGestureRecognizer(context: Context) -> UIPanGestureRecognizer { @@ -681,9 +786,9 @@ private struct AxisPanRecognizer: UIGestureRecognizerRepresentable { changed(along) case .ended: let velocity = recognizer.velocity(in: recognizer.view) - ended(axis == .horizontal ? velocity.x : velocity.y, across) + ended(axis == .horizontal ? velocity.x : velocity.y, across, true) case .cancelled, .failed: - ended(0, across) + ended(0, across, false) default: break } @@ -691,15 +796,20 @@ private struct AxisPanRecognizer: UIGestureRecognizerRepresentable { final class Coordinator: NSObject, UIGestureRecognizerDelegate { let axis: Axis + var pulls: CGFloat - init(axis: Axis) { + init(axis: Axis, pulls: CGFloat) { self.axis = axis + self.pulls = pulls } func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true } let velocity = pan.velocity(in: pan.view) - return axis == .horizontal ? abs(velocity.x) > abs(velocity.y) : abs(velocity.y) > abs(velocity.x) + let along = axis == .horizontal ? velocity.x : velocity.y + let across = axis == .horizontal ? velocity.y : velocity.x + guard abs(along) > abs(across) else { return false } + return pulls == 0 || (along < 0) == (pulls < 0) } func gestureRecognizer( diff --git a/tests/android/README.md b/tests/android/README.md index 86d74a1..b2beeb7 100644 --- a/tests/android/README.md +++ b/tests/android/README.md @@ -28,6 +28,13 @@ scroll container, and a bound pan still follows the finger. `NodeView` here stands in for core's: each child is a fixed-size box, clickable like a core `pressable` when it has a press handler. +`GestureAreaTriggerTest` pulls a pan with a trigger point (`pan-x-trigger`): +let go past the point it fires `@trigger` and springs back, short of it or +after coming back under it it only springs back, the haptic plays each time +the pull goes out past the point, the pan follows the finger to the point +and resists past it, and a drag the other way is left to an enclosing +scroll container. + `TextInputCaretTest` pushes a new value into the outlined, filled and bare editors and checks where the caret lands: where the `selection` props say (code points, clamped), at the end when they don't, never on an echo of what diff --git a/tests/android/src/test/kotlin/GestureAreaTriggerTest.kt b/tests/android/src/test/kotlin/GestureAreaTriggerTest.kt new file mode 100644 index 0000000..6335e5c --- /dev/null +++ b/tests/android/src/test/kotlin/GestureAreaTriggerTest.kt @@ -0,0 +1,168 @@ +package com.nativephp.plugins.native_ui.ui + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import com.nativephp.mobile.ui.nativerender.* +import org.junit.After +import org.junit.Before +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * A pan with a trigger (`pan-x-trigger`) follows a drag its own way only, + * plays a haptic each time it goes out past the point, fires `@trigger` + * when let go past it, and springs back to rest either way. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class GestureAreaTriggerTest { + @get:Rule val compose = createComposeRule() + + private val haptics = mutableListOf() + + /** A spring back cut short by the last test's teardown leaves the pan where it was. */ + @Before fun rest() { + SharedValueStore.set(PanId, 0f) + } + + @After fun reset() { + NativeUIBridge.events.clear() + } + + @Test fun pullLetGoPastThePointTriggersAndSpringsBack() { + render() + compose.onNodeWithTag("node-2").performTouchInput { pull(80) } + compose.runOnIdle { + assertEquals(listOf("press:7"), events()) + assertEquals(0f, SharedValueStore.valueOf(PanId), 0.01f) + } + } + + @Test fun pullFollowsTheFingerToThePointThenResists() { + var slop = 0f + compose.setContent { + slop = with(LocalDensity.current) { LocalViewConfiguration.current.touchSlop.toDp().value } + CompositionLocalProvider(LocalHapticFeedback provides recorder()) { + GestureAreaRenderer.Render(area(), Modifier) + } + } + // The drag starts once the finger passes touch slop, which it keeps. + compose.onNodeWithTag("node-2").performTouchInput { + down(center) + moveBy(Offset(30.dp.toPx(), 0f)) + moveBy(Offset(20.dp.toPx(), 0f)) + } + compose.runOnIdle { assertEquals(50f - slop, SharedValueStore.valueOf(PanId), 0.5f) } + compose.onNodeWithTag("node-2").performTouchInput { moveBy(Offset(40.dp.toPx(), 0f)) } + compose.runOnIdle { assertEquals(60f + (30f - slop) / 3f, SharedValueStore.valueOf(PanId), 0.5f) } + compose.onNodeWithTag("node-2").performTouchInput { up() } + } + + @Test fun pullLetGoShortOfThePointOnlySpringsBack() { + render() + compose.onNodeWithTag("node-2").performTouchInput { pull(40) } + compose.runOnIdle { + assertEquals(emptyList(), events()) + assertEquals(0f, SharedValueStore.valueOf(PanId), 0.01f) + assertEquals(emptyList(), haptics) + } + } + + @Test fun hapticPlaysEachTimeThePullGoesOutPastThePoint() { + render() + compose.onNodeWithTag("node-2").performTouchInput { + down(center) + listOf(40, 40, -40, -20, 60).forEach { moveBy(Offset(it.dp.toPx(), 0f)) } + up() + } + compose.runOnIdle { + assertEquals(listOf(HapticFeedbackType.GestureThresholdActivate, HapticFeedbackType.GestureThresholdActivate), haptics) + assertEquals(listOf("press:7"), events()) + } + } + + @Test fun pullBackUnderThePointBeforeLettingGoDoesNotTrigger() { + render() + compose.onNodeWithTag("node-2").performTouchInput { + down(center) + listOf(40, 40, -50).forEach { moveBy(Offset(it.dp.toPx(), 0f)) } + up() + } + compose.runOnIdle { + assertEquals(emptyList(), events()) + assertEquals(1, haptics.size) + } + } + + @Test fun dragTheOtherWayIsLeftToAnEnclosingScrollContainer() { + val scroll = ScrollState(500) + compose.setContent { + CompositionLocalProvider(LocalHapticFeedback provides recorder()) { + Row(Modifier.width(300.dp).horizontalScroll(scroll)) { + Spacer(Modifier.width(500.dp)) + GestureAreaRenderer.Render(area(), Modifier) + Spacer(Modifier.width(500.dp)) + } + } + } + compose.onNodeWithTag("node-2").performTouchInput { pull(-80) } + compose.runOnIdle { + assertTrue("scrolled to ${scroll.value}", scroll.value > 500) + assertEquals(0f, SharedValueStore.valueOf(PanId), 0.01f) + assertEquals(emptyList(), events()) + } + } + + /** Drags [dp] along x in steps, then lifts. */ + private fun TouchInjectionScope.pull(dp: Int) { + down(center) + repeat(8) { moveBy(Offset((dp / 8f).dp.toPx(), 0f)) } + up() + } + + private fun render() { + compose.setContent { + CompositionLocalProvider(LocalHapticFeedback provides recorder()) { + GestureAreaRenderer.Render(area(), Modifier) + } + } + } + + private fun recorder() = object : HapticFeedback { + override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) { + haptics += hapticFeedbackType + } + } + + private fun events() = NativeUIBridge.events.filter { it.startsWith("press:7") } + + private fun area() = NativeUINode( + 1, "", null, null, + GenericProps(mapOf("pan-x-id" to PanId, "pan-x-initial" to 0f, "pan-x-trigger" to 60f, "on_trigger" to 7)), + 0, 0, listOf(NativeUINode(2, "", null, null, GenericProps(emptyMap()), 0, 0, emptyList())), + ) + + private companion object { + /** Its own SharedValue, so no other test's pan moves it. */ + const val PanId = 4301 + } +}