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