mui / material-ui

Material UI: Comprehensive React component library that implements Google's Material Design. Free forever.
https://mui.com/material-ui/
MIT License
93.9k stars 32.26k forks source link

[Slider] Don't move on touch move in opposite direction #20990

Open cjoecker opened 4 years ago

cjoecker commented 4 years ago

Summary 💡

When a slider is on a device with touch screen, the touch move event for scrolling shouldn't overlap the drag movement of the slider.

Therefore, it will be nice, if the slider drag movement don't get fired, if there is a touch movement in the opposite direction of the slider.

Examples 🌈

Actual Behavior: Example

cjoecker commented 4 years ago

I checked the code on the slider and I think I could contribute and implement it.

For that, I will need to change the drag behavior on touch. The slider will then fire handleTouchMove only if the getFingerNewValue passes a small threshold in the direction of the slider.

What do you think?

oliviertassinari commented 4 years ago

@cjoecker Solving this issue is far from trivial. However, it seems that we could replicate the behavior of a native slider input on Android. Meaning, if the user tries to scroll, starting the interaction from a slider, the value would change, but the dragging behavior won't trigger, but the touch move will be dedicated to the scrolling of the window.

We would need something in this order:

diff --git a/packages/material-ui/src/Slider/Slider.js b/packages/material-ui/src/Slider/Slider.js
index 7322770ea..66726975e 100644
--- a/packages/material-ui/src/Slider/Slider.js
+++ b/packages/material-ui/src/Slider/Slider.js
@@ -123,6 +123,10 @@ const axisProps = {
   },
 };

+// This value is closed to what browsers are using internally to
+// trigger a native scroll.
+const UNCERTAINTY_THRESHOLD = 3; // px
+
 const Identity = (x) => x;

 export const styles = (theme) => ({
@@ -135,7 +139,7 @@ export const styles = (theme) => ({
     display: 'inline-block',
     position: 'relative',
     cursor: 'pointer',
-    touchAction: 'none',
+    touchAction: 'pan-y',
     color: theme.palette.primary.main,
     WebkitTapHighlightColor: 'transparent',
     '&$disabled': {
@@ -144,6 +148,7 @@ export const styles = (theme) => ({
       color: theme.palette.grey[400],
     },
     '&$vertical': {
+      touchAction: 'pan-x',
       width: 2,
       height: '100%',
       padding: '0 13px',
@@ -395,10 +400,6 @@ const Slider = React.forwardRef(function Slider(props, ref) {
         }))
       : marksProp || [];

-  instanceRef.current = {
-    source: valueDerived, // Keep track of the input value to leverage immutable state comparison.
-  };
-
   const { isFocusVisible, onBlurVisible, ref: focusVisibleRef } = useIsFocusVisible();
   const [focusVisible, setFocusVisible] = React.useState(-1);

@@ -570,6 +571,36 @@ const Slider = React.forwardRef(function Slider(props, ref) {
       return;
     }

+    if (instanceRef.current.isSwiping === null) {
+      const dx = Math.abs(finger.x - instanceRef.current.startFinger.x);
+      const dy = Math.abs(finger.y - instanceRef.current.startFinger.y);
+
+      console.log({ dx, dy })
+
+      // We are likely to be swiping, let's prevent the scroll event on iOS.
+      if (orientation === 'horizontal' ? dx > dy : dy < dx) {
+        if (event.cancelable) {
+          console.log('preventDefault')
+          event.preventDefault();
+        }
+      }
+
+      const definitelySwiping = orientation === 'horizontal'
+      ? dx > dy && dx > UNCERTAINTY_THRESHOLD
+      : dy > dx && dy > UNCERTAINTY_THRESHOLD;
+
+      if (
+        definitelySwiping === true ||
+        (orientation === 'horizontal' ? dy > UNCERTAINTY_THRESHOLD : dx > UNCERTAINTY_THRESHOLD)
+      ) {
+        instanceRef.current.isSwiping = definitelySwiping;
+      }
+    }
+
+    if (!instanceRef.current.isSwiping) {
+      return;
+    }
+
     const { newValue, activeIndex } = getFingerNewValue({
       finger,
       move: true,
@@ -608,13 +639,14 @@ const Slider = React.forwardRef(function Slider(props, ref) {
     const doc = ownerDocument(sliderRef.current);
     doc.removeEventListener('mousemove', handleTouchMove);
     doc.removeEventListener('mouseup', handleTouchEnd);
-    doc.removeEventListener('touchmove', handleTouchMove);
+    doc.removeEventListener('touchmove', handleTouchMove, { passive: false });
     doc.removeEventListener('touchend', handleTouchEnd);
   });

   const handleTouchStart = useEventCallback((event) => {
-    // Workaround as Safari has partial support for touchAction: 'none'.
-    event.preventDefault();
+    // // Workaround as Safari has partial support for touchAction: 'none'.
+    // event.preventDefault();
+
     const touch = event.changedTouches[0];
     if (touch != null) {
       // A number that uniquely identifies the current finger in the touch session.
@@ -624,6 +656,11 @@ const Slider = React.forwardRef(function Slider(props, ref) {
     const { newValue, activeIndex } = getFingerNewValue({ finger, values, source: valueDerived });
     focusThumb({ sliderRef, activeIndex, setActive });

+    instanceRef.current = {
+      startFinger: finger,
+      isSwiping: null,
+    };
+
     setValueState(newValue);

     if (onChange) {

On iOS, the tradeoff is different. A touch start on the slider's rail doesn't trigger it.

cjoecker commented 4 years ago

Wow, that was fast @oliviertassinari !

It looks good to me. Are there plans to implement this in the next update?

oliviertassinari commented 4 years ago

@cjoecker So you confirm that the behavior of a native slider on Android/Chrome, is an acceptable tradeoff?

cjoecker commented 4 years ago

@oliviertassinari: you mean by

the value would change, but the dragging behavior won't trigger

that onChange will be fired but the value sent by the event will remain the same while scrolling?

oliviertassinari commented 4 years ago

@cjoecker Yes, the slider value changes at the touch start and then ignored if the end-user scrolls in the opposite direction of the slider.

cjoecker commented 4 years ago

@oliviertassinari is good for me, thanks! Much better user experience for less developer experience is a good trad-off 😉

oliviertassinari commented 4 years ago

@cjoecker Ok cool, well, I think that the tradeoff is purely for end-users. The options:

Sounds great.

eps1lon commented 4 years ago

We should be very careful with custom implementations of "pan-detection". They are very hard to get right.

It might be more fruitful to check how cross-browser support for pointer-events related features is. If it matches our supported platforms we can fix this feature with minimal effort: https://codesandbox.io/s/pointer-events-are-garbage-h2p0l?file=/src/index.js

oliviertassinari commented 4 years ago

@eps1lon Nice demo, it's the first time I see the API in use. It also seems to behavior work fine on iOS (even without touch-action: pan-y; support). Regarding the platform support, we have https://caniuse.com/#feat=pointer. I'm not sure it's good enough yet.

eps1lon commented 4 years ago

Regarding the platform support, we have caniuse.com/#feat=pointer. I'm not sure it's good enough yet.

This looks fine for v5 to me.

I wouldn't spend time on this in v4 since it's not trivial to detect pan-intention. For v5 we can "use the platform".

VasilyVP commented 1 year ago

It seems in v5 it does not work as expected on touch screens. Is there plan to fix it?