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, hover, and haptics

In this chapter, you will:

  • Predict which view receives a touch under WaterUI’s pass-through hit-testing model
  • Attach tap, long-press, drag, pinch, and rotation gestures, and read their event payloads
  • Compose gestures sequentially, simultaneously, and exclusively
  • React to pointer hover and set the pointer cursor
  • Drive pressed and hover visuals from the interaction state, and add haptic feedback

A gesture in WaterUI is a plain descriptor. You say what should be recognized and what to run when it is; each backend feeds the same recognizers the same input, so the thresholds, timing, and the way competing or composed gestures resolve are identical on every backend. A backend whose native recognizer differs is non-conforming and is fixed in the backend, not worked around in your view code.

The gesture types live in waterui::gesture, the cursor types in waterui::cursor, and the interaction-state types in waterui::interaction. The prelude brings GestureObserver and the gesture and cursor modules into scope; the examples below import the other types explicitly.

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 gesture handler) capture touches inside their bounds.

In a ZStack or an overlay, the topmost interactive view at the touch point wins, not simply the topmost view. If a tap is not reaching the view you expect, look for an interactive element in the overlay above it.

.hittable(enabled) overrides this per view. It takes impl IntoComputed<bool>, so a plain bool or a signal both work. With false, touches pass through the view and everything inside it to whatever is behind; its appearance does not change. true is the default: it does not make a non-interactive view capture touches.

#![allow(unused)]
fn main() {
use waterui::prelude::*;

fn dismissible_banner() -> impl View {
    let visible: Binding<bool> = binding(true);

    zstack((
        button("Underneath").action(|| waterui::log::info!("reached")),
        // Once dismissed, the banner fades out and stops catching taps,
        // so they reach the button underneath.
        button("Dismiss banner")
            .action(|State(visible): State<Binding<bool>>| visible.set(false))
            .state(&visible)
            .opacity(visible.select(1.0, 0.0))
            .hittable(visible),
    ))
}
}

.disabled(true) is the heavier tool: it also disables every control in the subtree and stops hit-testing there. See Controls.

Tap, long press, and drag

ViewExt::gesture is the general form. Its first argument is anything that implements Into<Gesture>; its second is a handler, written the way Button::action is (see Controls). A bare closure is a handler.

#![allow(unused)]
fn main() {
use waterui::gesture::TapGesture;
use waterui::prelude::*;

fn tappable() -> impl View {
    text("Tap me").gesture(TapGesture::new(), || waterui::log::info!("tapped"))
}
}

The descriptors and their constructors:

#![allow(unused)]
fn main() {
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 ms
let drag = DragGesture::new(5.0);             // 5 pixels of travel before it starts
let pinch = MagnificationGesture::new(1.0);   // initial scale factor
let rotation = RotationGesture::new(0.0);     // initial angle, radians
}

LongPressGesture::new takes a u32, not a Duration. The type leaves the unit to the backend; the Apple and Hydrolysis backends both read it as milliseconds, which is what .on_long_press_gesture below documents. DragGesture::new takes the f32 distance, in logical pixels, the pointer must travel before the drag begins. A drag recognizer only detects the motion; it moves no data. Moving data between views is drag and drop.

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) (a count of 0 becomes 1)
.on_long_press_gesture(ms, action).gesture(LongPressGesture::new(ms), action)

Use .on_tap for tappable content. Use button(..) when the thing is semantically a button: the button brings platform chrome and the button accessibility role.

Pointer buttons

Tap, long-press, and drag respond to the primary button by default (a touch or pen contact counts as primary). .buttons(..) takes a PointerButtons set (PRIMARY, SECONDARY, MIDDLE, BACK, FORWARD). A gesture that accepts SECONDARY competes with a .context_menu at the same point, and the context menu wins where one is declared.

#![allow(unused)]
fn main() {
use waterui::gesture::{PointerButtons, TapGesture};
use waterui::prelude::*;

fn closable_tab() -> impl View {
    text("Tab").gesture(
        TapGesture::new().buttons(PointerButtons::MIDDLE),
        || waterui::log::info!("middle click closes the tab"),
    )
}
}

Getting state into a handler

A handler’s parameters are extracted from the environment. Inject a value with ViewExt::state, then name it in the handler signature through State<T>. Chain .state(..) after the modifier that carries the handler, view.gesture(..).state(&value), or install it on an ancestor. Values are keyed by type, so one injection serves every handler in the subtree.

#![allow(unused)]
fn main() {
use waterui::gesture::TapGesture;
use waterui::prelude::*;

fn counter() -> impl View {
    let count: Binding<i32> = binding(0);

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

Because the label is a text!, it updates from the binding without rebuilding the view that owns the gesture. See Reactive state.

To reuse a descriptor together with its handler, build a GestureObserver and attach it with .gesture_observer(..). GestureObserver::new(gesture, action) is the constructor, and GestureObserver::builder(gesture).action(action) is the builder form.

#![allow(unused)]
fn main() {
use waterui::gesture::{GestureObserver, TapGesture};
use waterui::prelude::*;

fn double_tap_counter() -> impl View {
    let count: Binding<i32> = binding(0);

    text!("Double-tap count: {count}")
        .gesture_observer(GestureObserver::new(
            TapGesture::repeat(2),
            |State(count): State<Binding<i32>>| *count.get_mut() += 1,
        ))
        .state(&count)
}
}

Event payloads

When a backend recognizes a gesture it puts a payload into the environment. A handler reads it with the Use<T> extractor, which is in the prelude.

Event typeAccessors
TapEventlocation(), count(), button()
LongPressEventlocation(), duration(), button()
DragEventphase(), location(), translation(), velocity(), button()
MagnificationEventphase(), center(), scale(), velocity()
RotationEventphase(), center(), angle(), velocity()

Points are GesturePoint { x, y } in logical pixels, in the view’s own coordinate space. LongPressEvent::duration() is the hold the gesture required, in the same unit as LongPressGesture::new. Angles are in radians. GesturePhase is Started, Updated, Ended, or Cancelled: a drag, pinch, or rotation runs its handler at every phase, while a tap or long press runs it once, on recognition.

#![allow(unused)]
fn main() {
use waterui::gesture::{DragEvent, DragGesture, GesturePhase};
use waterui::prelude::*;

fn drag_readout() -> impl View {
    let dx: Binding<f32> = binding(0.0);

    vstack((
        text!("Horizontal travel: {dx}"),
        text("Drag me").padding().gesture(
            DragGesture::new(5.0),
            |Use(event): Use<DragEvent>, State(dx): State<Binding<f32>>| {
                if event.phase() == GesturePhase::Cancelled {
                    dx.set(0.0);
                } else {
                    dx.set(event.translation().x);
                }
            },
        ),
    ))
    .state(&dx)
}
}

Here .state(&dx) wraps the whole stack, an ancestor of the gesture, which is also valid. Two injected values of the same type bind to same-typed parameters in .state() call order; bundling them in a struct of your own is clearer.

Combining gestures

Every descriptor, and Gesture itself, carries four composition methods. Each returns the erased Gesture enum, which is itself Into<Gesture>, so a composition nests and goes straight into .gesture(..):

MethodResult
.then(g) (alias .sequenced_before(g))Gesture::Then: g is armed once the first is recognized, and the handler runs when g is
.simultaneously_with(g)Gesture::Simultaneous: the handler runs whenever either is recognized; neither blocks the other
.exclusively_before(g)Gesture::Exclusive: whichever is recognized first runs the handler, and any recognition in the next 50 ms is ignored

The handler of a composition receives the event of the child that completed it, so a tap-then-long-press reads a LongPressEvent.

#![allow(unused)]
fn main() {
use waterui::gesture::{DragGesture, LongPressGesture, TapGesture};
use waterui::prelude::*;

fn composed() -> impl View {
    let status: Binding<&'static str> = binding("Waiting");

    vstack((
        text!("{status}"),
        text("Tap, then hold").padding().gesture(
            TapGesture::new().then(LongPressGesture::new(300)),
            |State(status): State<Binding<&'static str>>| status.set("Chain complete"),
        ),
        text("Hold or tap").padding().gesture(
            // Whichever is recognized first runs the handler.
            LongPressGesture::new(500).exclusively_before(TapGesture::new()),
            || waterui::log::info!("hold or tap"),
        ),
        text("Tap or drag").padding().gesture(
            TapGesture::new().simultaneously_with(DragGesture::new(8.0)),
            || waterui::log::info!("tap or drag"),
        ),
    ))
    .state(&status)
}
}

An exclusive pair does not make the second gesture wait for the first to fail. TapGesture::repeat(2).exclusively_before(TapGesture::new()) runs on the first tap of a double tap, because the single tap is recognized first.

There is deliberately no .simultaneous_gesture(..) or .high_priority_gesture(..) modifier. Compose the descriptors explicitly when precedence matters.

Hover

.on_hover_enter(handler) and .on_hover_exit(handler) fire as a pointer enters and leaves the view’s bounds. They apply where a pointer exists, such as a mouse or a trackpad; touch input produces no hover. Hover may therefore enhance an interaction but must never be the only way to reach it.

Drive the visual from state rather than rebuilding the view: stack both looks and cross-fade with complementary opacity signals. .select(a, b) on a bool signal picks a when true and b otherwise. .animated() rides the platform’s default animation on a signal, and .with(Animation) rides one you choose. See Animation.

#![allow(unused)]
fn main() {
use waterui::animation::Animation;
use waterui::prelude::*;

fn hover_card() -> impl View {
    let hovered: Binding<bool> = binding(false);
    let scale = hovered.select(1.05, 1.0).with(Animation::spring(400.0, 15.0));

    zstack((
        Color::srgb(33, 150, 243)
            .with_opacity(0.3)
            .opacity(hovered.select(0.0, 1.0).animated()),
        Color::srgb(76, 175, 80)
            .with_opacity(0.5)
            .opacity(hovered.select(1.0, 0.0).animated()),
        text("Hover me").padding(),
    ))
    .width(200.0)
    .height(80.0)
    .on_hover_enter(|State(hovered): State<Binding<bool>>| hovered.set(true))
    .on_hover_exit(|State(hovered): State<Binding<bool>>| hovered.set(false))
    .state(&hovered)
    .scale(scale.clone(), scale)
}
}

Pointer cursor

.cursor(style) takes impl IntoComputed<CursorStyle>: a plain style or a signal of one. The style applies within the view’s bounds and reverts to the parent’s cursor or the system default on exit.

#![allow(unused)]
fn main() {
use waterui::cursor::CursorStyle;
use waterui::gesture::{DragEvent, DragGesture, GesturePhase};
use waterui::prelude::*;

fn cursors() -> impl View {
    let dragging: Binding<bool> = binding(false);

    vstack((
        text("Open the docs").cursor(CursorStyle::PointingHand),
        text("Drag handle")
            .cursor(dragging.select(CursorStyle::ClosedHand, CursorStyle::OpenHand))
            .gesture(
                DragGesture::new(0.0),
                |Use(event): Use<DragEvent>, State(dragging): State<Binding<bool>>| {
                    dragging.set(matches!(
                        event.phase(),
                        GesturePhase::Started | GesturePhase::Updated
                    ));
                },
            )
            .state(&dragging),
    ))
}
}

CursorStyle has Arrow (the default), PointingHand, IBeam, Crosshair, OpenHand, ClosedHand, NotAllowed, ResizeLeft, ResizeRight, ResizeUp, ResizeDown, ResizeLeftRight, ResizeUpDown, Move, Wait, and Copy. The enum is #[non_exhaustive]. Per the Cursor documentation, a style a platform lacks typically falls back to the arrow, and cursors exist only where there is a pointer, so they are another enhancement rather than a requirement.

Reactive pressed and focus visuals

.on_hover_enter reports the pointer of that view. When chrome lives around a control it does not own (an outline, an offset, a shadow), ask the backend for the whole interaction state instead. .interaction_state(&binding) writes the InteractionState of the outermost interactive control at or inside the view it is applied to, every time it changes. The binding stays at InteractionState::empty() while no interactive control is there.

InteractionState is a bitflags set: HOVERED, FOCUSED (focus that should be shown, like :focus-visible: keyboard focus, never a click), PRESSED, DRAGGED, SELECTED, and DISABLED. States combine, so test with .contains(..) and never ==.

#![allow(unused)]
fn main() {
use waterui::interaction::InteractionState;
use waterui::prelude::*;

fn pressable_chip() -> impl View {
    let state: Binding<InteractionState> = binding(InteractionState::empty());
    let pressed = state.map(|s: InteractionState| s.contains(InteractionState::PRESSED));
    // The whole chip sinks two pixels while its button is pressed.
    let sink = pressed.select(2.0_f32, 0.0_f32);

    vstack((
        text("Filter"),
        button("Apply").action(|| waterui::log::info!("applied")),
    ))
    .offset(0.0, sink)
    .interaction_state(&state)
}
}

Theme styles describe per-state values with StateValue<T>: StateValue::new(resting).when(InteractionState::HOVERED, hovered_value), where the order of .when(..) calls is their precedence and .resolve(state) returns the first override whose states are all present. .selected(signal) marks the control it modifies as selected, adding SELECTED to its state.

Haptic feedback

Haptic modifiers require the std facade feature, which is not a default feature:

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

With it enabled, waterui::Intensity (re-exported from waterkit_haptic) has the constants LOW, MEDIUM, HIGH, and MAX. The modifiers fire an impact before running your handler:

use waterui::Intensity;
use waterui::prelude::*;

fn save_button() -> impl View {
    vstack((
        text("Save").on_tap_haptic_default(|| waterui::log::info!("saved")),
        text("Delete").on_tap_haptic(Intensity::HIGH, || waterui::log::info!("deleted")),
        text("Hold to arm").on_long_press_haptic(600, Intensity::MAX, || {
            waterui::log::info!("armed");
        }),
    ))
}

.on_tap_haptic_default(action) uses Intensity::MEDIUM, and .on_long_press_haptic_default(ms, action) is the long-press equivalent.

Where next

Gestures recognize input on one view. Drag and drop moves typed data between views and applications, and Keyboard and focus covers key handling. For how handler parameters such as State<T> and Use<T> are extracted, read Controls and the environment chapter.