From d135f295e8d6684868fb9d48f7e7782243520bee Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 24 Oct 2026 03:05:19 +0100 Subject: [PATCH] Keep a bounded gesture area's content on screen, on iOS and Android Pan stops at the zoomed content's edges and centres an axis it fits on, drags past an edge meet resistance and spring back on release, and a pinch zooms around the fingers and pans with them. Co-Authored-By: Claude Opus 5.5 --- resources/android/GestureAreaRenderer.kt | 217 ++++++++++++- .../ios/NativeUIGestureAreaRenderer.swift | 293 +++++++++++++++++- 2 files changed, 504 insertions(+), 6 deletions(-) diff --git a/resources/android/GestureAreaRenderer.kt b/resources/android/GestureAreaRenderer.kt index 6d79d4d..b193327 100644 --- a/resources/android/GestureAreaRenderer.kt +++ b/resources/android/GestureAreaRenderer.kt @@ -1,22 +1,37 @@ package com.nativephp.plugins.native_ui.ui +import androidx.compose.animation.core.CubicBezierEasing +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.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.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.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import com.nativephp.mobile.ui.nativerender.NativeUIBridge import com.nativephp.mobile.ui.nativerender.NativeUINode import com.nativephp.mobile.ui.nativerender.NodeView import com.nativephp.mobile.ui.nativerender.SharedValueStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import kotlin.math.abs /** @@ -60,6 +75,16 @@ import kotlin.math.abs * TEXT_CHANGE format). * - `swipe-fingers` (int) — touches required for the swipe * (default 1; Jump-style is 3). + * - `bounded` (bool) — keep the zoomed content inside the + * area (see PanBounds): pan stops at the + * content's edges, an axis the content + * fits on stays centred, a drag past an + * edge meets resistance, and the content + * springs back into place on release. A + * pinch zooms around the fingers. + * - `content-ratio` (float) — the content's width over height, + * fitted inside the area, for `bounded` + * (0 = the content fills the area). * * Per-frame value updates happen on the Compose render thread; PHP is * only involved on the discrete events. @@ -80,6 +105,18 @@ object GestureAreaRenderer { val onSwipe = node.props.getInt("on_swipe", 0) val swipeFingers = node.props.getInt("swipe-fingers", 1) + // `bounded` only: the area's size in dp (pan values are dp), and the + // two-finger state and spring back the drag and pinch share. + val density = LocalDensity.current + var areaSize by remember { mutableStateOf(Size.Zero) } + val bounds = PanBounds( + enabled = node.props.getBool("bounded"), + area = areaSize, + contentRatio = node.props.getFloat("content-ratio", 0f), + ) + val scope = rememberCoroutineScope() + val settle = remember { BoundedPan() } + // Seed during COMPOSITION (inline, before children compose) rather // than in a LaunchedEffect: effects run after children have // composed, and a child's `evaluate()` read materializes the store @@ -99,8 +136,50 @@ object GestureAreaRenderer { Box( modifier = modifier .nuiA11y(node.props.getString("a11y_label"), node.props.getString("a11y_hint")) - .pointerInput(panXId, panYId, onDragEnd) { + .onSizeChanged { size -> + areaSize = with(density) { Size(size.width.toDp().value, size.height.toDp().value) } + } + .pointerInput(panXId, panYId, onDragEnd, bounds) { if (panXId == 0 && panYId == 0 && onDragEnd == 0) return@pointerInput + if (bounds.enabled) { + // The undamped pan the finger asked for, and what was + // last written, per axis: a pinch or the spring back + // moving the pan between steps is picked up again. + var rawX = 0f + var rawY = 0f + var writtenX = 0f + var writtenY = 0f + detectDragGestures( + onDragEnd = { + val (x, y) = settle.into(bounds, scope, panXId, panYId, pinchId) + if (onDragEnd != 0) { + NativeUIBridge.sendTextChangeEvent(onDragEnd, node.id, "$x,$y") + } + }, + onDragCancel = { settle.into(bounds, scope, panXId, panYId, pinchId) }, + ) { _, dragAmount -> + // Two fingers down: the pinch moves the pan with + // both of them, so the first finger alone mustn't. + if (settle.pinching) return@detectDragGestures + settle.stop() + val scale = if (pinchId != 0) SharedValueStore.valueOf(pinchId) else 1f + if (panXId != 0) { + val current = SharedValueStore.valueOf(panXId) + if (current != writtenX) rawX = current + rawX += dragAmount.x.toDp().value + writtenX = bounds.resisted(rawX, horizontal = true, scale = scale) + SharedValueStore.set(panXId, writtenX) + } + if (panYId != 0) { + val current = SharedValueStore.valueOf(panYId) + if (current != writtenY) rawY = current + rawY += dragAmount.y.toDp().value + writtenY = bounds.resisted(rawY, horizontal = false, scale = scale) + SharedValueStore.set(panYId, writtenY) + } + } + return@pointerInput + } // Raw gesture translation for this drag, in dp — what // `on_drag_end` reports for an axis with no SharedValue. var gestureX = 0f @@ -155,7 +234,7 @@ object GestureAreaRenderer { ) { _, dragAmount -> onDrag(dragAmount, dragAmount) } } } - .pointerInput(pinchId, onPinchEnd, pinchMin, pinchMax) { + .pointerInput(pinchId, onPinchEnd, pinchMin, pinchMax, bounds) { if (pinchId == 0 && onPinchEnd == 0) return@pointerInput awaitEachGesture { // The zoom factor is dimensionless — no px/dp @@ -167,12 +246,15 @@ object GestureAreaRenderer { // reversals respond on the first frame. var scale = if (pinchId != 0) SharedValueStore.valueOf(pinchId) else 1f var zoomed = false + var moved = false awaitFirstDown(requireUnconsumed = false) var pressed = true while (pressed) { val event = awaitPointerEvent() - if (event.changes.count { it.pressed } >= 2) { + val fingers = event.changes.count { it.pressed } + if (fingers >= 2) { val zoom = event.calculateZoom() + val previous = scale if (zoom != 1f) { zoomed = true scale *= zoom @@ -182,9 +264,43 @@ object GestureAreaRenderer { SharedValueStore.set(pinchId, scale) } } + if (bounds.enabled && pinchId != 0 && previous > 0f) { + settle.pinching = true + settle.stop() + // Zoom around the fingers and move with them: + // a point at c from the centre maps to + // scale * p + pan, so keeping the point that + // was under the fingers' last centre (c - its + // move) under the new one gives + // pan' = c - r * (c - move - pan). + val applied = scale / previous + val centroid = event.calculateCentroid(useCurrent = true) + val move = event.calculatePan() + if (centroid != Offset.Unspecified) { + if (move != Offset.Zero) moved = true + val cx = centroid.x.toDp().value - bounds.area.width / 2 + val cy = centroid.y.toDp().value - bounds.area.height / 2 + val lastX = cx - move.x.toDp().value + val lastY = cy - move.y.toDp().value + if (panXId != 0) { + val pan = SharedValueStore.valueOf(panXId) + SharedValueStore.set(panXId, cx - applied * (lastX - pan)) + } + if (panYId != 0) { + val pan = SharedValueStore.valueOf(panYId) + SharedValueStore.set(panYId, cy - applied * (lastY - pan)) + } + } + } + } else { + settle.pinching = false } pressed = event.changes.any { it.pressed } } + settle.pinching = false + if ((zoomed || moved) && bounds.enabled) { + settle.into(bounds, scope, panXId, panYId, pinchId) + } if (zoomed && onPinchEnd != 0) { NativeUIBridge.sendSliderChangeEvent(onPinchEnd, node.id, scale) } @@ -268,4 +384,99 @@ object GestureAreaRenderer { private class BindingHolder { var pair: Pair? = null } + + /** + * Where `bounded` lets the pan go, in dp. The content is the area, or + * `content-ratio` fitted inside it (where `fit="contain"` draws an + * image). Zoomed by `scale` about the centre, it may move until its + * edges meet the area's: `(scale * content - area) / 2` each way, or + * not at all on an axis it fits on, which keeps it centred there. + */ + private data class PanBounds(val enabled: Boolean, val area: Size, val contentRatio: Float) { + private val content: Size + get() { + if (contentRatio <= 0f || area.width <= 0f || area.height <= 0f) return area + return if (area.width / area.height > contentRatio) { + Size(area.height * contentRatio, area.height) + } else { + Size(area.width, area.width / contentRatio) + } + } + + fun limit(horizontal: Boolean, scale: Float): Float { + val content = content + return if (horizontal) { + maxOf(0f, (scale * content.width - area.width) / 2) + } else { + maxOf(0f, (scale * content.height - area.height) / 2) + } + } + + /** Where a pan comes to rest: inside the limit. */ + fun settled(pan: Float, horizontal: Boolean, scale: Float): Float { + if (!enabled) return pan + val limit = limit(horizontal, scale) + return pan.coerceIn(-limit, limit) + } + + /** + * What the finger's undamped pan shows: itself inside the limit, + * and past it an overshoot that grows ever slower, never more than + * the area's own size (iOS's rubber band, so both feel the same). + */ + fun resisted(pan: Float, horizontal: Boolean, scale: Float): Float { + val limit = limit(horizontal, scale) + val overshoot = abs(pan) - limit + if (overshoot <= 0f) return pan + val extent = maxOf(1f, if (horizontal) area.width else area.height) + val damped = (1 - 1 / (overshoot * 0.55f / extent + 1)) * extent + return (if (pan < 0) -1 else 1) * (limit + damped) + } + } + + /** + * What a bounded area's drag and pinch share: whether two fingers are + * down (the pinch then moves the pan, not the drag), and the spring + * back inside the bounds. + */ + private class BoundedPan { + var pinching = false + + private var job: Job? = null + + fun stop() { + job?.cancel() + job = null + } + + /** + * Moves each bound axis back inside the bounds at the current zoom, + * and returns where they'll rest (x, y) for `on_drag_end`. + */ + fun into(bounds: PanBounds, scope: CoroutineScope, panXId: Int, panYId: Int, pinchId: Int): Pair { + stop() + val scale = if (pinchId != 0) SharedValueStore.valueOf(pinchId) else 1f + val moves = listOf(panXId to true, panYId to false) + .filter { (id, _) -> id != 0 } + .map { (id, horizontal) -> + val from = SharedValueStore.valueOf(id) + Triple(id, from, bounds.settled(from, horizontal, scale)) + } + if (moves.any { (_, from, to) -> abs(to - from) > 0.01f }) { + job = scope.launch { + animate(0f, 1f, animationSpec = tween(350, easing = EaseOutCubic)) { progress, _ -> + moves.forEach { (id, from, to) -> SharedValueStore.set(id, from + (to - from) * progress) } + } + } + } + val x = moves.firstOrNull { it.first == panXId }?.third ?: 0f + val y = moves.firstOrNull { it.first == panYId }?.third ?: 0f + return x to y + } + + private companion object { + /** Fast away from the edge, slowing into place. */ + val EaseOutCubic = CubicBezierEasing(0.33f, 1f, 0.68f, 1f) + } + } } diff --git a/resources/ios/NativeUIGestureAreaRenderer.swift b/resources/ios/NativeUIGestureAreaRenderer.swift index a15df98..0c75590 100644 --- a/resources/ios/NativeUIGestureAreaRenderer.swift +++ b/resources/ios/NativeUIGestureAreaRenderer.swift @@ -43,6 +43,16 @@ import UIKit /// TEXT_CHANGE format). /// - `swipe-fingers` (int) — touches required for the swipe /// (default 1; Jump-style is 3). +/// - `bounded` (bool) — keep the zoomed content inside the +/// area (see NativeUIPanBounds): pan stops at the +/// content's edges, an axis the content +/// fits on stays centred, a drag past an +/// edge meets resistance, and the content +/// springs back into place on release. A +/// pinch zooms around the fingers. +/// - `content-ratio` (float) — the content's width over height, +/// fitted inside the area, for `bounded` +/// (0 = the content fills the area). /// /// On gesture end, each anchor is reset so subsequent gestures continue /// from the current value rather than snapping back. @@ -53,6 +63,18 @@ struct NativeUIGestureAreaRenderer: View { @State private var dragStart: CGFloat = 0 @ObservedObject private var store = SharedValueStore.shared + // `bounded` only: the area's size, the drag's last translation (bounded + // drags move by each step's change, so a pinch can move the pan in the + // same gesture), the undamped pan the finger asked for and what was last + // written, whether two fingers are pinching (the pinch then moves the + // pan, not the drag), and the spring back. + @State private var areaSize: CGSize = .zero + @State private var lastTranslation: CGSize = .zero + @State private var rawPan: CGSize = .zero + @State private var writtenPan: CGSize = .zero + @State private var pinching = false + @State private var settle = NativeUIPanSettle() + var body: some View { let panXId = node.props.getInt("pan-x-id", default: 0) let panXInitial = CGFloat(node.props.getFloat("pan-x-initial", default: 0)) @@ -67,6 +89,11 @@ struct NativeUIGestureAreaRenderer: View { let onPinchEnd = node.props.getInt("on_pinch_end", default: 0) let onSwipe = node.props.getInt("on_swipe", default: 0) let swipeFingers = node.props.getInt("swipe-fingers", default: 1) + let bounds = NativeUIPanBounds( + enabled: node.props.getBool("bounded"), + area: areaSize, + contentRatio: CGFloat(node.props.getFloat("content-ratio", default: 0)) + ) let a11yLabel = node.props.getString("a11y_label") let a11yHint = node.props.getString("a11y_hint") @@ -76,6 +103,7 @@ struct NativeUIGestureAreaRenderer: View { } } .contentShape(Rectangle()) + .onGeometryChange(for: CGSize.self) { $0.size } action: { areaSize = $0 } // `including: .subviews` disables the drag entirely when no pan // binding exists — DragGesture(minimumDistance: 0) recognizes on // touch-down and would otherwise starve the swipe recognizers @@ -83,6 +111,10 @@ struct NativeUIGestureAreaRenderer: View { .gesture( DragGesture(minimumDistance: 0) .onChanged { value in + if bounds.enabled { + dragBounded(value.translation, panXId: panXId, panYId: panYId, pinchId: pinchId, bounds: bounds) + return + } if panXId != 0 { store.set(dragStartX + value.translation.width, for: panXId) } @@ -91,6 +123,10 @@ struct NativeUIGestureAreaRenderer: View { } } .onEnded { value in + if bounds.enabled { + lastTranslation = .zero + settleIntoBounds(panXId: panXId, panYId: panYId, pinchId: pinchId, bounds: bounds) + } if panXId != 0 { dragStartX = store.value(for: panXId) } if panYId != 0 { dragStart = store.value(for: panYId) } guard onDragEnd != 0 else { return } @@ -98,8 +134,9 @@ struct NativeUIGestureAreaRenderer: View { // an unbound axis reports the raw gesture translation // so a callback-only area (no SharedValue) still learns // where the finger let go. - let x = panXId != 0 ? store.value(for: panXId) : value.translation.width - let y = panYId != 0 ? store.value(for: panYId) : value.translation.height + let scale = pinchId != 0 ? store.value(for: pinchId) : 1 + let x = panXId != 0 ? bounds.settled(store.value(for: panXId), axis: .horizontal, scale: scale) : value.translation.width + let y = panYId != 0 ? bounds.settled(store.value(for: panYId), axis: .vertical, scale: scale) : value.translation.height NativeElementBridge.sendTextChangeEvent(onDragEnd, nodeId: node.id, text: "\(x),\(y)") }, including: hasPan ? .all : .subviews @@ -109,7 +146,18 @@ struct NativeUIGestureAreaRenderer: View { pinchId: pinchId, onPinchEnd: onPinchEnd, minScale: pinchMin, - maxScale: pinchMax + maxScale: pinchMax, + bounds: bounds, + panXId: panXId, + panYId: panYId, + onBoundedBegin: { + settle.stop() + pinching = true + }, + onBoundedEnd: { + pinching = false + settleIntoBounds(panXId: panXId, panYId: panYId, pinchId: pinchId, bounds: bounds) + } )) .modifier(SwipeModifier( nodeId: node.id, @@ -159,6 +207,49 @@ struct NativeUIGestureAreaRenderer: View { .modifier(A11yHintModifier(hint: a11yHint)) } + /// A bounded drag step: moves each bound axis by this step's change, + /// freely inside the bounds and with growing resistance past them. + private func dragBounded(_ translation: CGSize, panXId: Int, panYId: Int, pinchId: Int, bounds: NativeUIPanBounds) { + let step = CGSize(width: translation.width - lastTranslation.width, height: translation.height - lastTranslation.height) + lastTranslation = translation + // SwiftUI's drag follows one finger; while two are down the pinch + // moves the pan with both of them. + guard !pinching else { return } + settle.stop() + let scale = pinchId != 0 ? store.value(for: pinchId) : 1 + if panXId != 0 { + // Something else (a pinch, the spring back) moved the pan since + // the last step: carry on from where it is. + let current = store.value(for: panXId) + if current != writtenPan.width { rawPan.width = current } + rawPan.width += step.width + writtenPan.width = bounds.resisted(rawPan.width, axis: .horizontal, scale: scale) + store.set(writtenPan.width, for: panXId) + } + if panYId != 0 { + let current = store.value(for: panYId) + if current != writtenPan.height { rawPan.height = current } + rawPan.height += step.height + writtenPan.height = bounds.resisted(rawPan.height, axis: .vertical, scale: scale) + store.set(writtenPan.height, for: panYId) + } + } + + /// Springs each bound axis back inside the bounds at the current zoom. + private func settleIntoBounds(panXId: Int, panYId: Int, pinchId: Int, bounds: NativeUIPanBounds) { + let scale = pinchId != 0 ? store.value(for: pinchId) : 1 + var moves: [NativeUIPanSettle.Move] = [] + if panXId != 0 { + let from = store.value(for: panXId) + moves.append(.init(id: panXId, from: from, to: bounds.settled(from, axis: .horizontal, scale: scale))) + } + if panYId != 0 { + let from = store.value(for: panYId) + moves.append(.init(id: panYId, from: from, to: bounds.settled(from, axis: .vertical, scale: scale))) + } + settle.run(moves) + } + private func seedPanX(_ id: Int, initial: CGFloat) { guard id != 0 else { return } if store.values[id] == nil { @@ -202,6 +293,14 @@ private struct PinchModifier: ViewModifier { let onPinchEnd: Int let minScale: CGFloat // 0 = unbounded let maxScale: CGFloat // 0 = unbounded + /// With `bounded`, a UIKit pinch (see BoundedPinchRecognizer) zooms + /// around the fingers and pans with them, and the content springs back + /// into place when they lift. + let bounds: NativeUIPanBounds + let panXId: Int + let panYId: Int + let onBoundedBegin: () -> Void + let onBoundedEnd: () -> Void @State private var lastMagnification: CGFloat = 1 /// Running scale when no SharedValue is bound (`@pinchEnd` only). @@ -210,6 +309,21 @@ private struct PinchModifier: ViewModifier { func body(content: Content) -> some View { if pinchId == 0 && onPinchEnd == 0 { content + } else if bounds.enabled && pinchId != 0 { + content.gesture(BoundedPinchRecognizer( + bounds: bounds, + pinchId: pinchId, + panXId: panXId, + panYId: panYId, + clamp: { clamped($0) }, + onBegin: onBoundedBegin, + onEnd: { final in + onBoundedEnd() + if onPinchEnd != 0 { + NativeElementBridge.sendSliderChangeEvent(onPinchEnd, nodeId: nodeId, value: Float(final)) + } + } + )) } else { content.simultaneousGesture( MagnifyGesture() @@ -244,6 +358,179 @@ private struct PinchModifier: ViewModifier { } } +// MARK: - Bounded pan + +/// The pinch of a `bounded` area. UIKit's pinch reports where the fingers +/// are on every step (SwiftUI's only where they started), so the zoom +/// follows them and both fingers pan together: a point at c from the +/// centre maps to scale * p + pan, so keeping the point that was under the +/// fingers' last centre under the new one gives +/// pan' = c - r * (last - pan). +private struct BoundedPinchRecognizer: UIGestureRecognizerRepresentable { + let bounds: NativeUIPanBounds + let pinchId: Int + let panXId: Int + let panYId: Int + let clamp: (CGFloat) -> CGFloat + let onBegin: () -> Void + let onEnd: (CGFloat) -> Void + + func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { + Coordinator() + } + + func makeUIGestureRecognizer(context: Context) -> UIPinchGestureRecognizer { + let recognizer = UIPinchGestureRecognizer() + recognizer.cancelsTouchesInView = false + recognizer.delegate = context.coordinator + return recognizer + } + + func handleUIGestureRecognizerAction(_ recognizer: UIPinchGestureRecognizer, context: Context) { + let store = SharedValueStore.shared + let location = context.converter.localLocation + let centre = CGPoint(x: location.x - bounds.area.width / 2, y: location.y - bounds.area.height / 2) + + switch recognizer.state { + case .began: + recognizer.scale = 1 + context.coordinator.lastCentre = centre + onBegin() + case .changed: + // A finger lifting or landing moves the centre without the + // content moving: start again from the new centre. + guard recognizer.numberOfTouches >= 2, let last = context.coordinator.lastCentre, + context.coordinator.touches == recognizer.numberOfTouches else { + context.coordinator.touches = recognizer.numberOfTouches + context.coordinator.lastCentre = centre + recognizer.scale = 1 + return + } + let previous = store.value(for: pinchId) + let next = clamp(previous * recognizer.scale) + recognizer.scale = 1 + store.set(next, for: pinchId) + let applied = previous > 0 ? next / previous : 1 + if panXId != 0 { + store.set(centre.x - applied * (last.x - store.value(for: panXId)), for: panXId) + } + if panYId != 0 { + store.set(centre.y - applied * (last.y - store.value(for: panYId)), for: panYId) + } + context.coordinator.lastCentre = centre + case .ended, .cancelled, .failed: + context.coordinator.lastCentre = nil + context.coordinator.touches = 2 + onEnd(store.value(for: pinchId)) + default: + break + } + } + + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + var lastCentre: CGPoint? + var touches = 2 + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + true + } + } +} + +/// Where `bounded` lets the pan go. The content is the area, or +/// `content-ratio` fitted inside it (where `fit="contain"` draws an +/// image). Zoomed by `scale` about the centre, it may move until its +/// edges meet the area's: `(scale * content - area) / 2` each way, or not +/// at all on an axis it fits on, which keeps it centred there. +struct NativeUIPanBounds { + enum Axis { case horizontal, vertical } + + let enabled: Bool + let area: CGSize + let contentRatio: CGFloat + + private var content: CGSize { + guard contentRatio > 0, area.width > 0, area.height > 0 else { return area } + return area.width / area.height > contentRatio + ? CGSize(width: area.height * contentRatio, height: area.height) + : CGSize(width: area.width, height: area.width / contentRatio) + } + + func limit(_ axis: Axis, scale: CGFloat) -> CGFloat { + let content = content + return axis == .horizontal + ? max(0, (scale * content.width - area.width) / 2) + : max(0, (scale * content.height - area.height) / 2) + } + + /// Where a pan comes to rest: inside the limit. + func settled(_ pan: CGFloat, axis: Axis, scale: CGFloat) -> CGFloat { + guard enabled else { return pan } + let limit = limit(axis, scale: scale) + return min(limit, max(-limit, pan)) + } + + /// What the finger's undamped pan shows: itself inside the limit, and + /// past it an overshoot that grows ever slower, never more than the + /// area's own size (UIScrollView's rubber band). + func resisted(_ pan: CGFloat, axis: Axis, scale: CGFloat) -> CGFloat { + let limit = limit(axis, scale: scale) + let overshoot = abs(pan) - limit + guard overshoot > 0 else { return pan } + let extent = max(1, axis == .horizontal ? area.width : area.height) + let damped = (1 - 1 / (overshoot * 0.55 / extent + 1)) * extent + return (pan < 0 ? -1 : 1) * (limit + damped) + } +} + +/// Moves pan values back inside their bounds, frame by frame through the +/// store. The content's transform reads the store directly and its own +/// modifier turns SwiftUI animations off for it, so a `withAnimation` +/// write wouldn't move it smoothly. +final class NativeUIPanSettle: NSObject { + struct Move { + let id: Int + let from: CGFloat + let to: CGFloat + } + + private static let duration: CFTimeInterval = 0.35 + + private var link: CADisplayLink? + private var startedAt: CFTimeInterval = 0 + private var moves: [Move] = [] + + func run(_ moves: [Move]) { + stop() + self.moves = moves.filter { $0.id != 0 && abs($0.to - $0.from) > 0.01 } + guard !self.moves.isEmpty else { return } + startedAt = CACurrentMediaTime() + let link = CADisplayLink(target: self, selector: #selector(step(_:))) + link.add(to: .main, forMode: .common) + self.link = link + } + + func stop() { + link?.invalidate() + link = nil + } + + @objc private func step(_ link: CADisplayLink) { + let progress = min(1, (link.targetTimestamp - startedAt) / Self.duration) + // Ease out: fast away from the edge, slowing into place. + let eased = 1 - pow(1 - progress, 3) + for move in moves { + SharedValueStore.shared.set(move.from + (move.to - move.from) * eased, for: move.id) + } + if progress >= 1 { + stop() + } + } +} + // MARK: - Swipe (conditional) /// Directional swipe with a configurable touch count (`swipe-fingers`,