Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Gestures and haptics

In this chapter, you will:

  • Predict which view receives a touch under WaterUI’s hit-testing model
  • Attach tap, long-press, drag, pinch, and rotation gestures to views
  • Inject reactive state and pull it back with the State<T> extractor
  • Compose gestures sequentially, simultaneously, and with priority
  • Add haptic feedback, and know what it costs in dependencies

Gesture descriptors in WaterUI are plain data. You describe what should be recognized; each backend translates that into a platform gesture recognizer. Nothing in the descriptor knows about touch coordinates or timers.

Hit-testing model

WaterUI passes touches through views that have no interaction of their own:

  • Non-interactive views (plain Text, Spacer, layout containers) never intercept a touch. It falls through to whatever is behind them in Z-order.
  • Interactive views (Button, anything carrying a GestureObserver) capture touches inside their bounds.

So in a ZStack or an overlay, the topmost interactive view at the touch point wins — not simply the topmost view.

use waterui::prelude::*;

zstack((
    video_player(url).show_controls(true),
    vstack((
        spacer(),                                 // transparent to touch
        button("Play").action(|| { /* ... */ }),  // captures touch
    )),
))

If a tap is not reaching the view you expect, look for an interactive element in the overlay above it.

Gesture types

The descriptors live in waterui::gesture.

use waterui::gesture::{
    DragGesture, LongPressGesture, MagnificationGesture, RotationGesture, TapGesture,
};

let single = TapGesture::new();                  // one tap
let double = TapGesture::repeat(2);              // two consecutive taps
let press = LongPressGesture::new(500);          // hold for 500 time units
let drag = DragGesture::new(5.0);                // 5pt minimum travel
let pinch = MagnificationGesture::new(1.0);      // initial scale factor
let rotation = RotationGesture::new(0.0);        // initial angle, radians

LongPressGesture’s duration is a raw u32 that each backend interprets in its own time unit — in practice, milliseconds. The .on_long_press_gesture() modifier below documents its argument as milliseconds explicitly.

Every descriptor converts into the Gesture enum, which also holds the composition variants:

use waterui::gesture::{Gesture, TapGesture};

let gesture: Gesture = TapGesture::new().into();

Event payloads

When a backend recognizes a gesture it puts a payload into the environment, which a handler reads with the Use<T> extractor:

Event typeFields
TapEventlocation: GesturePoint, count: u32
LongPressEventlocation: GesturePoint, duration: f32
DragEventphase, location, translation, velocity
MagnificationEventphase, center, scale, velocity

GesturePhase is Started, Updated, Ended, or Cancelled.

RotationEvent mirrors MagnificationEvent for the other two-finger transform gesture, carrying the phase, the pivot point, the current angle in radians, and the angular velocity.

Attaching a gesture

ViewExt::gesture is the general form. Its first argument is anything that implements Into<Gesture>; its second is any Handler<Args, ()> — a bare closure, or one that names extractors like State<T>, as described in Resolvers and hooks.

use waterui::gesture::TapGesture;
use waterui::prelude::*;

text("Tap me").gesture(TapGesture::new(), || tracing::info!("tapped"))

The shorthands cover the common cases:

ModifierEquivalent
.on_tap(action).gesture(TapGesture::new(), action)
.on_tap_gesture(action)alias for .on_tap
.on_tap_gesture_count(n, action).gesture(TapGesture::repeat(n), action)
.on_long_press_gesture(ms, action).gesture(LongPressGesture::new(ms), action)

There is deliberately no .simultaneous_gesture(...) or .high_priority_gesture(...). Both existed as silent aliases for .gesture(...) that changed no recognition precedence, which invited code that looked correct and was not; they were removed rather than shipped as no-ops. Compose the gestures explicitly (below) when precedence matters.

To reuse a descriptor plus its handler, build a GestureObserver and attach it with .gesture_observer(...):

use waterui::gesture::{GestureObserver, TapGesture};
use waterui::prelude::*;

let counter = Binding::i32(0);

text("Count taps")
    .state(&counter)
    .gesture_observer(GestureObserver::new(
        TapGesture::repeat(2),
        |State(counter): State<Binding<i32>>| *counter.get_mut() += 1,
    ))

Getting state into a handler

Handlers are resolved from the environment, not from captured variables. Inject a value with ViewExt::state, then name it in the handler signature through State<T>. Values are keyed by type, so one injection serves every handler in the subtree:

use waterui::gesture::TapGesture;
use waterui::prelude::*;

let count = Binding::i32(0);

text!("Tapped {count} times")
    .padding()
    .background(Color::srgb(200, 220, 255))
    .state(&count)
    .gesture(
        TapGesture::new(),
        |State(count): State<Binding<i32>>| *count.get_mut() += 1,
    )

Stack .state(...) calls to inject several values, and name one extractor per value:

use waterui::prelude::*;

let count = Binding::i32(0);
let status = Binding::container(Str::from("Ready"));

text("Interact")
    .state(&count)
    .state(&status)
    .on_tap(
        |State(count): State<Binding<i32>>, State(status): State<Binding<Str>>| {
            *count.get_mut() += 1;
            status.set(Str::from("Tapped"));
        },
    )

Keep this to three injected values or fewer. Beyond that, bundle the state in one #[derive(Clone)] struct and inject that instead — a handler with four State<Binding<T>> parameters is a sign the state belongs together.

Combining gestures

Every descriptor carries the three composition methods, and each produces a Gesture::Then, Gesture::Simultaneous, or Gesture::Exclusive.

use waterui::gesture::{DragGesture, LongPressGesture, TapGesture};

// Sequential: the long press only starts after the tap completes.
let chained = TapGesture::new().then(LongPressGesture::new(300));

// Simultaneous: both may be recognized at once.
let combined = TapGesture::new().simultaneously_with(DragGesture::new(8.0));

// Exclusive: the tap has priority, the long press is the fallback.
let exclusive = TapGesture::new().exclusively_before(LongPressGesture::new(500));

.sequenced_before() is an alias for .then(). Compositions nest, so a.then(b).simultaneously_with(c) is valid.

Haptic feedback

Haptics are behind the non-default std feature, which pulls in the waterkit-haptic crate:

waterui = { version = "0.2", features = ["std"] }

With that enabled, .on_tap_haptic_default(action) fires a medium impact before running the action:

use waterui::prelude::*;

text("Save").on_tap_haptic_default(|| tracing::info!("saved"))

.on_long_press_haptic_default(ms, action) is the long-press equivalent. The explicit-intensity forms, .on_tap_haptic(intensity, action) and .on_long_press_haptic(ms, intensity, action), take a waterkit_haptic::Intensity (LOW, MEDIUM, HIGH, MAX, or Intensity::new(value)). That type is not re-exported through waterui, so using them means adding waterkit-haptic to your own Cargo.toml.

Haptics are implemented for iOS, macOS, Android, Windows, and Linux. Where the platform cannot deliver one, the failure is logged at debug level and the action still runs — a gesture never silently stops working because a device has no haptic engine.

Putting it together

use waterui::gesture::{DragGesture, LongPressGesture, TapGesture};
use waterui::prelude::*;

fn gesture_demo() -> impl View {
    let taps = Binding::i32(0);
    let presses = Binding::i32(0);
    let drags = Binding::i32(0);
    let chained = Binding::container(Str::from("Waiting"));

    scroll(vstack((
        text("Gesture demo").title(),

        text!("Taps: {taps}")
            .padding()
            .background(Color::srgb(33, 150, 243).with_opacity(0.3))
            .state(&taps)
            .gesture(
                TapGesture::new(),
                |State(c): State<Binding<i32>>| *c.get_mut() += 1,
            ),

        text!("Long presses: {presses}")
            .padding()
            .background(Color::srgb(255, 152, 0).with_opacity(0.3))
            .state(&presses)
            .gesture(
                LongPressGesture::new(500),
                |State(c): State<Binding<i32>>| *c.get_mut() += 1,
            ),

        text!("Drags: {drags}")
            .padding()
            .size(200.0, 100.0)
            .background(Color::srgb(156, 39, 176).with_opacity(0.3))
            .state(&drags)
            .gesture(
                DragGesture::new(5.0),
                |State(c): State<Binding<i32>>| *c.get_mut() += 1,
            ),

        text!("{chained}")
            .padding()
            .background(Color::srgb(244, 67, 54).with_opacity(0.3))
            .state(&chained)
            .gesture(
                TapGesture::new().then(LongPressGesture::new(300)),
                |State(s): State<Binding<Str>>| s.set(Str::from("Chain complete")),
            ),
    )))
}

Every counter is displayed with text!, so the label updates from the binding without rebuilding the view that owns the gesture.

Try it yourself: give the first box a double tap as well, using TapGesture::repeat(2).exclusively_before(TapGesture::new()), and watch how the single-tap fallback waits for the double-tap window to expire.

What’s next

A gesture that starts a network request needs somewhere to show progress. In the next chapter you will handle async work with Suspense and render a loading state while the data is in flight.