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

Dominik Csapak
2026-04-16T09:00:25Z

superseded by v2:
https://lore.proxmox.com/yew-devel/20260416085849.1062721-1-d.csapak@proxmox.com/T/#t

On 4/15/26 5:02 PM, Dominik Csapak wrote:
> 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};