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