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:

  • Understand WaterUI’s hit-testing model for touch events
  • Attach tap, long-press, drag, pinch, and rotation gestures to views
  • Inject reactive state and pull it back through the State<T> extractor
  • Compose gestures sequentially, simultaneously, and with priority
  • Add haptic feedback to make interactions feel tangible

A button click is just the beginning. Real apps need drag-to-reorder, pinch-to-zoom, long-press context menus, and double-tap shortcuts. WaterUI provides a declarative gesture system where gesture descriptors are lightweight data structures that backends translate into platform-native gesture recognizers. You describe what you want to recognize, and the platform handles the how.

Hit-Testing Model

Before diving into gestures, it is important to understand how WaterUI decides which view receives a touch event.

WaterUI uses a pass-through model:

  • Non-interactive views (plain Text, Spacer, layout containers) are transparent to touch events. Touches fall through to views behind them in the Z-order.
  • Interactive views (Button, views with a GestureObserver attached) capture touches within their bounds.

In a ZStack or overlay, only the topmost interactive view at a touch location receives the event.

use waterui::prelude::*;

zstack((
    VideoPlayer::new(url).show_controls(true),
    vstack((
        spacer(),  // non-interactive: touches pass through to VideoPlayer
        button("Play").action(|| { /* ... */ }),  // captures touches
    )),
))

Note: If you find that taps are not reaching a view behind an overlay, check whether the overlay contains any interactive elements that might be capturing the touch.

Gesture Types

All gesture descriptors live in waterui::gesture. Each type captures the minimum configuration required for a backend to register the interaction.

TapGesture

Recognizes one or more consecutive taps:

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

let single = TapGesture::new();       // single tap
let double = TapGesture::repeat(2);   // double tap
let triple = TapGesture::repeat(3);   // triple tap
}

LongPressGesture

Activates after the pointer is held for a minimum duration:

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

let press = LongPressGesture::new(500); // 500ms minimum hold
}

The duration unit is interpreted by each backend (typically milliseconds).

DragGesture

Begins after the pointer moves beyond a minimum distance:

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

let drag = DragGesture::new(5.0); // 5pt minimum travel
}

MagnificationGesture

Recognizes pinch-to-zoom interactions:

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

let pinch = MagnificationGesture::new(1.0); // initial scale factor
}

RotationGesture

Recognizes two-finger rotation:

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

let rotation = RotationGesture::new(0.0); // initial angle in radians
}

All of these types can be converted into the unified Gesture enum, which also contains composition variants (Then, Simultaneous, Exclusive) that we will explore later in this chapter.

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

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

Gesture Event Payloads

When a backend recognizes a gesture, it creates an event payload and places it in the environment. The payload types carry interaction details:

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

GesturePhase tracks the lifecycle: Started, Updated, Ended, or Cancelled.

Attaching Gestures to Views

Now that you know the gesture types, let’s attach them to views. WaterUI offers several approaches, from general-purpose to convenient shorthand.

The .gesture() Method

The most general way to attach a gesture is through ViewExt::gesture:

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

text("Tap Me!")
    .gesture(TapGesture::new(), || {
        tracing::info!("Tapped!");
    })

The first argument is anything that implements Into<Gesture>. The second is any type that implements Handler<Args, ()> – a plain closure with no arguments, a closure that pulls injected state via State<T>, or any combination of extractors documented in Resolvers and Hooks.

.on_tap()

A convenience shorthand for single-tap gestures:

use waterui::prelude::*;

text("Click me")
    .on_tap(|| tracing::info!("Clicked!"))

.on_tap_gesture() and .on_tap_gesture_count()

.on_tap_gesture() is an alias for .on_tap(). Use .on_tap_gesture_count(n, action) for multi-tap gestures:

use waterui::prelude::*;

text("Double-tap me")
    .on_tap_gesture_count(2, || {
        tracing::info!("Double tapped!");
    })

.on_long_press_gesture()

Attach a long-press handler with a minimum duration in milliseconds:

use waterui::prelude::*;

text("Long press me")
    .on_long_press_gesture(500, || {
        tracing::info!("Long pressed!");
    })

.gesture_observer()

For full control, construct a GestureObserver directly. The action is any type that implements Handler<Args, ()>, so you can pull captured state out of the environment with the State<T> extractor:

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

let counter = binding(0i32);

text("Count taps")
    .state(&counter)
    .gesture_observer(GestureObserver::new(
        TapGesture::new(),
        |State(counter): State<Binding<i32>>| counter.set(counter.get() + 1),
    ))

Stateful Gesture Handlers

Most interactions need access to reactive state – counting taps, tracking drag positions, or toggling a boolean. WaterUI keeps the handler ergonomic without a custom builder: inject state into the subtree’s environment with ViewExt::state, then ask for it back through the State<T> extractor in the handler signature.

Injecting State

ViewExt::state clones a binding (or any cloneable value) into the subtree’s environment. The injected value is keyed by type, so every handler in scope can extract it:

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

let count = binding(0i32);

text("Tap to count")
    .padding()
    .background(Color::srgb(200, 220, 255))
    .state(&count)
    .gesture(
        TapGesture::new(),
        |State(count): State<Binding<i32>>| count.set(count.get() + 1),
    )

Stack multiple .state(...) calls to inject several values. Each extractor in the handler tuple pulls one out:

use waterui::prelude::*;
use waterui::reactive::binding;

let count = binding(0i32);
let label = binding(String::from("Ready"));

text("Interact")
    .state(&count)
    .state(&label)
    .on_tap(
        |State(count): State<Binding<i32>>,
         State(label): State<Binding<String>>| {
            count.set(count.get() + 1);
            label.set(format!("Tapped {} times", count.get()));
        },
    )

Inside a GestureObserver

The same State<T> extractor works when you build a GestureObserver manually – the action signature is identical to the one you would pass to .gesture(...):

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

let counter = binding(0i32);

let observer = GestureObserver::new(
    TapGesture::repeat(2),
    |State(counter): State<Binding<i32>>| counter.set(counter.get() + 1),
);

Attach the observer with view.state(&counter).gesture_observer(observer) so the binding is available when the handler fires.

Combining Gestures

Single gestures are useful, but real interactions often involve combinations. WaterUI supports three composition modes, mirroring SwiftUI.

Sequential: .then()

The second gesture starts only after the first completes:

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

let chained = TapGesture::new()
    .then(LongPressGesture::new(300));
// User must tap, then long-press

.sequenced_before() is an alias for .then().

Simultaneous: .simultaneously_with()

Both gestures can be recognized at the same time:

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

let combined = TapGesture::new()
    .simultaneously_with(DragGesture::new(8.0));

Exclusive: .exclusively_before()

The first gesture has recognition priority; the second is a fallback:

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

let exclusive = TapGesture::new()
    .exclusively_before(LongPressGesture::new(500));

These composition methods can be chained to build arbitrarily complex gesture graphs. Each produces a Gesture::Then, Gesture::Simultaneous, or Gesture::Exclusive variant.

Priority Modifiers

ViewExt provides SwiftUI-style naming for attaching composed gestures directly to views:

use waterui::prelude::*;
use waterui::gesture::DragGesture;

text("Drag or tap")
    .simultaneous_gesture(DragGesture::new(5.0), || {
        tracing::info!("Drag detected");
    })
    .high_priority_gesture(
        waterui::gesture::TapGesture::new(),
        || tracing::info!("Tap wins")
    )

Haptic Feedback

On platforms that support it (iOS, Android), WaterUI integrates with the waterkit-haptic crate to trigger tactile feedback alongside gestures. Haptics make interactions feel real – a subtle vibration on a successful action, a heavier pulse on a destructive one.

.on_tap_haptic()

Combines a tap gesture with haptic impact:

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

text("Haptic Tap")
    .on_tap_haptic(Intensity::MEDIUM, || {
        tracing::info!("Felt that!");
    })

Intensity provides constants for common feedback levels. The haptic fires before the action closure runs.

.on_tap_haptic_default()

Uses Intensity::MEDIUM as a sensible default:

use waterui::prelude::*;

text("Default Haptic")
    .on_tap_haptic_default(|| {
        tracing::info!("Medium haptic fired");
    })

Note: Both haptic methods require the std feature flag and are no-ops on platforms without haptic hardware.

Complete Example

Let’s put it all together. This example demonstrates multiple gesture types, state handling, and gesture composition in a single view:

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

fn main() -> impl View {
    let tap_count = binding(0i32);
    let long_press_count = binding(0i32);
    let drag_count = binding(0i32);
    let chained_status = binding(String::from("Waiting..."));

    fn bump(c: &Binding<i32>) {
        c.set(c.get() + 1);
    }

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

        // Single tap
        text("Tap Me!")
            .padding()
            .background(Color::srgb(33, 150, 243).with_opacity(0.3))
            .state(&tap_count)
            .gesture(
                TapGesture::new(),
                |State(c): State<Binding<i32>>| bump(&c),
            ),

        // Long press
        text("Long Press Me!")
            .padding()
            .background(Color::srgb(255, 152, 0).with_opacity(0.3))
            .state(&long_press_count)
            .gesture(
                LongPressGesture::new(500),
                |State(c): State<Binding<i32>>| bump(&c),
            ),

        // Drag
        text("Drag Here")
            .padding()
            .width(200.0).height(100.0)
            .background(Color::srgb(156, 39, 176).with_opacity(0.3))
            .state(&drag_count)
            .gesture(
                DragGesture::new(5.0),
                |State(c): State<Binding<i32>>| bump(&c),
            ),

        // Chained: tap then long press
        text("Tap then Long Press")
            .padding()
            .background(Color::srgb(244, 67, 54).with_opacity(0.3))
            .state(&chained_status)
            .gesture(
                TapGesture::new().then(LongPressGesture::new(300)),
                |State(s): State<Binding<String>>| {
                    s.set(String::from("Chained gesture completed!"));
                },
            ),
    )))
}

Try it yourself: Add a double-tap gesture to one of the views above using TapGesture::repeat(2). Can you make it coexist with the single tap using .exclusively_before()?

Summary

APIPurpose
TapGesture::new()Single tap
TapGesture::repeat(n)Multi-tap
LongPressGesture::new(ms)Long press with minimum duration
DragGesture::new(distance)Drag with minimum distance
MagnificationGesture::new(scale)Pinch-to-zoom
RotationGesture::new(angle)Two-finger rotation
.gesture(g, action)Attach any gesture to a view
.on_tap(action)Single-tap shorthand
.on_tap_gesture_count(n, action)Multi-tap shorthand
.on_long_press_gesture(ms, action)Long-press shorthand
.gesture_observer(observer)Full-control gesture attachment
.state(&binding)Inject cloneable state into the subtree environment
State<T> extractorPull injected state into a handler
.then()Sequential composition
.simultaneously_with()Parallel composition
.exclusively_before()Priority composition
.on_tap_haptic(intensity, action)Tap with haptic feedback
.on_tap_haptic_default(action)Tap with medium haptic

What’s Next

Your app now responds to rich touch interactions. But what happens when a gesture triggers a network request? In the next chapter, you will learn how to handle async operations gracefully with Suspense, showing loading states while data arrives.