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`,