[PATCH yew-widget-toolkit] touch: gesture detector: implement pinch zoom gesture

Dominik Csapak
2026-04-15T15:03:17Z

exposes three new callbacks:

* on_pinch_zoom_start
* on_pinch_zoom
* on_pinch_zoom_end

As callback parameter a new GesturePinchZoomEvent is introduced. This
struct contains the two involved points, the angle relative to the
starting angle (as radians) and the relative scale of the distance
between the points (for convenience).

The rotation angle does consider multiple rotations, e.g. if the angle
is at 359 degrees and the next update reports 1 degree, it gets updated
to 361 degrees (and so on). While technically we can't know if the user
rotated the touche very fast, it's rather unlikely that this happens
in the real world so this heuristic seems fine. (Usually a user
won't rotate their fingers more than 180-270 degrees anyway).

This gesture is only registered for exactly two touches/pointers as
otherwise it would be rather complicated to calculate the
distance/angle. (As a further step we could expose
on_multi_touch{_start,_end} then this can also be handled by a user).

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
src/touch/gesture_detector.rs | 344 +++++++++++++++++++++++++++++++++-
src/touch/mod.rs | 5 +-
2 files changed, 341 insertions(+), 8 deletions(-)

diff --git a/src/touch/gesture_detector.rs b/src/touch/gesture_detector.rs
index ec05c2b..9c8777a 100644
--- a/src/touch/gesture_detector.rs
+++ b/src/touch/gesture_detector.rs
@@ -86,6 +86,31 @@ impl Deref for GestureSwipeEvent {
}
}

+/// Includes the current points involved in the gesture
+pub struct GesturePinchZoomEvent {
+ /// First touch/pointer [Point] of the Pinch/Zoom event
+ pub point0: PinchPoint,
+ /// Second touch/pointer [Point] of the Pinch/Zoom event
+ pub point1: PinchPoint,
+
+ /// Current angle of the gesture, relative to the starting position
+ pub angle: f64,
+
+ /// Current scale of the distance between touch points relative to the starting positions
+ pub scale: f64,
+}
+
+impl GesturePinchZoomEvent {
+ fn new(point0: PinchPoint, point1: PinchPoint, angle: f64, scale: f64) -> Self {
+ Self {
+ point0,
+ point1,
+ angle,
+ scale,
+ }
+ }
+}
+
/// Gesture detector.
///
/// You need to set the CSS attribute `touch-action: none;` on children to receive all events.
@@ -96,6 +121,7 @@ impl Deref for GestureSwipeEvent {
/// - long press: long tab without drag.
/// - drag: pointer move while touching the surface.
/// - swipe: fired at the end of a fast drag.
+/// - pinch/zoom: fired when two touches/pointers move.
///
/// # Note
///
@@ -103,7 +129,8 @@ impl Deref for GestureSwipeEvent {
///
/// Nested gesture detection is currently not implemented.
///
-/// Scale and rotate detection is also not implemented.
+/// It might be necessary to apply 'touch-action: none' to the content element.
+///
#[derive(Properties, Clone, PartialEq)]
pub struct GestureDetector {
/// The yew component key.
@@ -152,6 +179,18 @@ pub struct GestureDetector {

#[prop_or_default]
pub on_swipe: Option<Callback<GestureSwipeEvent>>,
+
+ /// Callback for Pinch/Zoom gesture start event.
+ #[prop_or_default]
+ pub on_pinch_zoom_start: Option<Callback<GesturePinchZoomEvent>>,
+
+ /// Callback for Pinch/Zoom gesture event.
+ #[prop_or_default]
+ pub on_pinch_zoom: Option<Callback<GesturePinchZoomEvent>>,
+
+ /// Callback for Pinch/Zoom end gesture event.
+ #[prop_or_default]
+ pub on_pinch_zoom_end: Option<Callback<GesturePinchZoomEvent>>,
}

impl GestureDetector {
@@ -203,6 +242,27 @@ impl GestureDetector {
self.on_swipe = cb.into_event_callback();
self
}
+
+ /// Builder style method to set the on_pinch_zoom_start callback
+ pub fn on_pinch_zoom_start(
+ mut self,
+ cb: impl IntoEventCallback<GesturePinchZoomEvent>,
+ ) -> Self {
+ self.on_pinch_zoom_start = cb.into_event_callback();
+ self
+ }
+
+ /// Builder style method to set the on_pinch_zoom callback
+ pub fn on_pinch_zoom(mut self, cb: impl IntoEventCallback<GesturePinchZoomEvent>) -> Self {
+ self.on_pinch_zoom = cb.into_event_callback();
+ self
+ }
+
+ /// Builder style method to set the on_pinch_zoom_end callback
+ pub fn on_pinch_zoom_end(mut self, cb: impl IntoEventCallback<GesturePinchZoomEvent>) -> Self {
+ self.on_pinch_zoom_end = cb.into_event_callback();
+ self
+ }
}

pub enum Msg {
@@ -227,6 +287,7 @@ enum DetectionState {
Single,
Drag,
Double,
+ Multi,
// Error,
Done,
}
@@ -245,12 +306,86 @@ struct PointerState {
direction: f64,
}

+impl PointerState {
+ fn to_pinch_point(&self, id: i32) -> PinchPoint {
+ PinchPoint {
+ id,
+ x: self.x,
+ y: self.y,
+ }
+ }
+}
+
+/// Represents a single pointer or touch
+pub struct PinchPoint {
+ pub id: i32,
+ pub x: i32,
+ pub y: i32,
+}
+
+impl PinchPoint {
+ /// calculates the distance in pixels to another [Point]
+ pub fn distance(&self, other: &PinchPoint) -> f64 {
+ compute_distance(self.x, self.y, other.x, other.y)
+ }
+
+ /// calculates the angle of the line to another [Point] in radians
+ pub fn angle(&self, other: &PinchPoint) -> f64 {
+ let x_diff = (other.x - self.x) as f64;
+ let y_diff = (-other.y + self.y) as f64;
+
+ y_diff.atan2(x_diff) + std::f64::consts::PI
+ }
+}
+
+#[derive(Debug, Default, Clone, PartialEq)]
+struct PinchZoomInfo {
+ start_angle: f64,
+ current_angle: f64,
+ start_distance: f64,
+ current_distance: f64,
+}
+
+impl PinchZoomInfo {
+ fn new(point0: PinchPoint, point1: PinchPoint) -> Self {
+ let angle = point0.angle(&point1);
+
+ // force a minimal distance of 1 pixel
+ let distance = point0.distance(&point1).max(1.0);
+
+ Self {
+ start_angle: angle,
+ current_angle: angle,
+ start_distance: distance,
+ current_distance: distance,
+ }
+ }
+
+ fn update(&mut self, point0: PinchPoint, point1: PinchPoint) {
+ let last_angle = self.current_angle;
+ let rotations = (last_angle / std::f64::consts::TAU).round();
+
+ let angle = point0.angle(&point1) + rotations * std::f64::consts::TAU;
+
+ if (last_angle - angle).abs() < std::f64::consts::PI {
+ self.current_angle = angle;
+ } else if last_angle > angle {
+ self.current_angle = angle + std::f64::consts::TAU;
+ } else if last_angle < angle {
+ self.current_angle = angle - std::f64::consts::TAU;
+ }
+
+ self.current_distance = point0.distance(&point1);
+ }
+}
+
#[doc(hidden)]
pub struct PwtGestureDetector {
touch_only: bool,
node_ref: NodeRef,
state: DetectionState,
pointers: HashMap<i32, PointerState>,
+ pinch_zoom_info: PinchZoomInfo,
}

fn now() -> f64 {
@@ -298,6 +433,10 @@ impl PwtGestureDetector {
let start_y = event.y();

self.register_pointer_state(ctx, id, start_x, start_y);
+
+ if self.pointers.len() == 2 {
+ self.start_pinch_zoom();
+ }
}

fn register_touches(&mut self, ctx: &Context<Self>, event: &TouchEvent) {
@@ -307,6 +446,20 @@ impl PwtGestureDetector {
let y = touch.client_y();
self.register_pointer_state(ctx, id, x, y);
});
+
+ if self.pointers.len() == 2 {
+ self.start_pinch_zoom();
+ }
+ }
+
+ fn start_pinch_zoom(&mut self) {
+ let (point0, point1) = self.get_pinch_points();
+ self.pinch_zoom_info = PinchZoomInfo::new(point0, point1)
+ }
+
+ fn update_pinch_zoom(&mut self) {
+ let (point0, point1) = self.get_pinch_points();
+ self.pinch_zoom_info.update(point0, point1);
}

fn unregister_touches<F: FnMut(i32, Touch, PointerState)>(
@@ -332,6 +485,28 @@ impl PwtGestureDetector {
}
}

+ fn get_pinch_points(&self) -> (PinchPoint, PinchPoint) {
+ let mut points: Vec<_> = self
+ .pointers
+ .iter()
+ .map(|(id, pointer)| pointer.to_pinch_point(*id))
+ .collect();
+ assert!(points.len() == 2);
+
+ // sort for stable stable order
+ points.sort_by_key(|p| p.id);
+
+ (points.remove(0), points.remove(0))
+ }
+
+ fn get_angle(&self) -> f64 {
+ self.pinch_zoom_info.current_angle - self.pinch_zoom_info.start_angle
+ }
+
+ fn get_scale(&self) -> f64 {
+ self.pinch_zoom_info.current_distance / self.pinch_zoom_info.start_distance
+ }
+
fn update_pointer_position(&mut self, id: i32, x: i32, y: i32) -> Option<&PointerState> {
if let Some(pointer_state) = self.pointers.get_mut(&id) {
let ctime = now();
@@ -382,8 +557,12 @@ impl PwtGestureDetector {
self.state = match self.pointers.len() {
0 => DetectionState::Initial,
1 => DetectionState::Single,
- // TODO implement more touches
- _ => DetectionState::Double,
+ 2 => {
+ let (point0, point1) = self.get_pinch_points();
+ self.call_on_pinch_zoom_start(ctx, point0, point1);
+ DetectionState::Double
+ }
+ _ => DetectionState::Multi,
};
}
Msg::PointerUp(_event) => { /* ignore */ }
@@ -432,6 +611,8 @@ impl PwtGestureDetector {
assert!(pointer_count == 1);
self.register_pointer(ctx, &event);
self.state = DetectionState::Double;
+ let (point0, point1) = self.get_pinch_points();
+ self.call_on_pinch_zoom_start(ctx, point0, point1);
}
Msg::TouchStart(event) => {
let pointer_count = self.pointers.len();
@@ -440,8 +621,12 @@ impl PwtGestureDetector {
self.state = match self.pointers.len() {
0 => DetectionState::Initial,
1 => DetectionState::Single,
- // TODO implement more touches
- _ => DetectionState::Double,
+ 2 => {
+ let (point0, point1) = self.get_pinch_points();
+ self.call_on_pinch_zoom_start(ctx, point0, point1);
+ DetectionState::Double
+ }
+ _ => DetectionState::Multi,
};
}
Msg::PointerUp(event) => {
@@ -560,13 +745,27 @@ impl PwtGestureDetector {
if let Some(on_drag_end) = &props.on_drag_end {
on_drag_end.emit(event.into());
}
+ let (point0, point1) = self.get_pinch_points();
+ self.call_on_pinch_zoom_start(ctx, point0, point1);
}
Msg::TouchStart(event) => {
let pointer_count = self.pointers.len();
assert!(pointer_count == 1);
// Abort current drags
self.register_touches(ctx, &event);
- self.state = DetectionState::Double;
+ let pointer_count = self.pointers.len();
+ match pointer_count {
+ 2 => {
+ self.state = DetectionState::Double;
+
+ let (point0, point1) = self.get_pinch_points();
+ self.call_on_pinch_zoom_start(ctx, point0, point1);
+ }
+ count if count > 2 => {
+ self.state = DetectionState::Multi;
+ }
+ _ => {}
+ }
for_each_active_touch(&event, |touch| {
if self.pointers.contains_key(&touch.identifier()) {
if let Some(on_drag_end) = &props.on_drag_end {
@@ -717,6 +916,135 @@ impl PwtGestureDetector {
true
}

+ fn call_on_pinch_zoom_start(
+ &mut self,
+ ctx: &Context<Self>,
+ point0: PinchPoint,
+ point1: PinchPoint,
+ ) {
+ if let Some(on_pinch_zoom_start) = &ctx.props().on_pinch_zoom_start {
+ on_pinch_zoom_start.emit(GesturePinchZoomEvent::new(
+ point0,
+ point1,
+ self.get_angle(),
+ self.get_scale(),
+ ))
+ }
+ }
+
+ fn call_on_pinch_zoom(&mut self, ctx: &Context<Self>, point0: PinchPoint, point1: PinchPoint) {
+ if let Some(on_pinch_zoom) = &ctx.props().on_pinch_zoom {
+ on_pinch_zoom.emit(GesturePinchZoomEvent::new(
+ point0,
+ point1,
+ self.get_angle(),
+ self.get_scale(),
+ ))
+ }
+ }
+
+ fn call_on_pinch_zoom_end(
+ &mut self,
+ ctx: &Context<Self>,
+ point0: PinchPoint,
+ point1: PinchPoint,
+ ) {
+ if let Some(on_pinch_zoom_end) = &ctx.props().on_pinch_zoom_end {
+ on_pinch_zoom_end.emit(GesturePinchZoomEvent::new(
+ point0,
+ point1,
+ self.get_angle(),
+ self.get_scale(),
+ ))
+ }
+ }
+
+ fn update_double(&mut self, ctx: &Context<Self>, msg: Msg) -> bool {
+ match msg {
+ Msg::TapTimeout(_id) => { /* ignore */ }
+ Msg::LongPressTimeout(_id) => { /* ignore */ }
+ Msg::PointerDown(event) => {
+ let pointer_count = self.pointers.len();
+ assert!(pointer_count == 2);
+ self.register_pointer(ctx, &event);
+ self.state = DetectionState::Multi;
+ }
+ Msg::TouchStart(event) => {
+ let pointer_count = self.pointers.len();
+ assert!(pointer_count == 2);
+ let (point0, point1) = self.get_pinch_points();
+ self.register_touches(ctx, &event);
+ self.state = DetectionState::Multi;
+ self.call_on_pinch_zoom_end(ctx, point0, point1);
+ }
+ Msg::PointerUp(event) | Msg::PointerCancel(event) | Msg::PointerLeave(event) => {
+ event.prevent_default();
+ let pointer_count = self.pointers.len();
+ assert!(pointer_count == 2);
+ let (point0, point1) = self.get_pinch_points();
+ if self.unregister_pointer(event.pointer_id()).is_some() {
+ self.state = DetectionState::Drag;
+ }
+ self.call_on_pinch_zoom_end(ctx, point0, point1);
+ }
+ Msg::TouchEnd(event) | Msg::TouchCancel(event) => {
+ let pointer_count = self.pointers.len();
+ assert!(pointer_count == 2);
+ let (point0, point1) = self.get_pinch_points();
+ let mut unregistered = 0;
+ for_each_changed_touch(&event, |touch| {
+ if self.unregister_pointer(touch.identifier()).is_some() {
+ unregistered += 1;
+ }
+ });
+ let pointer_count = pointer_count.saturating_sub(unregistered);
+ if pointer_count < 2 {
+ self.call_on_pinch_zoom_end(ctx, point0, point1);
+ }
+ match pointer_count {
+ 0 => self.state = DetectionState::Initial,
+ 1 => self.state = DetectionState::Drag,
+ 2 => {}
+ _more => self.state = DetectionState::Multi, // more touchpoints on removal?
+ }
+ }
+ Msg::PointerMove(event) => {
+ event.prevent_default();
+ let updated = self
+ .update_pointer_position(event.pointer_id(), event.x(), event.y())
+ .is_some();
+
+ self.update_pinch_zoom();
+
+ if updated {
+ let (point0, point1) = self.get_pinch_points();
+ self.call_on_pinch_zoom(ctx, point0, point1);
+ }
+ }
+ Msg::TouchMove(event) => {
+ let mut had_valid = false;
+ for_each_changed_touch(&event, |touch| {
+ if self
+ .update_pointer_position(
+ touch.identifier(),
+ touch.client_x(),
+ touch.client_y(),
+ )
+ .is_some()
+ {
+ had_valid = true
+ }
+ });
+ self.update_pinch_zoom();
+ if had_valid {
+ let (point0, point1) = self.get_pinch_points();
+ self.call_on_pinch_zoom(ctx, point0, point1);
+ }
+ }
+ }
+ true
+ }
+
// Wait until all pointers are released
fn update_error(&mut self, ctx: &Context<Self>, msg: Msg) -> bool {
match msg {
@@ -777,6 +1105,7 @@ impl Component for PwtGestureDetector {
state: DetectionState::Initial,
pointers: HashMap::new(),
node_ref: NodeRef::default(),
+ pinch_zoom_info: PinchZoomInfo::default(),
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
@@ -786,7 +1115,8 @@ impl Component for PwtGestureDetector {
DetectionState::Initial => self.update_initial(ctx, msg),
DetectionState::Single => self.update_single(ctx, msg),
DetectionState::Drag => self.update_drag(ctx, msg),
- DetectionState::Double => self.update_error(ctx, msg), // todo
+ DetectionState::Double => self.update_double(ctx, msg),
+ DetectionState::Multi => self.update_error(ctx, msg), // todo
//DetectionState::Error => self.update_error(ctx, msg),
DetectionState::Done => self.update_error(ctx, msg),
}
diff --git a/src/touch/mod.rs b/src/touch/mod.rs
index acb27ae..e9a8e19 100644
--- a/src/touch/mod.rs
+++ b/src/touch/mod.rs
@@ -5,7 +5,10 @@ mod application_bar;
pub use application_bar::{ApplicationBar, PwtApplicationBar};

mod gesture_detector;
-pub use gesture_detector::{GestureDetector, GestureSwipeEvent, InputEvent, PwtGestureDetector};
+pub use gesture_detector::{
+ GestureDetector, GesturePinchZoomEvent, GestureSwipeEvent, InputEvent, PinchPoint,
+ PwtGestureDetector,
+};

mod fab;
pub use fab::{Fab, FabSize, PwtFab};
--
2.47.3