Skip to main content

repose_material/material3/
swipe.rs

1#![allow(non_snake_case)]
2
3use std::rc::Rc;
4
5use repose_core::animation::AnimationSpec;
6use repose_core::*;
7use repose_ui::{Box, Column, ViewExt};
8
9use super::*;
10
11/// Configuration for swipe-to-dismiss.
12#[derive(Clone, Debug)]
13pub struct SwipeToDismissConfig {
14    pub modifier: Modifier,
15    pub dismiss_threshold: f32,
16    pub dismissed_offset: f32,
17    pub animation_spec: AnimationSpec,
18    pub gestures_enabled: bool,
19    pub enable_dismiss_from_start_to_end: bool,
20    pub enable_dismiss_from_end_to_start: bool,
21}
22
23impl Default for SwipeToDismissConfig {
24    fn default() -> Self {
25        Self {
26            modifier: Modifier::new(),
27            dismiss_threshold: SwipeToDismissDefaults::DISMISS_THRESHOLD,
28            dismissed_offset: SwipeToDismissDefaults::DISMISSED_OFFSET,
29            animation_spec: AnimationSpec::spring_gentle(),
30            gestures_enabled: true,
31            enable_dismiss_from_start_to_end: true,
32            enable_dismiss_from_end_to_start: true,
33        }
34    }
35}
36
37/// Direction for the dismiss action.
38#[derive(Clone, Copy, Debug, PartialEq)]
39pub enum DismissDirection {
40    StartToEnd,
41    EndToStart,
42    Both,
43}
44
45/// Resolved state for swipe-to-dismiss.
46#[derive(Clone, Copy, Debug, PartialEq)]
47pub enum DismissValue {
48    Default,
49    DismissedToStart,
50    DismissedToEnd,
51}
52
53/// State for `SwipeToDismiss` - backed by a generic `SwipeableState<DismissValue>`.
54pub struct SwipeToDismissState {
55    swipeable: repose_core::SwipeableState<DismissValue>,
56}
57
58impl Default for SwipeToDismissState {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64impl SwipeToDismissState {
65    pub fn new() -> Self {
66        Self::with_config(SwipeToDismissConfig::default())
67    }
68
69    pub fn with_config(config: SwipeToDismissConfig) -> Self {
70        let one_third = 1.0 / 3.0;
71        let positional_threshold = (config.dismiss_threshold * one_third) / config.dismissed_offset;
72        let mut anchors = vec![(0.0, DismissValue::Default)];
73        if config.enable_dismiss_from_end_to_start {
74            anchors.push((-config.dismissed_offset, DismissValue::DismissedToStart));
75        }
76        if config.enable_dismiss_from_start_to_end {
77            anchors.push((config.dismissed_offset, DismissValue::DismissedToEnd));
78        }
79        // Sort by offset for correct clamp/nearest/next-anchor logic.
80        anchors.sort_by(|(a, _), (b, _)| a.partial_cmp(b).unwrap());
81        let swipeable = repose_core::SwipeableState::new(
82            anchors,
83            repose_core::SwipeableConfig {
84                animation_spec: config.animation_spec,
85                positional_threshold,
86                ..Default::default()
87            },
88        );
89        // Start at the default position (not anchors[0], which may be negative).
90        swipeable.snap_to(0.0);
91        Self { swipeable }
92    }
93
94    /// Current animated offset in pixels.
95    pub fn offset(&self) -> f32 {
96        self.swipeable.offset()
97    }
98
99    /// Snap instantly to an offset (used during active drag).
100    pub fn set_offset_instant(&self, off: f32) {
101        self.swipeable.snap_to(off);
102    }
103
104    /// Whether the current position is past the dismiss threshold.
105    pub fn is_dismissed(&self) -> bool {
106        self.swipeable.current_value() != DismissValue::Default
107    }
108
109    /// Animate to the dismissed position.
110    pub fn dismiss(&self) {
111        self.swipeable.animate_to(&DismissValue::DismissedToStart);
112    }
113
114    /// Animate to the dismissed position with custom offset.
115    pub fn dismiss_to(&self, offset: f32) {
116        let value = if offset < 0.0 {
117            DismissValue::DismissedToStart
118        } else {
119            DismissValue::DismissedToEnd
120        };
121        self.swipeable.animate_to(&value);
122    }
123
124    /// Animate back to origin.
125    pub fn reset(&self) {
126        self.swipeable.animate_to(&DismissValue::Default);
127    }
128
129    /// Fire the dismiss callback once when the spring settles past a given threshold.
130    fn try_handle_dismiss_with_threshold(
131        &self,
132        on_dismiss: &Option<Rc<dyn Fn()>>,
133        _threshold: f32,
134    ) {
135        if !self.swipeable.is_animating() {
136            let val = self.swipeable.current_value();
137            if val != DismissValue::Default
138                && let Some(cb) = on_dismiss
139            {
140                cb();
141            }
142        }
143    }
144}
145
146/// M3 SwipeToDismiss - wraps content that can be swiped to reveal
147/// a `background` action view. On release past the threshold the content
148/// springs to the dismissed position and `on_dismiss` fires **once**.
149///
150/// The gesture logic uses `SwipeableState<DismissValue>` internally, so it
151/// supports both left and right dismiss directions based on the config.
152pub fn SwipeToDismiss(
153    state: Rc<SwipeToDismissState>,
154    on_dismiss: Option<Rc<dyn Fn()>>,
155    background: View,
156    content: View,
157    modifier: Modifier,
158    config: SwipeToDismissConfig,
159) -> View {
160    let offset = state.offset();
161    state.try_handle_dismiss_with_threshold(&on_dismiss, config.dismiss_threshold);
162
163    let s1 = state.swipeable.clone();
164    let s2 = state.swipeable.clone();
165    let s3 = state.swipeable.clone();
166    let on_down = { move |e: PointerEvent| s1.on_pointer_down(e.position.x) };
167    let on_move = { move |e: PointerEvent| s2.on_pointer_move(e.position.x) };
168    let on_up = { move |_e: PointerEvent| s3.on_pointer_up() };
169
170    let display_offset = offset
171        .max(-config.dismissed_offset)
172        .min(config.dismissed_offset);
173
174    let content_modifier = {
175        let mut m = Modifier::new()
176            .fill_max_width()
177            .translate(display_offset, 0.0);
178        if config.gestures_enabled {
179            m = m
180                .on_pointer_down(on_down)
181                .on_pointer_move(on_move)
182                .on_pointer_up(on_up);
183        }
184        m
185    };
186
187    Column(modifier.fill_max_width()).child((
188        Box(Modifier::new().fill_max_size().absolute()).child(background),
189        Box(content_modifier).child(content),
190    ))
191}