From bbd613206ae4a4bb7544a40a3fe5c7a057972589 Mon Sep 17 00:00:00 2001 From: amos Date: Tue, 4 Apr 2023 20:34:33 +0200 Subject: [PATCH] Make Slider component work on touch devices (#168) blueprintjs, even on v4, apparently only works on desktop targets (it uses mouse events), but I'm using yewprint for _something_ and I needed it to work on mobile devices. It seems pointer events + pointer capture is the way to go in 2023, so that's what this uses! Tested on Chrome macOS, Firefox macOS, Safari macOS, and Chrome Android and it works fine. You can drag starting from the handle or starting from anywhere on the slider itself, you can still move the pointer up/down outside the slider while holding it, focusing the handle + using arrow keys still works as before. I guess the only breaking change is that you can move the slider with a secondary mouse button now, but since PointerEvent inherits from MouseEvent it might be possible to re-add that restriction, if anyone cares about it enough. --- Cargo.toml | 2 +- src/lib.rs | 4 +- src/slider.rs | 211 ++++++++++++++++++++++++++++++-------------------- 3 files changed, 129 insertions(+), 88 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e654792..9127a34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ gloo = "0.8" id_tree = { version = "1.8", optional = true } implicit-clone = "0.3.5" wasm-bindgen = "0.2" -web-sys = { version = "0.3", features = ["DomRect", "Element", "Event", "HtmlSelectElement", "DomTokenList"] } +web-sys = { version = "0.3", features = ["DomRect", "Element", "Event", "HtmlSelectElement", "DomTokenList", "Element"] } yew = "0.20" yew-callbacks = "0.2.1" diff --git a/src/lib.rs b/src/lib.rs index 9541535..b969a30 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,7 +4,8 @@ clippy::inconsistent_struct_constructor, clippy::type_complexity, clippy::derive_partial_eq_without_eq, - clippy::uninlined_format_args + clippy::uninlined_format_args, + clippy::derivable_impls )] mod button_group; @@ -75,6 +76,7 @@ use yew::Classes; // See https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons #[allow(dead_code)] const MOUSE_EVENT_BUTTONS_NONE: u16 = 0; +#[allow(dead_code)] const MOUSE_EVENT_BUTTONS_PRIMARY: u16 = 1; #[allow(dead_code)] const MOUSE_EVENT_BUTTONS_SECONDARY: u16 = 2; diff --git a/src/slider.rs b/src/slider.rs index 17f6d54..b5bc3da 100644 --- a/src/slider.rs +++ b/src/slider.rs @@ -1,17 +1,19 @@ use crate::Intent; use implicit_clone::{unsync::IArray, ImplicitClone}; use std::marker::PhantomData; -use wasm_bindgen::closure::Closure; use wasm_bindgen::JsCast; use web_sys::Element; use yew::prelude::*; pub struct Slider { - mouse_move: Closure, - mouse_up: Closure, + slider_ref: NodeRef, handle_ref: NodeRef, track_ref: NodeRef, is_moving: bool, + // The value we had just before we started moving. We need to track it + // in case the gesture turns from something we handle (dragging left/right) + // into something the user-agent should be handling (pan-y = scrolling up/down) + value_before_move: Option, focus_handle: bool, phantom: PhantomData, } @@ -31,10 +33,17 @@ pub struct SliderProps { pub selected: Option, } +#[derive(Debug)] pub enum Msg { - StartChange, - Mouse(MouseEvent), - StopChange, + // started a gesture, either via mouse ("desktop") or touch ("mobile") + PointerDown { pointer_id: Option }, + // pointer moved, we only track X for now (vertical isn't supported for now) + PointerMove { client_x: i32 }, + // gesture cancelled: turns out we were scrolling or something + PointerCancel, + // gesture completed successfully + PointerUp, + // adjusting via keyboard, completely independent from mouse gestures Keyboard(KeyboardEvent), } @@ -42,25 +51,13 @@ impl Component for Slider { type Message = Msg; type Properties = SliderProps; - fn create(ctx: &Context) -> Self { - let mouse_move = { - let link = ctx.link().clone(); - Closure::wrap(Box::new(move |event: web_sys::MouseEvent| { - link.send_message(Msg::Mouse(event)); - }) as Box) - }; - let mouse_up = { - let link = ctx.link().clone(); - Closure::wrap(Box::new(move |_event: web_sys::MouseEvent| { - link.send_message(Msg::StopChange); - }) as Box) - }; + fn create(_ctx: &Context) -> Self { Self { - mouse_move, - mouse_up, + slider_ref: NodeRef::default(), handle_ref: NodeRef::default(), track_ref: NodeRef::default(), is_moving: false, + value_before_move: None, focus_handle: false, phantom: PhantomData, } @@ -68,70 +65,66 @@ impl Component for Slider { fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { match msg { - Msg::StartChange if ctx.props().values.len() > 1 => { - let document = gloo::utils::document(); - let event_target: &web_sys::EventTarget = document.as_ref(); + Msg::PointerDown { pointer_id } if ctx.props().values.len() > 1 => { + if let Some(pointer_id) = pointer_id { + if let Some(slider) = self.slider_ref.cast::() { + slider.set_pointer_capture(pointer_id).unwrap(); + } + } + self.is_moving = true; - event_target - .add_event_listener_with_callback( - "mousemove", - self.mouse_move.as_ref().unchecked_ref(), - ) - .expect("No event listener to add"); - event_target - .add_event_listener_with_callback( - "mouseup", - self.mouse_up.as_ref().unchecked_ref(), - ) - .expect("No event listener to add"); + if let Some(selected) = ctx.props().selected.as_ref() { + // save the current value in case we need to restore it + self.value_before_move = Some(selected.clone()); + } true } - Msg::StartChange => false, - Msg::Mouse(event) if ctx.props().values.len() > 1 => { - if event.buttons() == crate::MOUSE_EVENT_BUTTONS_PRIMARY { - let track_rect = self.track_ref.cast::().expect("no track ref"); - let tick_size = (track_rect.client_width() as f64) - / ctx.props().values.len().saturating_sub(1) as f64; - let pixel_delta = - (event.client_x() as f64) - track_rect.get_bounding_client_rect().left(); + Msg::PointerDown { .. } => false, + Msg::PointerMove { client_x } if ctx.props().values.len() > 1 => { + if !self.is_moving { + return false; + } - let position = (pixel_delta / tick_size).round() as usize; + let track_rect = self.track_ref.cast::().expect("no track ref"); + let tick_size = (track_rect.client_width() as f64) + / ctx.props().values.len().saturating_sub(1) as f64; + let pixel_delta = (client_x as f64) - track_rect.get_bounding_client_rect().left(); - let (value, _) = ctx.props().values.get(position).unwrap_or_else(|| { - ctx.props() - .values - .last() - .cloned() - .expect("No value in the array") - }); + let position = (pixel_delta / tick_size).round() as usize; - if Some(&value) != ctx.props().selected.as_ref() { - ctx.props().onchange.emit(value); - } + let (value, _) = ctx.props().values.get(position).unwrap_or_else(|| { + ctx.props() + .values + .last() + .cloned() + .expect("No value in the array") + }); + if Some(&value) != ctx.props().selected.as_ref() { + ctx.props().onchange.emit(value); + } + true + } + Msg::PointerMove { .. } => false, + Msg::PointerCancel => { + self.is_moving = false; + + if let Some(value_before_move) = self.value_before_move.take() { + // there's a slight bug here: if the value was None before + // the cancelled gesture started, we have no way to restore + // it to None since `onchange` only accepts `T`, not `Option`. + // fixing this requires breaking the API + ctx.props().onchange.emit(value_before_move); true } else { false } } - Msg::Mouse(_) => false, - Msg::StopChange => { - let document = gloo::utils::document(); - let event_target: &web_sys::EventTarget = document.as_ref(); + Msg::PointerUp => { + // gesture completed successfully self.is_moving = false; - event_target - .remove_event_listener_with_callback( - "mousemove", - self.mouse_move.as_ref().unchecked_ref(), - ) - .expect("No event listener to remove"); - event_target - .remove_event_listener_with_callback( - "mouseup", - self.mouse_up.as_ref().unchecked_ref(), - ) - .expect("No event listener to remove"); + self.value_before_move = None; true } Msg::Keyboard(event) if ctx.props().values.len() > 1 => match event.key().as_str() { @@ -228,19 +221,73 @@ impl Component for Slider { "bp3-slider", ctx.props().vertical.then_some("bp3-vertical"), )} - onmousedown={(ctx.props().values.len() > 1).then( + ref={self.slider_ref.clone()} + style={"touch-action: pan-y pinch-zoom; -webkit-touch-callout: none;"} + onpointerdown={(ctx.props().values.len() > 1).then( || ctx.link().batch_callback( - |event: MouseEvent| { - if event.buttons() == - crate::MOUSE_EVENT_BUTTONS_PRIMARY - { - vec![Msg::StartChange, Msg::Mouse(event)] + |event: PointerEvent| { + if event.is_primary() { + let down = Msg::PointerDown { + pointer_id: Some(event.pointer_id()) + }; + if event.pointer_type() == "touch" { + // for touch devices, wait for some dragging + // to occur to know if we're dragging the + // slider or scrolling the page. this avoids + // some jumps on pointercancel, in most + // cases. it also doesn't affect "clicks" + // which do one-time adjustments. + vec![down] + } else { + vec![down, Msg::PointerMove { client_x: event.client_x() }] + } } else { vec![] } } ) )} + onpointermove={(ctx.props().values.len() > 1).then( + || ctx.link().batch_callback( + |event: PointerEvent| { + if let Some(target) = event.target() { + if let Some(el) = target.dyn_ref::() { + if el.has_pointer_capture(event.pointer_id()) { + return vec![ + Msg::PointerMove { client_x: event.client_x() } + ]; + } + } + } + vec![] + } + ) + )} + onpointerup={(ctx.props().values.len() > 1).then( + || ctx.link().callback( + |_event: PointerEvent| { + Msg::PointerUp + } + ) + )} + onpointercancel={(ctx.props().values.len() > 1).then( + || ctx.link().callback( + |_event: PointerEvent| { + Msg::PointerCancel + } + ) + )} + onclick={(ctx.props().values.len() > 1).then( + || ctx.link().batch_callback( + |event: MouseEvent| { + vec![ + Msg::PointerDown { pointer_id: None }, + Msg::PointerMove { client_x: event.client_x() }, + Msg::PointerUp + ] + } + ) + )} >
Component for Slider { )} ref={self.handle_ref.clone()} style={format!( - "left: calc({}% - 8px);", + "-webkit-touch-callout: none; left: calc({}% - 8px);", 100.0 * (index as f64) / (ctx.props().values.len() as f64 - 1.0), )} - onmousedown={ctx.link().batch_callback( - |event: MouseEvent| { - if event.buttons() == crate::MOUSE_EVENT_BUTTONS_PRIMARY { - vec![Msg::StartChange] - } else { - vec![] - } - })} onkeydown={ctx.link().callback(|event| Msg::Keyboard(event))} tabindex=0 >