Implement non-proportional multitouch pinch zooming

This commit is contained in:
Emil Ernerfeldt 2021-05-08 23:31:31 +02:00
parent 04d9ce227b
commit dd6980bacb
4 changed files with 125 additions and 17 deletions

View file

@ -130,7 +130,7 @@ impl InputState {
} }
/// Zoom scale factor this frame (e.g. from ctrl-scroll or pinch gesture). /// Zoom scale factor this frame (e.g. from ctrl-scroll or pinch gesture).
/// * `zoom = 1`: no change (default). /// * `zoom = 1`: no change
/// * `zoom < 1`: pinch together /// * `zoom < 1`: pinch together
/// * `zoom > 1`: pinch spread /// * `zoom > 1`: pinch spread
#[inline(always)] #[inline(always)]
@ -144,6 +144,30 @@ impl InputState {
.unwrap_or(self.raw.zoom_delta) .unwrap_or(self.raw.zoom_delta)
} }
/// 2D non-proportional zoom scale factor this frame (e.g. from ctrl-scroll or pinch gesture).
///
/// For multitouch devices the user can do a horizontal or vertical pinch gesture.
/// In these cases a non-proportional zoom factor is a available.
/// In other cases, this reverts to `Vec2::splat(self.zoom_delta())`.
///
/// For horizontal pinches, this will return `[z, 1]`,
/// for vertical pinches this will return `[1, z]`,
/// and otherwise this will return `[z, z]`,
/// where `z` is the zoom factor:
/// * `zoom = 1`: no change
/// * `zoom < 1`: pinch together
/// * `zoom > 1`: pinch spread
#[inline(always)]
pub fn zoom_delta_2d(&self) -> Vec2 {
// If a multi touch gesture is detected, it measures the exact and linear proportions of
// the distances of the finger tips. It is therefore potentially more accurate than
// `raw.zoom_delta` which is based on the `ctrl-scroll` event which, in turn, may be
// synthesized from an original touch gesture.
self.multi_touch()
.map(|touch| touch.zoom_delta_2d)
.unwrap_or_else(|| Vec2::splat(self.raw.zoom_delta))
}
pub fn wants_repaint(&self) -> bool { pub fn wants_repaint(&self) -> bool {
self.pointer.wants_repaint() || self.scroll_delta != Vec2::ZERO || !self.events.is_empty() self.pointer.wants_repaint() || self.scroll_delta != Vec2::ZERO || !self.events.is_empty()
} }

View file

@ -11,20 +11,36 @@ use epaint::emath::{Pos2, Vec2};
pub struct MultiTouchInfo { pub struct MultiTouchInfo {
/// Point in time when the gesture started. /// Point in time when the gesture started.
pub start_time: f64, pub start_time: f64,
/// Position of the pointer at the time the gesture started. /// Position of the pointer at the time the gesture started.
pub start_pos: Pos2, pub start_pos: Pos2,
/// Number of touches (fingers) on the surface. Value is ≥ 2 since for a single touch no /// Number of touches (fingers) on the surface. Value is ≥ 2 since for a single touch no
/// `MultiTouchInfo` is created. /// `MultiTouchInfo` is created.
pub num_touches: usize, pub num_touches: usize,
/// Zoom factor (Pinch or Zoom). Moving fingers closer together or further appart will change
/// this value. This is a relative value, comparing the average distances of the fingers in /// Proportional zoom factor (pinch gesture).
/// the current and previous frame. If the fingers did not move since the previous frame, /// * `zoom = 1`: no change
/// this value is `1.0`. /// * `zoom < 1`: pinch together
/// * `zoom > 1`: pinch spread
pub zoom_delta: f32, pub zoom_delta: f32,
/// 2D non-proportional zoom factor (pinch gesture).
///
/// For horizontal pinches, this will return `[z, 1]`,
/// for vertical pinches this will return `[1, z]`,
/// and otherwise this will return `[z, z]`,
/// where `z` is the zoom factor:
/// * `zoom = 1`: no change
/// * `zoom < 1`: pinch together
/// * `zoom > 1`: pinch spread
pub zoom_delta_2d: Vec2,
/// Rotation in radians. Moving fingers around each other will change this value. This is a /// Rotation in radians. Moving fingers around each other will change this value. This is a
/// relative value, comparing the orientation of fingers in the current frame with the previous /// relative value, comparing the orientation of fingers in the current frame with the previous
/// frame. If all fingers are resting, this value is `0.0`. /// frame. If all fingers are resting, this value is `0.0`.
pub rotation_delta: f32, pub rotation_delta: f32,
/// Relative movement (comparing previous frame and current frame) of the average position of /// Relative movement (comparing previous frame and current frame) of the average position of
/// all touch points. Without movement this value is `Vec2::ZERO`. /// all touch points. Without movement this value is `Vec2::ZERO`.
/// ///
@ -34,6 +50,7 @@ pub struct MultiTouchInfo {
/// the pointer, but touch movement is always measured in the units delivered by the device, /// the pointer, but touch movement is always measured in the units delivered by the device,
/// and may depend on hardware and system settings. /// and may depend on hardware and system settings.
pub translation_delta: Vec2, pub translation_delta: Vec2,
/// Current force of the touch (average of the forces of the individual fingers). This is a /// Current force of the touch (average of the forces of the individual fingers). This is a
/// value in the interval `[0.0 .. =1.0]`. /// value in the interval `[0.0 .. =1.0]`.
/// ///
@ -67,6 +84,7 @@ pub(crate) struct TouchState {
struct GestureState { struct GestureState {
start_time: f64, start_time: f64,
start_pointer_pos: Pos2, start_pointer_pos: Pos2,
pinch_type: PinchType,
previous: Option<DynGestureState>, previous: Option<DynGestureState>,
current: DynGestureState, current: DynGestureState,
} }
@ -74,7 +92,10 @@ struct GestureState {
/// Gesture data that can change over time /// Gesture data that can change over time
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
struct DynGestureState { struct DynGestureState {
/// used for proportional zooming
avg_distance: f32, avg_distance: f32,
/// used for non-proportional zooming
avg_abs_distance2: Vec2,
avg_pos: Pos2, avg_pos: Pos2,
avg_force: f32, avg_force: f32,
heading: f32, heading: f32,
@ -154,11 +175,27 @@ impl TouchState {
// changed. In this case, we take `current` as `previous`, pretending that there // changed. In this case, we take `current` as `previous`, pretending that there
// was no change for the current frame. // was no change for the current frame.
let state_previous = state.previous.unwrap_or(state.current); let state_previous = state.previous.unwrap_or(state.current);
let zoom_delta = state.current.avg_distance / state_previous.avg_distance;
let zoom_delta2 = match state.pinch_type {
PinchType::Horizontal => Vec2::new(
state.current.avg_abs_distance2.x / state_previous.avg_abs_distance2.x,
1.0,
),
PinchType::Vertical => Vec2::new(
1.0,
state.current.avg_abs_distance2.y / state_previous.avg_abs_distance2.y,
),
PinchType::Proportional => Vec2::splat(zoom_delta),
};
MultiTouchInfo { MultiTouchInfo {
start_time: state.start_time, start_time: state.start_time,
start_pos: state.start_pointer_pos, start_pos: state.start_pointer_pos,
num_touches: self.active_touches.len(), num_touches: self.active_touches.len(),
zoom_delta: state.current.avg_distance / state_previous.avg_distance, zoom_delta,
zoom_delta_2d: zoom_delta2,
rotation_delta: normalized_angle(state.current.heading, state_previous.heading), rotation_delta: normalized_angle(state.current.heading, state_previous.heading),
translation_delta: state.current.avg_pos - state_previous.avg_pos, translation_delta: state.current.avg_pos - state_previous.avg_pos,
force: state.current.avg_force, force: state.current.avg_force,
@ -177,6 +214,7 @@ impl TouchState {
self.gesture_state = Some(GestureState { self.gesture_state = Some(GestureState {
start_time: time, start_time: time,
start_pointer_pos: pointer_pos, start_pointer_pos: pointer_pos,
pinch_type: PinchType::classify(&self.active_touches),
previous: None, previous: None,
current: dyn_state, current: dyn_state,
}); });
@ -187,16 +225,18 @@ impl TouchState {
} }
} }
/// `None` if less than two fingers
fn calc_dynamic_state(&self) -> Option<DynGestureState> { fn calc_dynamic_state(&self) -> Option<DynGestureState> {
let num_touches = self.active_touches.len(); let num_touches = self.active_touches.len();
if num_touches < 2 { if num_touches < 2 {
None None
} else { } else {
let mut state = DynGestureState { let mut state = DynGestureState {
avg_distance: 0., avg_distance: 0.0,
avg_abs_distance2: Vec2::ZERO,
avg_pos: Pos2::ZERO, avg_pos: Pos2::ZERO,
avg_force: 0., avg_force: 0.0,
heading: 0., heading: 0.0,
}; };
let num_touches_recip = 1. / num_touches as f32; let num_touches_recip = 1. / num_touches as f32;
@ -213,8 +253,11 @@ impl TouchState {
// second pass: calculate distances from center: // second pass: calculate distances from center:
for touch in self.active_touches.values() { for touch in self.active_touches.values() {
state.avg_distance += state.avg_pos.distance(touch.pos); state.avg_distance += state.avg_pos.distance(touch.pos);
state.avg_abs_distance2.x += (state.avg_pos.x - touch.pos.x).abs();
state.avg_abs_distance2.y += (state.avg_pos.y - touch.pos.y).abs();
} }
state.avg_distance *= num_touches_recip; state.avg_distance *= num_touches_recip;
state.avg_abs_distance2 *= num_touches_recip;
// Calculate the direction from the first touch to the center position. // Calculate the direction from the first touch to the center position.
// This is not the perfect way of calculating the direction if more than two fingers // This is not the perfect way of calculating the direction if more than two fingers
@ -271,3 +314,40 @@ fn normalizing_angle_from_350_to_0_yields_10() {
<= 5. * f32::EPSILON // many conversions (=divisions) involved => high error rate <= 5. * f32::EPSILON // many conversions (=divisions) involved => high error rate
); );
} }
#[derive(Clone, Debug)]
enum PinchType {
Horizontal,
Vertical,
Proportional,
}
impl PinchType {
fn classify(touches: &BTreeMap<TouchId, ActiveTouch>) -> Self {
// For non-proportional 2d zooming:
// If the user is pinching with two fingers that have roughly the same Y coord,
// then the Y zoom is unstable and should be 1.
// Similarly, if the fingers are directly above/below each other,
// we should only zoom on the Y axis.
// If the fingers are roughly on a diagonal, we revert to the proportional zooming.
if touches.len() == 2 {
let mut touches = touches.values();
let t0 = touches.next().unwrap().pos;
let t1 = touches.next().unwrap().pos;
let dx = (t0.x - t1.x).abs();
let dy = (t0.y - t1.y).abs();
if dx > 3.0 * dy {
Self::Horizontal
} else if dy > 3.0 * dx {
Self::Vertical
} else {
Self::Proportional
}
} else {
Self::Proportional
}
}
}

View file

@ -418,12 +418,17 @@ impl Widget for Plot {
// Zooming // Zooming
if allow_zoom { if allow_zoom {
if let Some(hover_pos) = response.hover_pos() { if let Some(hover_pos) = response.hover_pos() {
let zoom_factor = ui.input().zoom_delta(); let zoom_factor = if data_aspect.is_some() {
Vec2::splat(ui.input().zoom_delta())
} else {
ui.input().zoom_delta_2d()
};
#[allow(clippy::float_cmp)] #[allow(clippy::float_cmp)]
if zoom_factor != 1.0 { if zoom_factor != Vec2::splat(1.0) {
transform.zoom(zoom_factor, hover_pos); transform.zoom(zoom_factor, hover_pos);
auto_bounds = false; auto_bounds = false;
} }
let scroll_delta = ui.input().scroll_delta; let scroll_delta = ui.input().scroll_delta;
if scroll_delta != Vec2::ZERO { if scroll_delta != Vec2::ZERO {
transform.translate_bounds(-scroll_delta); transform.translate_bounds(-scroll_delta);

View file

@ -161,15 +161,14 @@ impl ScreenTransform {
} }
/// Zoom by a relative factor with the given screen position as center. /// Zoom by a relative factor with the given screen position as center.
pub fn zoom(&mut self, zoom_factor: f32, center: Pos2) { pub fn zoom(&mut self, zoom_factor: Vec2, center: Pos2) {
let zoom_factor = zoom_factor as f64;
let center = self.value_from_position(center); let center = self.value_from_position(center);
let mut new_bounds = self.bounds; let mut new_bounds = self.bounds;
new_bounds.min[0] = center.x + (new_bounds.min[0] - center.x) / zoom_factor; new_bounds.min[0] = center.x + (new_bounds.min[0] - center.x) / (zoom_factor.x as f64);
new_bounds.max[0] = center.x + (new_bounds.max[0] - center.x) / zoom_factor; new_bounds.max[0] = center.x + (new_bounds.max[0] - center.x) / (zoom_factor.x as f64);
new_bounds.min[1] = center.y + (new_bounds.min[1] - center.y) / zoom_factor; new_bounds.min[1] = center.y + (new_bounds.min[1] - center.y) / (zoom_factor.y as f64);
new_bounds.max[1] = center.y + (new_bounds.max[1] - center.y) / zoom_factor; new_bounds.max[1] = center.y + (new_bounds.max[1] - center.y) / (zoom_factor.y as f64);
if new_bounds.is_valid() { if new_bounds.is_valid() {
self.bounds = new_bounds; self.bounds = new_bounds;