Skip to main content

iced_cube/primitives/
tappable.rs

1//! Makes any content respond to taps and long presses, by finger or mouse,
2//! without stopping a surrounding scrollable from scrolling.
3//!
4//! A button captures the finger that presses it, so a list made of buttons
5//! can only be dragged from outside its rows. A tappable never captures a
6//! finger: it watches the press, and a finger that moves further than the
7//! slop is a drag, which cancels the tap and leaves the scrolling to the
8//! scrollable around it.
9//!
10//! | Input | Result |
11//! | --- | --- |
12//! | Finger or left button pressed and lifted within the slop | `on_tap` |
13//! | Finger or left button held for the hold time | `on_long_press`, and the lift is ignored |
14//! | Right click | `on_long_press` |
15//! | Finger moved beyond the slop, a second finger, or a lost finger | Cancelled |
16//!
17//! Content gets every event first. A press on content that is interactive
18//! under the pointer, such as a button or a tappable nested in the row,
19//! belongs to the content: the row is not tapped as well. A nested button
20//! also captures the finger, so a drag that starts on it does not scroll;
21//! keep nested controls small, or make them tappables too. Links in rich
22//! text count as interactive, so wrap such text in `touch_clicks` for a tap
23//! to open them.
24//!
25//! ```no_run
26//! use iced::widget::text;
27//! use iced_cube::tappable;
28//!
29//! #[derive(Debug, Clone)]
30//! enum Message { Open(u32), Menu(u32) }
31//!
32//! let row: iced::Element<'_, Message> = tappable(text("Shopping list"))
33//!     .on_tap(Message::Open(7))
34//!     .on_long_press(Message::Menu(7))
35//!     .width(iced::Length::Fill)
36//!     .padding(12)
37//!     .into();
38//! ```
39
40use std::fmt;
41
42use iced::advanced::layout::{self, Layout};
43use iced::advanced::widget::{Operation, Tree, Widget, tree};
44use iced::advanced::{Clipboard, Shell, overlay, renderer};
45use iced::time::{Duration, Instant};
46use iced::touch::{self, Finger};
47use iced::widget::container;
48use iced::{
49    Background, Border, Color, Element, Event, Length, Padding, Point, Rectangle, Renderer, Size,
50    Theme, Vector, mouse, window,
51};
52
53use crate::theme::{Tokens, mix, radius};
54
55/// How long a press must be held to count as a long press.
56pub const DEFAULT_HOLD: Duration = Duration::from_millis(450);
57/// How far, in logical pixels, a press may move and still be a tap.
58pub const DEFAULT_SLOP: f32 = 8.0;
59
60/// The interaction state a tappable is drawn in.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
62pub enum Status {
63    /// At rest.
64    #[default]
65    Active,
66    /// A mouse is over it. Never shown after a touch until the mouse moves.
67    Hovered,
68    /// A finger or button is down on it.
69    Pressed,
70}
71
72impl Status {
73    pub const ALL: [Status; 3] = [Status::Active, Status::Hovered, Status::Pressed];
74}
75
76/// What a finished interaction means.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum Gesture {
79    Tap,
80    LongPress,
81}
82
83/// The finger or mouse button that started a press.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum Pointer {
86    Finger(Finger),
87    Mouse,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq)]
91struct Press {
92    pointer: Pointer,
93    origin: Point,
94    at: Instant,
95    held: bool,
96}
97
98/// The gesture logic of a tappable, free of any widget, so it can be
99/// driven and tested directly.
100#[derive(Debug, Clone, Copy, PartialEq, Default)]
101pub struct Tracker {
102    press: Option<Press>,
103    touched: bool,
104}
105
106impl Tracker {
107    /// A press started at `position`. A second finger while one is down
108    /// cancels instead, since two fingers are a pinch rather than a tap.
109    pub fn press(&mut self, pointer: Pointer, position: Point, now: Instant) {
110        if let Pointer::Finger(_) = pointer {
111            self.touched = true;
112        }
113        if self.press.is_some() {
114            self.press = None;
115            return;
116        }
117        self.press = Some(Press {
118            pointer,
119            origin: position,
120            at: now,
121            held: false,
122        });
123    }
124
125    /// The pointer moved. Beyond `slop` from where it started, the press is
126    /// cancelled.
127    pub fn moved(&mut self, pointer: Pointer, position: Point, slop: f32) {
128        let Some(press) = self.press else {
129            return;
130        };
131        if press.pointer == pointer && press.origin.distance(position) > slop {
132            self.press = None;
133        }
134    }
135
136    /// The pointer was lifted. A press that was not held and ends `inside`
137    /// is a tap.
138    pub fn release(&mut self, pointer: Pointer, inside: bool) -> Option<Gesture> {
139        let press = self.press.filter(|press| press.pointer == pointer)?;
140        self.press = None;
141        (!press.held && inside).then_some(Gesture::Tap)
142    }
143
144    /// The pointer went away without a release.
145    pub fn lost(&mut self, pointer: Pointer) {
146        if self.press.is_some_and(|press| press.pointer == pointer) {
147            self.press = None;
148        }
149    }
150
151    /// Checks the clock. Once a press has been held for `hold`, it becomes
152    /// a long press, reported once, and its release is then ignored.
153    pub fn tick(&mut self, now: Instant, hold: Duration) -> Option<Gesture> {
154        let press = self.press.as_mut()?;
155        if press.held || now.saturating_duration_since(press.at) < hold {
156            return None;
157        }
158        press.held = true;
159        Some(Gesture::LongPress)
160    }
161
162    /// When the current press becomes a long press, if one is pending.
163    pub fn deadline(&self, hold: Duration) -> Option<Instant> {
164        let press = self.press.filter(|press| !press.held)?;
165        Some(press.at + hold)
166    }
167
168    /// A real mouse moved, so hover can be shown again.
169    pub fn mouse_moved(&mut self) {
170        self.touched = false;
171    }
172
173    pub fn is_pressed(&self) -> bool {
174        self.press.is_some()
175    }
176
177    /// The status to draw, given whether the cursor is over the bounds. A
178    /// touch leaves the cursor where the finger was, so hover is ignored
179    /// after a touch until the mouse moves.
180    pub fn status(&self, cursor_over: bool) -> Status {
181        if self.press.is_some() {
182            return Status::Pressed;
183        }
184        if cursor_over && !self.touched {
185            return Status::Hovered;
186        }
187        Status::Active
188    }
189}
190
191/// The default look: transparent at rest, a light tint on hover and a
192/// stronger one while pressed.
193pub fn style(tokens: &Tokens, status: Status) -> container::Style {
194    let background = match status {
195        Status::Active => None,
196        Status::Hovered => Some(mix(tokens.background, tokens.accent, 0.5)),
197        Status::Pressed => Some(tokens.accent),
198    };
199    container::Style {
200        background: background.map(Background::Color),
201        border: Border {
202            color: Color::TRANSPARENT,
203            width: 0.0,
204            radius: radius::MD.into(),
205        },
206        ..container::Style::default()
207    }
208}
209
210type StyleFn<'a> = Box<dyn Fn(&Theme, Status) -> container::Style + 'a>;
211
212/// A tappable builder. Convert it into an [`Element`] to render.
213///
214/// Without [`on_tap`](Tappable::on_tap) or
215/// [`on_long_press`](Tappable::on_long_press) it draws its content and
216/// nothing else.
217pub struct Tappable<'a, Message> {
218    content: Element<'a, Message>,
219    on_tap: Option<Message>,
220    on_long_press: Option<Message>,
221    hold: Duration,
222    slop: f32,
223    style: StyleFn<'a>,
224    width: Length,
225    height: Length,
226    padding: Padding,
227}
228
229/// Wraps `content` so it reports taps and long presses.
230pub fn tappable<'a, Message>(content: impl Into<Element<'a, Message>>) -> Tappable<'a, Message> {
231    Tappable {
232        content: content.into(),
233        on_tap: None,
234        on_long_press: None,
235        hold: DEFAULT_HOLD,
236        slop: DEFAULT_SLOP,
237        style: Box::new(|theme, status| style(&Tokens::of(theme), status)),
238        width: Length::Shrink,
239        height: Length::Shrink,
240        padding: Padding::ZERO,
241    }
242}
243
244impl<Message> fmt::Debug for Tappable<'_, Message> {
245    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246        f.debug_struct("Tappable")
247            .field("on_tap", &self.on_tap.is_some())
248            .field("on_long_press", &self.on_long_press.is_some())
249            .field("hold", &self.hold)
250            .field("slop", &self.slop)
251            .field("width", &self.width)
252            .field("height", &self.height)
253            .field("padding", &self.padding)
254            .finish_non_exhaustive()
255    }
256}
257
258impl<'a, Message> Tappable<'a, Message> {
259    /// The message for a tap or a left click.
260    pub fn on_tap(mut self, message: Message) -> Self {
261        self.on_tap = Some(message);
262        self
263    }
264
265    /// The message for a press held for [`hold`](Self::hold), or a right
266    /// click.
267    pub fn on_long_press(mut self, message: Message) -> Self {
268        self.on_long_press = Some(message);
269        self
270    }
271
272    /// How long a press is held to count as a long press. Defaults to
273    /// [`DEFAULT_HOLD`].
274    pub fn hold(mut self, hold: Duration) -> Self {
275        self.hold = hold;
276        self
277    }
278
279    /// How far a press may move and still be a tap. Defaults to
280    /// [`DEFAULT_SLOP`].
281    pub fn slop(mut self, slop: f32) -> Self {
282        self.slop = slop.max(0.0);
283        self
284    }
285
286    /// Replaces the default [`style`].
287    pub fn style(mut self, style: impl Fn(&Theme, Status) -> container::Style + 'a) -> Self {
288        self.style = Box::new(style);
289        self
290    }
291
292    pub fn width(mut self, width: impl Into<Length>) -> Self {
293        self.width = width.into();
294        self
295    }
296
297    pub fn height(mut self, height: impl Into<Length>) -> Self {
298        self.height = height.into();
299        self
300    }
301
302    pub fn padding(mut self, padding: impl Into<Padding>) -> Self {
303        self.padding = padding.into();
304        self
305    }
306
307    pub fn is_enabled(&self) -> bool {
308        self.on_tap.is_some() || self.on_long_press.is_some()
309    }
310}
311
312impl<'a, Message: Clone + 'a> From<Tappable<'a, Message>> for Element<'a, Message> {
313    fn from(tappable: Tappable<'a, Message>) -> Self {
314        let Tappable {
315            content,
316            on_tap,
317            on_long_press,
318            hold,
319            slop,
320            style,
321            width,
322            height,
323            padding,
324        } = tappable;
325        Element::new(Surface {
326            content: container(content)
327                .width(width)
328                .height(height)
329                .padding(padding)
330                .into(),
331            on_tap,
332            on_long_press,
333            hold,
334            slop,
335            style,
336        })
337    }
338}
339
340struct Surface<'a, Message> {
341    content: Element<'a, Message>,
342    on_tap: Option<Message>,
343    on_long_press: Option<Message>,
344    hold: Duration,
345    slop: f32,
346    style: StyleFn<'a>,
347}
348
349#[derive(Debug, Default)]
350struct State {
351    tracker: Tracker,
352    drawn: Status,
353}
354
355impl<Message> Surface<'_, Message> {
356    fn is_enabled(&self) -> bool {
357        self.on_tap.is_some() || self.on_long_press.is_some()
358    }
359}
360
361/// Whether the cursor is over the part of `bounds` that is on screen.
362fn over(cursor: mouse::Cursor, bounds: Rectangle, viewport: &Rectangle) -> bool {
363    cursor.is_over(bounds) && cursor.is_over(*viewport)
364}
365
366impl<Message: Clone> Widget<Message, Theme, Renderer> for Surface<'_, Message> {
367    fn tag(&self) -> tree::Tag {
368        tree::Tag::of::<State>()
369    }
370
371    fn state(&self) -> tree::State {
372        tree::State::new(State::default())
373    }
374
375    fn children(&self) -> Vec<Tree> {
376        vec![Tree::new(&self.content)]
377    }
378
379    fn diff(&self, tree: &mut Tree) {
380        tree.diff_children(std::slice::from_ref(&self.content));
381    }
382
383    fn size(&self) -> Size<Length> {
384        self.content.as_widget().size()
385    }
386
387    fn size_hint(&self) -> Size<Length> {
388        self.content.as_widget().size_hint()
389    }
390
391    fn layout(
392        &mut self,
393        tree: &mut Tree,
394        renderer: &Renderer,
395        limits: &layout::Limits,
396    ) -> layout::Node {
397        self.content
398            .as_widget_mut()
399            .layout(&mut tree.children[0], renderer, limits)
400    }
401
402    fn draw(
403        &self,
404        tree: &Tree,
405        renderer: &mut Renderer,
406        theme: &Theme,
407        style: &renderer::Style,
408        layout: Layout<'_>,
409        cursor: mouse::Cursor,
410        viewport: &Rectangle,
411    ) {
412        let state = tree.state.downcast_ref::<State>();
413        let bounds = layout.bounds();
414        let status = if self.is_enabled() {
415            state.tracker.status(over(cursor, bounds, viewport))
416        } else {
417            Status::Active
418        };
419        let appearance = (self.style)(theme, status);
420        container::draw_background(renderer, &appearance, bounds);
421        let style = renderer::Style {
422            text_color: appearance.text_color.unwrap_or(style.text_color),
423        };
424        self.content.as_widget().draw(
425            &tree.children[0],
426            renderer,
427            theme,
428            &style,
429            layout,
430            cursor,
431            viewport,
432        );
433    }
434
435    fn operate(
436        &mut self,
437        tree: &mut Tree,
438        layout: Layout<'_>,
439        renderer: &Renderer,
440        operation: &mut dyn Operation,
441    ) {
442        self.content
443            .as_widget_mut()
444            .operate(&mut tree.children[0], layout, renderer, operation);
445    }
446
447    fn update(
448        &mut self,
449        tree: &mut Tree,
450        event: &Event,
451        layout: Layout<'_>,
452        cursor: mouse::Cursor,
453        renderer: &Renderer,
454        clipboard: &mut dyn Clipboard,
455        shell: &mut Shell<'_, Message>,
456        viewport: &Rectangle,
457    ) {
458        self.content.as_widget_mut().update(
459            &mut tree.children[0],
460            event,
461            layout,
462            cursor,
463            renderer,
464            clipboard,
465            shell,
466            viewport,
467        );
468        if !self.is_enabled() {
469            return;
470        }
471
472        // Content that is interactive under the cursor, such as a nested
473        // tappable, owns a press there even when it does not capture it.
474        let claimed = self.content.as_widget().mouse_interaction(
475            &tree.children[0],
476            layout,
477            cursor,
478            viewport,
479            renderer,
480        ) != mouse::Interaction::None;
481        let state = tree.state.downcast_mut::<State>();
482        let bounds = layout.bounds();
483        let inside = over(cursor, bounds, viewport);
484        let free = !shell.is_event_captured() && !claimed;
485        let tracker = &mut state.tracker;
486        let mut gesture = None;
487        let mut now = Instant::now();
488
489        match event {
490            Event::Touch(touch::Event::FingerPressed { id, position }) => {
491                if tracker.is_pressed() || (inside && free) {
492                    tracker.press(Pointer::Finger(*id), *position, now);
493                }
494            }
495            Event::Touch(touch::Event::FingerMoved { id, position }) => {
496                tracker.moved(Pointer::Finger(*id), *position, self.slop);
497            }
498            Event::Touch(touch::Event::FingerLifted { id, .. }) => {
499                gesture = tracker.release(Pointer::Finger(*id), true);
500            }
501            Event::Touch(touch::Event::FingerLost { id, .. }) => {
502                tracker.lost(Pointer::Finger(*id));
503            }
504            Event::Mouse(mouse::Event::CursorMoved { .. }) => {
505                tracker.mouse_moved();
506                if let Some(position) = cursor.position() {
507                    tracker.moved(Pointer::Mouse, position, self.slop);
508                }
509            }
510            Event::Mouse(mouse::Event::CursorLeft) => tracker.lost(Pointer::Mouse),
511            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if inside && free => {
512                if let Some(position) = cursor.position() {
513                    tracker.press(Pointer::Mouse, position, now);
514                }
515            }
516            Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
517                gesture = tracker.release(Pointer::Mouse, inside);
518            }
519            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
520                if inside && free && self.on_long_press.is_some() =>
521            {
522                gesture = Some(Gesture::LongPress);
523                shell.capture_event();
524            }
525            Event::Window(window::Event::RedrawRequested(at)) => now = *at,
526            _ => {}
527        }
528
529        if gesture.is_none() && self.on_long_press.is_some() {
530            gesture = tracker.tick(now, self.hold);
531        }
532        if self.on_long_press.is_some()
533            && let Some(deadline) = tracker.deadline(self.hold)
534        {
535            shell.request_redraw_at(deadline);
536        }
537
538        let message = match gesture {
539            Some(Gesture::Tap) => self.on_tap.clone(),
540            Some(Gesture::LongPress) => self.on_long_press.clone(),
541            None => None,
542        };
543        if let Some(message) = message {
544            shell.publish(message);
545        }
546
547        let status = tracker.status(inside);
548        if let Event::Window(window::Event::RedrawRequested(_)) = event {
549            state.drawn = status;
550        } else if status != state.drawn {
551            shell.request_redraw();
552        }
553    }
554
555    fn mouse_interaction(
556        &self,
557        tree: &Tree,
558        layout: Layout<'_>,
559        cursor: mouse::Cursor,
560        viewport: &Rectangle,
561        renderer: &Renderer,
562    ) -> mouse::Interaction {
563        let content = self.content.as_widget().mouse_interaction(
564            &tree.children[0],
565            layout,
566            cursor,
567            viewport,
568            renderer,
569        );
570        if content != mouse::Interaction::None {
571            return content;
572        }
573        if self.is_enabled() && over(cursor, layout.bounds(), viewport) {
574            return mouse::Interaction::Pointer;
575        }
576        content
577    }
578
579    fn overlay<'b>(
580        &'b mut self,
581        tree: &'b mut Tree,
582        layout: Layout<'b>,
583        renderer: &Renderer,
584        viewport: &Rectangle,
585        translation: Vector,
586    ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
587        self.content.as_widget_mut().overlay(
588            &mut tree.children[0],
589            layout,
590            renderer,
591            viewport,
592            translation,
593        )
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600    use crate::theme::{dark, light};
601
602    const FINGER: Pointer = Pointer::Finger(Finger(1));
603
604    fn at(x: f32) -> Point {
605        Point::new(x, 10.0)
606    }
607
608    #[test]
609    fn builder_defaults() {
610        let t: Tappable<'_, u8> = tappable("Row");
611        assert_eq!(t.hold, DEFAULT_HOLD);
612        assert_eq!(t.slop, DEFAULT_SLOP);
613        assert_eq!(t.width, Length::Shrink);
614        assert!(!t.is_enabled());
615        assert!(tappable::<u8>("Row").on_tap(1).is_enabled());
616        assert!(tappable::<u8>("Row").on_long_press(1).is_enabled());
617        assert_eq!(tappable::<u8>("Row").slop(-3.0).slop, 0.0);
618    }
619
620    #[test]
621    fn a_press_and_lift_within_the_slop_is_a_tap() {
622        let mut tracker = Tracker::default();
623        let now = Instant::now();
624        tracker.press(FINGER, at(0.0), now);
625        assert_eq!(tracker.status(true), Status::Pressed);
626        tracker.moved(FINGER, at(DEFAULT_SLOP), DEFAULT_SLOP);
627        assert_eq!(tracker.release(FINGER, true), Some(Gesture::Tap));
628        assert_eq!(tracker.status(true), Status::Active);
629    }
630
631    #[test]
632    fn moving_beyond_the_slop_cancels() {
633        let mut tracker = Tracker::default();
634        tracker.press(FINGER, at(0.0), Instant::now());
635        tracker.moved(FINGER, at(DEFAULT_SLOP + 1.0), DEFAULT_SLOP);
636        assert!(!tracker.is_pressed());
637        assert_eq!(tracker.release(FINGER, true), None);
638    }
639
640    #[test]
641    fn other_fingers_are_ignored_and_a_second_press_cancels() {
642        let mut tracker = Tracker::default();
643        tracker.press(FINGER, at(0.0), Instant::now());
644        tracker.moved(Pointer::Finger(Finger(2)), at(100.0), DEFAULT_SLOP);
645        assert_eq!(tracker.release(Pointer::Finger(Finger(2)), true), None);
646        assert!(tracker.is_pressed());
647        tracker.press(Pointer::Finger(Finger(2)), at(50.0), Instant::now());
648        assert!(!tracker.is_pressed());
649    }
650
651    #[test]
652    fn holding_fires_one_long_press_and_swallows_the_lift() {
653        let mut tracker = Tracker::default();
654        let start = Instant::now();
655        tracker.press(FINGER, at(0.0), start);
656        assert_eq!(tracker.deadline(DEFAULT_HOLD), Some(start + DEFAULT_HOLD));
657        assert_eq!(tracker.tick(start, DEFAULT_HOLD), None);
658        let later = start + DEFAULT_HOLD;
659        assert_eq!(tracker.tick(later, DEFAULT_HOLD), Some(Gesture::LongPress));
660        assert_eq!(tracker.tick(later, DEFAULT_HOLD), None);
661        assert_eq!(tracker.deadline(DEFAULT_HOLD), None);
662        assert_eq!(tracker.release(FINGER, true), None);
663    }
664
665    #[test]
666    fn lost_and_released_outside_produce_nothing() {
667        let mut tracker = Tracker::default();
668        tracker.press(FINGER, at(0.0), Instant::now());
669        tracker.lost(FINGER);
670        assert!(!tracker.is_pressed());
671        tracker.press(Pointer::Mouse, at(0.0), Instant::now());
672        assert_eq!(tracker.release(Pointer::Mouse, false), None);
673    }
674
675    #[test]
676    fn hover_is_ignored_after_a_touch_until_the_mouse_moves() {
677        let mut tracker = Tracker::default();
678        assert_eq!(tracker.status(true), Status::Hovered);
679        tracker.press(FINGER, at(0.0), Instant::now());
680        let _ = tracker.release(FINGER, true);
681        assert_eq!(tracker.status(true), Status::Active);
682        tracker.mouse_moved();
683        assert_eq!(tracker.status(true), Status::Hovered);
684        assert_eq!(tracker.status(false), Status::Active);
685    }
686
687    #[test]
688    fn default_style_is_clear_at_rest_and_stronger_when_pressed() {
689        for theme in [light(), dark()] {
690            let tokens = Tokens::of(&theme);
691            assert!(style(&tokens, Status::Active).background.is_none());
692            let hovered = style(&tokens, Status::Hovered).background;
693            let pressed = style(&tokens, Status::Pressed).background;
694            assert!(hovered.is_some() && pressed.is_some());
695            assert_ne!(hovered, pressed);
696            assert_eq!(pressed, Some(Background::Color(tokens.accent)));
697        }
698    }
699}