Skip to main content

fret_ui_kit/declarative/
touch_pan_scroll.rs

1use std::sync::Arc;
2
3use fret_core::{Point, Px};
4use fret_runtime::Model;
5use fret_ui::action::{OnPointerDown, OnPointerMove, OnPointerUp};
6use fret_ui::scroll::ScrollHandle;
7use fret_ui::{ElementContext, Invalidation, UiHost};
8use fret_ui::{action::OnPointerCancel, element::ScrollAxis};
9
10use super::controllable_state::use_controllable_model;
11
12#[derive(Debug, Clone, Copy)]
13pub struct TouchPanToScrollOptions {
14    pub drag_threshold: Px,
15}
16
17impl Default for TouchPanToScrollOptions {
18    fn default() -> Self {
19        Self {
20            drag_threshold: Px(6.0),
21        }
22    }
23}
24
25#[derive(Debug, Default, Clone, Copy)]
26struct TouchPanToScrollState {
27    pointer_id: Option<fret_core::PointerId>,
28    start: Option<Point>,
29    last: Option<Point>,
30    panning: bool,
31}
32
33fn axis_scrollable(handle: &ScrollHandle, axis: ScrollAxis) -> bool {
34    let max = handle.max_offset();
35    match axis {
36        ScrollAxis::X => max.x.0 > 0.01,
37        ScrollAxis::Y => max.y.0 > 0.01,
38        ScrollAxis::Both => max.x.0 > 0.01 || max.y.0 > 0.01,
39    }
40}
41
42fn apply_pan_delta(handle: &ScrollHandle, axis: ScrollAxis, delta: Point) {
43    let prev = handle.offset();
44    let next = match axis {
45        ScrollAxis::X => Point::new(Px(prev.x.0 - delta.x.0), prev.y),
46        ScrollAxis::Y => Point::new(prev.x, Px(prev.y.0 - delta.y.0)),
47        ScrollAxis::Both => Point::new(Px(prev.x.0 - delta.x.0), Px(prev.y.0 - delta.y.0)),
48    };
49    handle.set_offset(next);
50}
51
52/// Installs a minimal touch "pan to scroll" policy onto the current `PointerRegion` element.
53///
54/// Notes:
55/// - This is intentionally *touch-only* and does not attempt to implement a full gesture arena.
56/// - Press/tap activation should already be gated by `PointerEvent::Up.is_click` for touch
57///   pressables, so a pan that exceeds click slop will not activate descendants.
58#[track_caller]
59pub fn install_touch_pan_to_scroll<H: UiHost>(
60    cx: &mut ElementContext<'_, H>,
61    axis: ScrollAxis,
62    scroll_handle: ScrollHandle,
63    options: TouchPanToScrollOptions,
64) {
65    let state: Model<TouchPanToScrollState> = use_controllable_model(
66        cx,
67        None::<Model<TouchPanToScrollState>>,
68        TouchPanToScrollState::default,
69    )
70    .model();
71
72    let state_c = state.clone();
73    let handle_c = scroll_handle.clone();
74    let on_down: OnPointerDown = Arc::new(move |host, _action_cx, down| {
75        if down.pointer_type != fret_core::PointerType::Touch {
76            return false;
77        }
78        if !axis_scrollable(&handle_c, axis) {
79            return false;
80        }
81
82        let _ = host.models_mut().update(&state_c, |st| {
83            st.pointer_id = Some(down.pointer_id);
84            st.start = Some(down.position);
85            st.last = Some(down.position);
86            st.panning = false;
87        });
88        false
89    });
90
91    let state_c = state.clone();
92    let handle_c = scroll_handle.clone();
93    let on_move: OnPointerMove = Arc::new(move |host, action_cx, mv| {
94        if mv.pointer_type != fret_core::PointerType::Touch {
95            return false;
96        }
97        if !axis_scrollable(&handle_c, axis) {
98            return false;
99        }
100
101        let mut out = false;
102        let _ = host.models_mut().update(&state_c, |st| {
103            if st.pointer_id != Some(mv.pointer_id) {
104                return;
105            }
106            let Some(prev) = st.last else {
107                st.last = Some(mv.position);
108                return;
109            };
110            let Some(start) = st.start else {
111                st.start = Some(mv.position);
112                st.last = Some(mv.position);
113                return;
114            };
115
116            let total_dx = mv.position.x.0 - start.x.0;
117            let total_dy = mv.position.y.0 - start.y.0;
118            if !st.panning {
119                let dist = (total_dx * total_dx + total_dy * total_dy).sqrt();
120                if dist > options.drag_threshold.0.max(0.0) {
121                    st.panning = true;
122                }
123            }
124
125            let dx = mv.position.x.0 - prev.x.0;
126            let dy = mv.position.y.0 - prev.y.0;
127            st.last = Some(mv.position);
128
129            if st.panning {
130                apply_pan_delta(&handle_c, axis, Point::new(Px(dx), Px(dy)));
131                out = true;
132            }
133        });
134
135        if out {
136            host.invalidate(Invalidation::HitTestOnly);
137            host.request_redraw(action_cx.window);
138        }
139        out
140    });
141
142    let state_c = state.clone();
143    let on_up: OnPointerUp = Arc::new(move |host, _action_cx, up| {
144        if up.pointer_type != fret_core::PointerType::Touch {
145            return false;
146        }
147        let _ = host.models_mut().update(&state_c, |st| {
148            if st.pointer_id == Some(up.pointer_id) {
149                *st = TouchPanToScrollState::default();
150            }
151        });
152        false
153    });
154
155    let state_c = state.clone();
156    let on_cancel: OnPointerCancel = Arc::new(move |host, _action_cx, cancel| {
157        if cancel.pointer_type != fret_core::PointerType::Touch {
158            return false;
159        }
160        let _ = host.models_mut().update(&state_c, |st| {
161            if st.pointer_id == Some(cancel.pointer_id) {
162                *st = TouchPanToScrollState::default();
163            }
164        });
165        false
166    });
167
168    cx.pointer_region_add_on_pointer_down(on_down);
169    cx.pointer_region_add_on_pointer_move(on_move);
170    cx.pointer_region_add_on_pointer_up(on_up);
171    cx.pointer_region_on_pointer_cancel(on_cancel);
172}