Skip to main content

cranpose_ui/widgets/
swipe_to_dismiss.rs

1//! SwipeToDismiss composable
2//!
3//! Mirrors Jetpack Compose's `SwipeToDismissBox` (Material) in spirit: wraps
4//! content that can be dragged horizontally; releasing past a threshold
5//! animates the content off-screen and fires `on_dismiss`, otherwise the
6//! content springs back into place.
7//!
8//! # Gesture disambiguation
9//!
10//! The drag capture mirrors the axis-locking rules of the scroll modifier so
11//! a `SwipeToDismiss` row inside a vertical `LazyColumn` coexists with the
12//! list scroll (children receive pointer events before ancestors):
13//!
14//! - the swipe captures the gesture only once the *horizontal* travel exceeds
15//!   the drag slop while dominating the vertical travel; from then on events
16//!   are consumed so the parent scroll abandons the gesture;
17//! - a decisively *vertical* start locks the swipe out for the rest of the
18//!   gesture, leaving every event unconsumed for the parent to scroll;
19//! - taps (no travel beyond the slop) consume nothing, so clickable rows
20//!   keep working.
21
22#![allow(non_snake_case)]
23
24use std::{
25    cell::{Cell, RefCell},
26    rc::Rc,
27    sync::atomic::{AtomicU64, Ordering},
28};
29
30use cranpose_animation::{Animatable, AnimationType, Spring, spring};
31use cranpose_core::{
32    NodeId, Owned, OwnedMutableState, RuntimeHandle, internal::FrameCallbackRegistration,
33    with_current_composer,
34};
35use cranpose_foundation::DRAG_THRESHOLD;
36use cranpose_ui_layout::{Measurable, MeasurePolicy, MeasureResult, MeasureScope, Placement};
37
38use crate::{
39    composable,
40    layout::policies::BoxMeasurePolicy,
41    modifier::{GraphicsLayer, Modifier, PointerEvent, PointerEventKind},
42    subcompose_layout::Constraints,
43    widgets::{
44        box_widget::{Box, BoxSpec},
45        layout::Layout,
46    },
47};
48
49/// Offset (logical px) within which a dismiss animation counts as settled.
50const DISMISS_SETTLE_EPSILON: f32 = 0.5;
51
52/// Height-scale (fraction of the natural height) at or below which the
53/// post-dismiss collapse is considered complete.
54const COLLAPSE_SETTLE_EPSILON: f32 = 0.01;
55
56/// Spring used both for the dismissal fling and the spring-back.
57fn swipe_spring() -> AnimationType {
58    spring(Spring::DampingRatioNoBouncy, Spring::StiffnessMediumLow)
59}
60
61/// Which edge the dismiss background is revealed on, i.e. the side the row is
62/// being swiped away from. Passed to the [`SwipeToDismissSpec::with_background`]
63/// closure so its label/icon can follow the swipe direction instead of being
64/// pinned to one side.
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub enum SwipeDismissSide {
67    /// The row is moving right (offset > 0); the background shows on the leading
68    /// (start / left-in-LTR) edge.
69    Start,
70    /// The row is moving left (offset < 0); the background shows on the trailing
71    /// (end / right-in-LTR) edge.
72    End,
73}
74
75/// Directions accepted by a swipe-dismiss container.
76#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
77pub enum SwipeDismissDirection {
78    #[default]
79    Both,
80    StartToEnd,
81    EndToStart,
82}
83
84/// Boxed background closure, invoked each frame with the current
85/// [`SwipeDismissSide`] so the reveal can follow the swipe direction.
86type BackgroundFn = Rc<RefCell<dyn FnMut(SwipeDismissSide)>>;
87
88/// Configuration for [`SwipeToDismiss`].
89#[derive(Clone)]
90pub struct SwipeToDismissSpec {
91    /// Fraction of the content width the offset must exceed on release for
92    /// the row to dismiss (default `0.5`). Clamped to `(0, 1]`.
93    pub threshold_fraction: f32,
94    background: Option<BackgroundFn>,
95    /// Identity of the wrapped content (e.g. the row's database id). When a
96    /// composition slot is reused for a DIFFERENT item — unkeyed lazy-list
97    /// rows all shift up after a removal — the remembered swipe state must
98    /// not leak onto the new item: without a key, the next row inherits the
99    /// dismissed row's displacement, revealed background and collapsed
100    /// height ("items go shuffled, red boxes stick").
101    ///
102    /// Inside a keyed lazy list this is filled in from the item's own key, so
103    /// a row states its identity once, where the list already states it. Set it
104    /// explicitly only for a row that is not a lazy item, or whose identity is
105    /// not the one the list is keyed by.
106    pub key: Option<u64>,
107    /// Caller-owned swipe state, from [`rememberSwipeDismissState`].
108    ///
109    /// Supply one to read the swipe as it happens — a label that fades in with
110    /// it, a count of what is about to go, an undo bar after it lands — or to
111    /// return a row to rest from elsewhere on the screen. Left unset, the row
112    /// owns its own state.
113    pub state: Option<SwipeDismissState>,
114    pub direction: SwipeDismissDirection,
115    pub edge_width: Option<f32>,
116    pub collapse_after_dismiss: bool,
117    /// Whether the content returns to rest once `on_dismiss` has fired
118    /// (default `false`).
119    ///
120    /// A dismissed ROW is about to be removed by its host, so it stays off
121    /// screen and the host drops it. A full-content NAVIGATION dismissal is
122    /// not that: the gesture means "go up one level", and the host may
123    /// legitimately answer by staying composed -- back out of a pause overlay
124    /// and the game underneath resumes in the same root composable. Left off
125    /// screen, that content never comes back, and since
126    /// [`SwipeToDismissBox`] owns its state internally the application has no
127    /// handle to call [`SwipeDismissState::reset`] on. The screen is then
128    /// blank, taps land on nothing, and further back gestures neither redraw
129    /// nor leave.
130    pub reset_after_dismiss: bool,
131    pub enabled: bool,
132}
133
134impl SwipeToDismissSpec {
135    pub fn new() -> Self {
136        Self {
137            threshold_fraction: 0.5,
138            background: None,
139            key: None,
140            state: None,
141            direction: SwipeDismissDirection::Both,
142            edge_width: None,
143            collapse_after_dismiss: true,
144            reset_after_dismiss: false,
145            enabled: true,
146        }
147    }
148
149    /// Declares the identity of the wrapped content. When the key changes,
150    /// the swipe state resets to rest — the new item starts untouched.
151    ///
152    /// A row inside a keyed lazy list already has an identity and does not
153    /// need this.
154    pub fn with_key(mut self, key: u64) -> Self {
155        self.key = Some(key);
156        self
157    }
158
159    /// Uses caller-owned swipe state, so the swipe can be read and reset from
160    /// outside the row.
161    pub fn with_state(mut self, state: SwipeDismissState) -> Self {
162        self.state = Some(state);
163        self
164    }
165
166    /// Sets the dismiss threshold as a fraction of the content width.
167    pub fn with_threshold_fraction(mut self, fraction: f32) -> Self {
168        self.threshold_fraction = fraction;
169        self
170    }
171
172    /// Sets the background content revealed behind the swiped row. The closure
173    /// receives the [`SwipeDismissSide`] the row is currently being swiped
174    /// toward, so it can align its label/icon to the revealed edge.
175    pub fn with_background(mut self, background: impl FnMut(SwipeDismissSide) + 'static) -> Self {
176        self.background = Some(Rc::new(RefCell::new(background)));
177        self
178    }
179
180    pub fn with_direction(mut self, direction: SwipeDismissDirection) -> Self {
181        self.direction = direction;
182        self
183    }
184
185    pub fn from_edge(mut self, width: f32) -> Self {
186        self.edge_width = Some(width.max(0.0));
187        self
188    }
189
190    pub fn with_reset_after_dismiss(mut self, reset: bool) -> Self {
191        self.reset_after_dismiss = reset;
192        self
193    }
194
195    pub fn with_collapse_after_dismiss(mut self, collapse: bool) -> Self {
196        self.collapse_after_dismiss = collapse;
197        self
198    }
199
200    pub fn with_enabled(mut self, enabled: bool) -> Self {
201        self.enabled = enabled;
202        self
203    }
204}
205
206impl Default for SwipeToDismissSpec {
207    fn default() -> Self {
208        Self::new()
209    }
210}
211
212/// Phase of the swipe gesture state machine.
213#[derive(Clone, Copy, Debug, PartialEq)]
214enum SwipePhase {
215    /// No active pointer sequence.
216    Idle,
217    /// Pointer down, axis not decided yet.
218    Tracking {
219        down_x: f32,
220        down_y: f32,
221        start_offset: f32,
222    },
223    /// Horizontal axis won: the swipe owns the gesture and consumes events.
224    Dragging { down_x: f32, start_offset: f32 },
225    /// Vertical axis won decisively: never capture for this gesture.
226    LockedOut,
227}
228
229/// Axis decision for the initial slop check. Mirrors the scroll modifier:
230/// the main axis captures when it exceeds the slop *and* dominates; the
231/// cross axis locks out when it exceeds the slop and strictly dominates.
232#[derive(Clone, Copy, Debug, PartialEq, Eq)]
233pub(crate) enum SwipeAxisDecision {
234    Undecided,
235    Horizontal,
236    Vertical,
237}
238
239/// Decides the gesture axis from the total travel since pointer down.
240pub(crate) fn decide_axis(total_dx: f32, total_dy: f32, slop: f32) -> SwipeAxisDecision {
241    let horizontal = total_dx.abs();
242    let vertical = total_dy.abs();
243    if horizontal > slop && horizontal >= vertical {
244        SwipeAxisDecision::Horizontal
245    } else if vertical > slop && vertical > horizontal {
246        SwipeAxisDecision::Vertical
247    } else {
248        SwipeAxisDecision::Undecided
249    }
250}
251
252/// Where the released row should animate to: `Some(±width)` when the offset
253/// crossed the dismiss threshold, `None` to spring back to rest.
254pub(crate) fn dismissal_target(offset: f32, width: f32, threshold_fraction: f32) -> Option<f32> {
255    if !width.is_finite() || width <= 0.0 {
256        return None;
257    }
258    let threshold = width * threshold_fraction.clamp(f32::EPSILON, 1.0);
259    (offset.abs() >= threshold).then(|| width * offset.signum())
260}
261
262/// Clamps the dragged offset so the content cannot travel further than one
263/// full width in either direction.
264pub(crate) fn clamp_offset(offset: f32, width: f32) -> f32 {
265    if width.is_finite() && width > 0.0 {
266        offset.clamp(-width, width)
267    } else {
268        offset
269    }
270}
271
272static NEXT_SWIPE_ID: AtomicU64 = AtomicU64::new(0);
273
274/// State shared between the composable (which reads the animated offset) and
275/// the pointer-input handler (which drives it). Main-thread only.
276struct SwipeToDismissController {
277    id: u64,
278    runtime: RuntimeHandle,
279    offset: RefCell<Animatable<f32>>,
280    revealed: OwnedMutableState<bool>,
281    collapse: RefCell<Animatable<f32>>,
282    phase: Cell<SwipePhase>,
283    width_px: Cell<f32>,
284    threshold_fraction: Cell<f32>,
285    on_dismiss: RefCell<Option<Rc<dyn Fn()>>>,
286    dismissed: OwnedMutableState<bool>,
287    node_id: Cell<Option<NodeId>>,
288    settle_watcher: RefCell<Option<FrameCallbackRegistration>>,
289    collapse_watcher: RefCell<Option<FrameCallbackRegistration>>,
290    identity: Cell<Option<u64>>,
291    active_pointer: Cell<Option<u64>>,
292    direction: Cell<SwipeDismissDirection>,
293    edge_width: Cell<Option<f32>>,
294    collapse_after_dismiss: Cell<bool>,
295    reset_after_dismiss: Cell<bool>,
296    enabled: Cell<bool>,
297}
298
299impl SwipeToDismissController {
300    fn new(runtime: RuntimeHandle) -> Rc<Self> {
301        Rc::new(Self {
302            id: NEXT_SWIPE_ID.fetch_add(1, Ordering::Relaxed),
303            offset: RefCell::new(Animatable::new(0.0, runtime.clone())),
304            revealed: OwnedMutableState::with_runtime(false, runtime.clone()),
305            collapse: RefCell::new(Animatable::new(1.0, runtime.clone())),
306            dismissed: OwnedMutableState::with_runtime(false, runtime.clone()),
307            runtime,
308            phase: Cell::new(SwipePhase::Idle),
309            width_px: Cell::new(f32::NAN),
310            threshold_fraction: Cell::new(0.5),
311            on_dismiss: RefCell::new(None),
312            node_id: Cell::new(None),
313            settle_watcher: RefCell::new(None),
314            collapse_watcher: RefCell::new(None),
315            identity: Cell::new(None),
316            active_pointer: Cell::new(None),
317            direction: Cell::new(SwipeDismissDirection::Both),
318            edge_width: Cell::new(None),
319            collapse_after_dismiss: Cell::new(true),
320            reset_after_dismiss: Cell::new(false),
321            enabled: Cell::new(true),
322        })
323    }
324
325    fn reset_to_rest(&self) {
326        self.settle_watcher.borrow_mut().take();
327        self.collapse_watcher.borrow_mut().take();
328        self.offset.borrow_mut().snapTo(0.0);
329        self.collapse.borrow_mut().snapTo(1.0);
330        self.phase.set(SwipePhase::Idle);
331        self.active_pointer.set(None);
332        self.set_dismissed(false);
333        self.set_revealed(false);
334    }
335
336    fn current_offset(&self) -> f32 {
337        self.offset.borrow().state().value()
338    }
339
340    fn revealed_side(&self) -> SwipeDismissSide {
341        if self.current_offset() >= 0.0 {
342            SwipeDismissSide::Start
343        } else {
344            SwipeDismissSide::End
345        }
346    }
347
348    fn collapse_fraction(&self) -> f32 {
349        self.collapse.borrow().state().value()
350    }
351
352    fn revealed(&self) -> bool {
353        self.revealed.value()
354    }
355
356    fn set_revealed(&self, revealed: bool) {
357        if self.revealed.get_non_reactive() != revealed {
358            self.revealed.set_value(revealed);
359        }
360    }
361
362    fn set_dismissed(&self, dismissed: bool) {
363        if self.dismissed.get_non_reactive() != dismissed {
364            self.dismissed.set_value(dismissed);
365        }
366    }
367
368    fn snap_to(&self, offset: f32) {
369        self.offset.borrow_mut().snapTo(offset);
370        self.set_revealed(offset != 0.0);
371    }
372
373    fn animate_to(&self, target: f32) {
374        self.offset.borrow_mut().animateTo(target, swipe_spring());
375        if target != 0.0 {
376            self.set_revealed(true);
377        }
378    }
379}
380
381/// A row's swipe, as the application can see it.
382///
383/// A dismissable row is not only a callback: an application shows a delete
384/// label that fades in with the swipe, a counter of what is about to go, or an
385/// undo bar once a row has left. All of that needs the swipe itself, not just
386/// its ending, so the same state the widget drives is readable from outside it.
387///
388/// Cloning shares one row's state. Hold it with [`rememberSwipeDismissState`]
389/// and hand it to [`SwipeToDismissSpec::with_state`].
390#[derive(Clone)]
391pub struct SwipeDismissState {
392    controller: Rc<SwipeToDismissController>,
393}
394
395impl PartialEq for SwipeDismissState {
396    fn eq(&self, other: &Self) -> bool {
397        Rc::ptr_eq(&self.controller, &other.controller)
398    }
399}
400
401impl SwipeDismissState {
402    fn new(runtime: RuntimeHandle) -> Self {
403        Self {
404            controller: SwipeToDismissController::new(runtime),
405        }
406    }
407
408    /// How far the row is displaced, in logical pixels: positive towards the
409    /// start edge, negative towards the end. Reactive.
410    pub fn offset(&self) -> f32 {
411        self.controller.current_offset()
412    }
413
414    /// How far through a dismissal the row is, in `0..=1`, against the same
415    /// threshold a release is judged by. `1.0` means letting go now dismisses.
416    ///
417    /// Zero before the row has been measured — a fraction of an unknown width
418    /// would be a guess.
419    pub fn progress(&self) -> f32 {
420        let width = self.controller.width_px.get();
421        if !width.is_finite() || width <= 0.0 {
422            return 0.0;
423        }
424        let threshold = width * self.controller.threshold_fraction.get();
425        if threshold <= 0.0 {
426            return 0.0;
427        }
428        (self.offset().abs() / threshold).clamp(0.0, 1.0)
429    }
430
431    /// The edge the background is revealed on, or `None` while the row is at
432    /// rest and no edge is showing.
433    pub fn side(&self) -> Option<SwipeDismissSide> {
434        (self.offset() != 0.0).then(|| self.controller.revealed_side())
435    }
436
437    /// Whether the row is away from rest — dragged, springing back, or leaving.
438    /// Reactive.
439    pub fn is_displaced(&self) -> bool {
440        self.controller.revealed()
441    }
442
443    /// Whether this row has been dismissed. Reactive.
444    pub fn is_dismissed(&self) -> bool {
445        self.controller.dismissed.value()
446    }
447
448    /// Returns the row to rest without dismissing it — an undo, or a screen
449    /// closing a menu the swipe opened.
450    pub fn reset(&self) {
451        self.controller.reset_to_rest();
452    }
453}
454
455/// Remembers the swipe state for one row.
456///
457/// Inside a keyed lazy list the state is keyed by the item, so a row removed
458/// from the middle does not leave its displacement on the row that moves up
459/// into its slot.
460#[allow(non_snake_case)]
461#[track_caller]
462pub fn rememberSwipeDismissState() -> SwipeDismissState {
463    let caller = cranpose_core::caller_location_key();
464    let state = with_current_composer(|composer| {
465        let runtime = composer.runtime_handle();
466        let owned: Owned<SwipeDismissState> =
467            composer.remember_at(caller, || SwipeDismissState::new(runtime));
468        owned.with(SwipeDismissState::clone)
469    });
470    let identity = crate::lazy_item::lazy_item_key();
471    if state.controller.identity.get() != identity {
472        state.controller.reset_to_rest();
473        state.controller.identity.set(identity);
474    }
475    state
476}
477
478/// Appends the swipe pointer-input handler to `base`. Split out of the
479/// composable so headless tests can drive the exact production modifier
480/// through a manually built modifier chain.
481fn swipe_gesture_modifier(base: Modifier, controller: Rc<SwipeToDismissController>) -> Modifier {
482    let key = controller.id;
483    base.pointer_input(key, move |scope| {
484        let controller = Rc::clone(&controller);
485        async move {
486            scope
487                .await_pointer_event_scope(|await_scope| async move {
488                    loop {
489                        let event = await_scope.await_pointer_event().await;
490                        handle_swipe_event(&controller, &event);
491                    }
492                })
493                .await;
494        }
495    })
496}
497
498/// Handles one pointer event for the swipe gesture. Returns nothing; event
499/// consumption communicates ownership to sibling/ancestor handlers.
500fn handle_swipe_event(controller: &Rc<SwipeToDismissController>, event: &PointerEvent) {
501    if event.kind != PointerEventKind::Down
502        && event.kind != PointerEventKind::Cancel
503        && controller.active_pointer.get() != Some(event.id)
504    {
505        return;
506    }
507
508    match event.kind {
509        PointerEventKind::Down => {
510            if event.is_consumed()
511                || !controller.enabled.get()
512                || controller.active_pointer.get().is_some()
513            {
514                return;
515            }
516            let width = controller.width_px.get();
517            let inside_edge = match (controller.edge_width.get(), controller.direction.get()) {
518                (None, _) => true,
519                (Some(edge), SwipeDismissDirection::StartToEnd) => event.global_position.x <= edge,
520                (Some(edge), SwipeDismissDirection::EndToStart) => {
521                    width.is_finite() && event.global_position.x >= width - edge
522                }
523                (Some(edge), SwipeDismissDirection::Both) => {
524                    event.global_position.x <= edge
525                        || (width.is_finite() && event.global_position.x >= width - edge)
526                }
527            };
528            if !inside_edge {
529                return;
530            }
531            controller.active_pointer.set(Some(event.id));
532            let current = controller.current_offset();
533            controller.snap_to(current);
534            controller.phase.set(SwipePhase::Tracking {
535                down_x: event.global_position.x,
536                down_y: event.global_position.y,
537                start_offset: current,
538            });
539        }
540        PointerEventKind::Move => {
541            if event.is_consumed() {
542                if matches!(controller.phase.get(), SwipePhase::Dragging { .. }) {
543                    animate_spring_back(controller);
544                }
545                controller.phase.set(SwipePhase::Idle);
546                return;
547            }
548            match controller.phase.get() {
549                SwipePhase::Tracking {
550                    down_x,
551                    down_y,
552                    start_offset,
553                } => {
554                    let total_dx = event.global_position.x - down_x;
555                    let total_dy = event.global_position.y - down_y;
556                    match decide_axis(total_dx, total_dy, DRAG_THRESHOLD) {
557                        SwipeAxisDecision::Horizontal => {
558                            controller.phase.set(SwipePhase::Dragging {
559                                down_x,
560                                start_offset,
561                            });
562                            let width = controller.width_px.get();
563                            controller.snap_to(constrain_direction(
564                                clamp_offset(start_offset + total_dx, width),
565                                controller.direction.get(),
566                            ));
567                            event.consume();
568                        }
569                        SwipeAxisDecision::Vertical => {
570                            controller.phase.set(SwipePhase::LockedOut);
571                        }
572                        SwipeAxisDecision::Undecided => {}
573                    }
574                }
575                SwipePhase::Dragging {
576                    down_x,
577                    start_offset,
578                } => {
579                    let total_dx = event.global_position.x - down_x;
580                    let width = controller.width_px.get();
581                    controller.snap_to(constrain_direction(
582                        clamp_offset(start_offset + total_dx, width),
583                        controller.direction.get(),
584                    ));
585                    event.consume();
586                }
587                SwipePhase::Idle | SwipePhase::LockedOut => {}
588            }
589        }
590        PointerEventKind::Up => {
591            let phase = controller.phase.get();
592            controller.phase.set(SwipePhase::Idle);
593            if let SwipePhase::Dragging { .. } = phase {
594                settle_release(controller);
595                event.consume();
596            }
597            controller.active_pointer.set(None);
598        }
599        PointerEventKind::Cancel => {
600            if matches!(controller.phase.get(), SwipePhase::Dragging { .. }) {
601                animate_spring_back(controller);
602            }
603            controller.phase.set(SwipePhase::Idle);
604            controller.active_pointer.set(None);
605        }
606        PointerEventKind::Scroll
607        | PointerEventKind::Zoom
608        | PointerEventKind::RotaryScrollPre
609        | PointerEventKind::RotaryScroll
610        | PointerEventKind::Enter
611        | PointerEventKind::Exit => {}
612    }
613}
614
615fn constrain_direction(offset: f32, direction: SwipeDismissDirection) -> f32 {
616    match direction {
617        SwipeDismissDirection::Both => offset,
618        SwipeDismissDirection::StartToEnd => offset.max(0.0),
619        SwipeDismissDirection::EndToStart => offset.min(0.0),
620    }
621}
622
623/// Applies the release decision: animate off-screen and watch for completion
624/// (firing `on_dismiss`), or spring back to rest.
625fn settle_release(controller: &Rc<SwipeToDismissController>) {
626    let offset = controller.current_offset();
627    let width = controller.width_px.get();
628    match dismissal_target(offset, width, controller.threshold_fraction.get()) {
629        Some(target) => animate_dismiss(controller, target),
630        None => animate_spring_back(controller),
631    }
632}
633
634/// Flings the row off-screen and watches for `on_dismiss` to fire once settled.
635fn animate_dismiss(controller: &Rc<SwipeToDismissController>, target: f32) {
636    controller.animate_to(target);
637    watch_settle(controller, true);
638}
639
640/// Springs the row back to rest and watches so the reveal is hidden again once
641/// the content actually returns to offset 0.
642fn animate_spring_back(controller: &Rc<SwipeToDismissController>) {
643    controller.animate_to(0.0);
644    watch_settle(controller, false);
645}
646
647/// Watches the settle animation frame-by-frame. Once the offset reaches its
648/// target it syncs the reveal flag to the resting displacement and, for a
649/// dismissal, fires `on_dismiss` exactly once. Runs in frame callbacks
650/// (outside composition), so the callback may freely mutate state.
651fn watch_settle(controller: &Rc<SwipeToDismissController>, dismissing: bool) {
652    let weak = Rc::downgrade(controller);
653    let registration =
654        controller
655            .runtime
656            .frame_clock()
657            .with_frame_nanos(move |_frame_time_nanos| {
658                let Some(controller) = weak.upgrade() else {
659                    return;
660                };
661                controller.settle_watcher.borrow_mut().take();
662                if dismissing && controller.dismissed.get_non_reactive() {
663                    return;
664                }
665                if matches!(controller.phase.get(), SwipePhase::Dragging { .. }) {
666                    return;
667                }
668                let target = controller.offset.borrow().target();
669                let value = controller.current_offset();
670                if (value - target).abs() <= DISMISS_SETTLE_EPSILON {
671                    controller.set_revealed(false);
672                    if dismissing && !controller.dismissed.get_non_reactive() {
673                        controller.set_dismissed(true);
674                        if controller.collapse_after_dismiss.get() {
675                            start_collapse(&controller);
676                        }
677                        let on_dismiss = controller.on_dismiss.borrow().clone();
678                        if let Some(on_dismiss) = on_dismiss {
679                            on_dismiss();
680                        }
681                        if controller.reset_after_dismiss.get() {
682                            controller.reset_to_rest();
683                        }
684                    }
685                } else {
686                    watch_settle(&controller, dismissing);
687                }
688            });
689    *controller.settle_watcher.borrow_mut() = Some(registration);
690}
691
692/// Animates the row's height scale to `0.0` and watches the animation so the
693/// list re-measures the shrinking row each frame. Runs after a dismiss settles.
694fn start_collapse(controller: &Rc<SwipeToDismissController>) {
695    controller
696        .collapse
697        .borrow_mut()
698        .animateTo(0.0, swipe_spring());
699    watch_collapse(controller);
700}
701
702/// Frame-by-frame watcher for the post-dismiss collapse: the row height is a
703/// layout output, so a plain animated value would not reach the parent list on
704/// its own — each frame this forces a scoped re-measure of the row and a redraw
705/// until the height scale reaches zero.
706fn watch_collapse(controller: &Rc<SwipeToDismissController>) {
707    let weak = Rc::downgrade(controller);
708    let registration =
709        controller
710            .runtime
711            .frame_clock()
712            .with_frame_nanos(move |_frame_time_nanos| {
713                let Some(controller) = weak.upgrade() else {
714                    return;
715                };
716                controller.collapse_watcher.borrow_mut().take();
717                if let Some(node_id) = controller.node_id.get() {
718                    crate::schedule_measure_repass(node_id);
719                }
720                crate::request_render_invalidation();
721                if controller.collapse_fraction() > COLLAPSE_SETTLE_EPSILON {
722                    watch_collapse(&controller);
723                }
724            });
725    *controller.collapse_watcher.borrow_mut() = Some(registration);
726}
727
728#[derive(Clone, Copy, PartialEq)]
729enum SwipeLayoutPhase {
730    Row,
731    Collapse,
732}
733
734#[derive(Clone)]
735struct SwipeMeasurePolicy {
736    controller: Rc<SwipeToDismissController>,
737    phase: SwipeLayoutPhase,
738}
739
740impl PartialEq for SwipeMeasurePolicy {
741    fn eq(&self, other: &Self) -> bool {
742        self.phase == other.phase && Rc::ptr_eq(&self.controller, &other.controller)
743    }
744}
745
746impl MeasurePolicy for SwipeMeasurePolicy {
747    fn measure(
748        &self,
749        scope: &dyn MeasureScope,
750        measurables: &[Box<dyn Measurable>],
751        constraints: Constraints,
752    ) -> MeasureResult {
753        if self.phase == SwipeLayoutPhase::Row {
754            self.controller.width_px.set(constraints.max_width);
755            return BoxMeasurePolicy::new(crate::Alignment::TOP_START, false).measure(
756                scope,
757                measurables,
758                constraints,
759            );
760        }
761        let child_constraints = Constraints {
762            min_height: 0.0,
763            ..constraints
764        };
765        let mut placements = Vec::with_capacity(measurables.len());
766        let mut width = 0.0_f32;
767        let mut natural_height = 0.0_f32;
768        for measurable in measurables {
769            let placeable = measurable.measure(child_constraints);
770            width = width.max(placeable.width());
771            natural_height = natural_height.max(placeable.height());
772            placeable.place(0.0, 0.0);
773            placements.push(Placement::new(placeable.node_id(), 0.0, 0.0, 0));
774        }
775        let width = width.clamp(constraints.min_width, constraints.max_width);
776        let height = (natural_height * self.controller.collapse_fraction().clamp(0.0, 1.0))
777            .clamp(0.0, constraints.max_height);
778        MeasureResult::new(crate::modifier::Size { width, height }, placements)
779    }
780
781    fn min_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
782        BoxMeasurePolicy::new(crate::Alignment::TOP_START, false)
783            .min_intrinsic_width(measurables, height)
784    }
785
786    fn max_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
787        BoxMeasurePolicy::new(crate::Alignment::TOP_START, false)
788            .max_intrinsic_width(measurables, height)
789    }
790
791    fn min_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
792        BoxMeasurePolicy::new(crate::Alignment::TOP_START, false)
793            .min_intrinsic_height(measurables, width)
794    }
795
796    fn max_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
797        BoxMeasurePolicy::new(crate::Alignment::TOP_START, false)
798            .max_intrinsic_height(measurables, width)
799    }
800}
801
802/// Wraps `content` so it can be swiped away horizontally.
803///
804/// Dragging moves the content with the finger (clamped to one content width
805/// in either direction). Releasing past `spec.threshold_fraction` of the
806/// width animates the content off-screen with a spring and then fires
807/// `on_dismiss` (exactly once); releasing earlier springs the content back.
808/// An optional [`SwipeToDismissSpec::with_background`] content (delete bin,
809/// archive icon, ...) is revealed behind the row while it is displaced.
810///
811/// Vertical drags are handed to ancestor scroll containers untouched, so
812/// rows inside a `LazyColumn` still scroll the list (see the module docs for
813/// the axis-locking rules).
814#[composable(no_skip)]
815pub fn SwipeToDismiss<D, F>(
816    modifier: Modifier,
817    spec: SwipeToDismissSpec,
818    on_dismiss: D,
819    content: F,
820) -> cranpose_core::NodeId
821where
822    D: Fn() + 'static,
823    F: FnMut() + 'static,
824{
825    let owned_controller: Rc<SwipeToDismissController> = with_current_composer(|composer| {
826        let runtime = composer.runtime_handle();
827        let owned: Owned<Rc<SwipeToDismissController>> =
828            composer.remember(|| SwipeToDismissController::new(runtime));
829        owned.with(Rc::clone)
830    });
831    let controller = match &spec.state {
832        Some(state) => Rc::clone(&state.controller),
833        None => owned_controller,
834    };
835
836    let identity = spec.key.or_else(crate::lazy_item::lazy_item_key);
837    if controller.identity.get() != identity {
838        controller.reset_to_rest();
839        controller.identity.set(identity);
840    }
841
842    controller
843        .threshold_fraction
844        .set(spec.threshold_fraction.clamp(f32::EPSILON, 1.0));
845    controller.direction.set(spec.direction);
846    controller.edge_width.set(spec.edge_width);
847    controller
848        .collapse_after_dismiss
849        .set(spec.collapse_after_dismiss);
850    controller.reset_after_dismiss.set(spec.reset_after_dismiss);
851    controller.enabled.set(spec.enabled);
852    *controller.on_dismiss.borrow_mut() = Some(Rc::new(on_dismiss));
853
854    let background = spec.background.clone();
855    let content = Rc::new(RefCell::new(content));
856
857    let gesture_modifier = swipe_gesture_modifier(modifier, Rc::clone(&controller));
858
859    let controller_for_layout = Rc::clone(&controller);
860    let node = Layout(
861        Modifier::empty(),
862        SwipeMeasurePolicy {
863            phase: SwipeLayoutPhase::Collapse,
864            controller: Rc::clone(&controller_for_layout),
865        },
866        move || {
867            let background = background.clone();
868            let content = Rc::clone(&content);
869            let controller_for_row = Rc::clone(&controller_for_layout);
870            let gesture_modifier = gesture_modifier.clone();
871            Layout(
872                gesture_modifier,
873                SwipeMeasurePolicy {
874                    phase: SwipeLayoutPhase::Row,
875                    controller: Rc::clone(&controller_for_row),
876                },
877                move || {
878                    if controller_for_row.revealed() {
879                        if let Some(background) = &background {
880                            let background = Rc::clone(background);
881                            let side = controller_for_row.revealed_side();
882                            Box(Modifier::empty(), BoxSpec::new(), move || {
883                                (background.borrow_mut())(side);
884                            });
885                        }
886                    }
887                    let content = Rc::clone(&content);
888                    let controller_for_layer = Rc::clone(&controller_for_row);
889                    Box(
890                        Modifier::empty().graphics_layer(move || GraphicsLayer {
891                            translation_x: controller_for_layer.current_offset(),
892                            ..GraphicsLayer::default()
893                        }),
894                        BoxSpec::new(),
895                        move || {
896                            (content.borrow_mut())();
897                        },
898                    );
899                },
900            );
901        },
902    );
903    controller.node_id.set(Some(node));
904    node
905}
906
907/// Full-content navigation dismissal. The gesture starts at the leading edge,
908/// moves only toward the end edge, and leaves sizing to the navigation owner.
909#[composable]
910pub fn SwipeToDismissBox<D, F>(modifier: Modifier, on_dismiss: D, content: F) -> NodeId
911where
912    D: Fn() + 'static,
913    F: FnMut() + 'static,
914{
915    SwipeToDismiss(
916        modifier,
917        SwipeToDismissSpec::new()
918            .with_threshold_fraction(0.35)
919            .with_direction(SwipeDismissDirection::StartToEnd)
920            .from_edge(32.0)
921            .with_collapse_after_dismiss(false)
922            .with_reset_after_dismiss(true),
923        on_dismiss,
924        content,
925    )
926}
927
928#[cfg(test)]
929#[path = "../tests/swipe_to_dismiss_tests.rs"]
930mod tests;