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