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 aGestureObserverattached) 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:
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:
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:
use waterui::gesture::DragGesture;
let drag = DragGesture::new(5.0); // 5pt minimum travel
MagnificationGesture
Recognizes pinch-to-zoom interactions:
use waterui::gesture::MagnificationGesture;
let pinch = MagnificationGesture::new(1.0); // initial scale factor
RotationGesture
Recognizes two-finger rotation:
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.
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 Type | Fields |
|---|---|
TapEvent | location: GesturePoint, count: u32 |
LongPressEvent | location: GesturePoint, duration: f32 |
DragEvent | phase, location, translation, velocity |
MagnificationEvent | phase, 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
stdfeature 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
| API | Purpose |
|---|---|
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> extractor | Pull 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.