Repository: mui/material-ui (TypeScript)
Date: 2018-10-21 09:37:29+00:00
Task type: Bug Fix

Issue:
[Slider] Not able for multi-touch, but native range slider is
With this example you can compare multi-touch behaviour of Material-UI Lab Sliders towards native range input sliders (you need to have a touch device availlable and at least two fingers)

PS: What I mean with range input slider is something like this:

```
      <input
          onChange={this.handleChange2Sim}
          type='range'
          min='0'
          max='100'
          value={this.state.val2sim}
        />
``` 

https://midi-sliders.herokuapp.com/test-page from:
https://github.com/TimSusa/midi-sliders/blob/master/react-ui/src/pages/TestPage.jsx

- The Lab Sliders (vertical) will go to overwriting each others
- The native sliders (horizontal) continue to work fine with multiple touches
- the hor/vert is only for prasentation purposes, multi-touch behaviour is not dependend on this

<img width="371" alt="screen shot 2018-09-09 at 18 29 11" src="https://user-images.githubusercontent.com/8963529/45266625-56252300-b45e-11e8-9874-40f5054be71b.png">

## Expected Behavior
The Lab Sliders should continue to work fine


## Current Behavior
The Lab Sliders will go to overwriting each others


## Steps to Reproduce
Try to change at least two sliders with two fingers. Play arround with stuff.

Link: https://midi-sliders.herokuapp.com/test-page
 Code Example: https://github.com/TimSusa/midi-sliders/blob/master/react-ui/src/pages/TestPage.jsx

## Context
Improve Multi-Touch Behaviour.

  "dependencies": {
    "@material-ui/core": "^1.5.1",
    "@material-ui/icons": "^1.1.1",
    "@material-ui/lab": "^1.0.0-alpha.11",


Issue discussion:

Solution patch:
diff --git a/packages/material-ui-lab/src/Slider/Slider.js b/packages/material-ui-lab/src/Slider/Slider.js
--- a/packages/material-ui-lab/src/Slider/Slider.js
+++ b/packages/material-ui-lab/src/Slider/Slider.js
@@ -177,12 +177,24 @@ function getOffset(node) {
   };
 }
 
-function getMousePosition(event) {
-  if (event.changedTouches && event.changedTouches[0]) {
-    return {
-      x: event.changedTouches[0].pageX,
-      y: event.changedTouches[0].pageY,
-    };
+function getMousePosition(event, touchId) {
+  if (event.changedTouches) {
+    // event.changedTouches.findIndex(touch => touch.identifier === touchId)
+    let touchIndex = 0;
+    for (let i = 0; i < event.changedTouches.length; i += 1) {
+      const touch = event.changedTouches[i];
+      if (touch.identifier === touchId) {
+        touchIndex = i;
+        break;
+      }
+    }
+
+    if (event.changedTouches[touchIndex]) {
+      return {
+        x: event.changedTouches[touchIndex].pageX,
+        y: event.changedTouches[touchIndex].pageY,
+      };
+    }
   }
 
   return {
@@ -191,10 +203,10 @@ function getMousePosition(event) {
   };
 }
 
-function calculatePercent(node, event, isVertical, isRtl) {
+function calculatePercent(node, event, isVertical, isRtl, touchId) {
   const { width, height } = node.getBoundingClientRect();
   const { bottom, left } = getOffset(node);
-  const { x, y } = getMousePosition(event);
+  const { x, y } = getMousePosition(event, touchId);
 
   const value = isVertical ? bottom - y : x - left;
   const onePercent = (isVertical ? height : width) / 100;
@@ -218,6 +230,8 @@ class Slider extends React.Component {
 
   jumpAnimationTimeoutId = -1;
 
+  touchId = undefined;
+
   componentDidMount() {
     if (this.containerRef) {
       this.containerRef.addEventListener('touchstart', preventPageScrolling, { passive: false });
@@ -299,7 +313,13 @@ class Slider extends React.Component {
 
   handleClick = event => {
     const { min, max, vertical } = this.props;
-    const percent = calculatePercent(this.containerRef, event, vertical, this.isReverted());
+    const percent = calculatePercent(
+      this.containerRef,
+      event,
+      vertical,
+      this.isReverted(),
+      this.touchId,
+    );
     const value = percentToValue(percent, min, max);
 
     this.emitChange(event, value, () => {
@@ -323,9 +343,13 @@ class Slider extends React.Component {
 
   handleTouchStart = event => {
     event.preventDefault();
+    const touch = event.changedTouches.item(0);
+    if (touch != null) {
+      this.touchId = touch.identifier;
+    }
     this.setState({ currentState: 'activated' });
 
-    document.body.addEventListener('touchend', this.handleMouseUp);
+    document.body.addEventListener('touchend', this.handleTouchEnd);
 
     if (typeof this.props.onDragStart === 'function') {
       this.props.onDragStart(event);
@@ -346,13 +370,47 @@ class Slider extends React.Component {
     }
   };
 
+  handleTouchEnd = event => {
+    if (this.touchId === undefined) {
+      this.handleMouseUp(event);
+    }
+
+    for (let i = 0; i < event.changedTouches.length; i += 1) {
+      const touch = event.changedTouches.item(i);
+      if (touch.identifier === this.touchId) {
+        this.handleMouseUp(event);
+        break;
+      }
+    }
+  };
+
   handleMouseUp = event => {
     this.handleDragEnd(event);
   };
 
+  handleTouchMove = event => {
+    if (this.touchId === undefined) {
+      this.handleMouseMove(event);
+    }
+
+    for (let i = 0; i < event.changedTouches.length; i += 1) {
+      const touch = event.changedTouches.item(i);
+      if (touch.identifier === this.touchId) {
+        this.handleMouseMove(event);
+        break;
+      }
+    }
+  };
+
   handleMouseMove = event => {
     const { min, max, vertical } = this.props;
-    const percent = calculatePercent(this.containerRef, event, vertical, this.isReverted());
+    const percent = calculatePercent(
+      this.containerRef,
+      event,
+      vertical,
+      this.isReverted(),
+      this.touchId,
+    );
     const value = percentToValue(percent, min, max);
 
     this.emitChange(event, value);
@@ -365,7 +423,7 @@ class Slider extends React.Component {
     document.body.removeEventListener('mouseleave', this.handleMouseLeave);
     document.body.removeEventListener('mousemove', this.handleMouseMove);
     document.body.removeEventListener('mouseup', this.handleMouseUp);
-    document.body.removeEventListener('touchend', this.handleMouseUp);
+    document.body.removeEventListener('touchend', this.handleTouchEnd);
 
     if (typeof this.props.onDragEnd === 'function') {
       this.props.onDragEnd(event);
@@ -513,7 +571,7 @@ class Slider extends React.Component {
         onClick={this.handleClick}
         onMouseDown={this.handleMouseDown}
         onTouchStartCapture={this.handleTouchStart}
-        onTouchMove={this.handleMouseMove}
+        onTouchMove={this.handleTouchMove}
         ref={ref => {
           this.containerRef = ReactDOM.findDOMNode(ref);
         }}
@@ -529,7 +587,7 @@ class Slider extends React.Component {
               onBlur={this.handleBlur}
               onKeyDown={this.handleKeyDown}
               onTouchStartCapture={this.handleTouchStart}
-              onTouchMove={this.handleMouseMove}
+              onTouchMove={this.handleTouchMove}
               onFocusVisible={this.handleFocus}
             >
               {ThumbIcon}


Test patch:
diff --git a/packages/material-ui-lab/src/Slider/Slider.test.js b/packages/material-ui-lab/src/Slider/Slider.test.js
--- a/packages/material-ui-lab/src/Slider/Slider.test.js
+++ b/packages/material-ui-lab/src/Slider/Slider.test.js
@@ -4,6 +4,11 @@ import { assert } from 'chai';
 import { createMount, createShallow, getClasses } from '@material-ui/core/test-utils';
 import Slider from './Slider';
 
+function touchList(touchArray) {
+  touchArray.item = idx => touchArray[idx];
+  return touchArray;
+}
+
 describe('<Slider />', () => {
   let mount;
   let shallow;
@@ -55,6 +60,47 @@ describe('<Slider />', () => {
     assert.strictEqual(handleDragEnd.callCount, 1, 'should have called the handleDragEnd cb');
   });
 
+  it('should only listen to changes from the same touchpoint', () => {
+    const handleChange = spy();
+    const handleDragStart = spy();
+    const handleDragEnd = spy();
+    let touchEvent;
+
+    const wrapper = mount(
+      <Slider
+        onChange={handleChange}
+        onDragStart={handleDragStart}
+        onDragEnd={handleDragEnd}
+        value={0}
+      />,
+    );
+
+    wrapper.simulate('touchstart', {
+      changedTouches: touchList([{ identifier: 1 }]),
+    });
+    wrapper.simulate('touchmove', {
+      changedTouches: touchList([{ identifier: 2 }]),
+    });
+    touchEvent = new window.MouseEvent('touchend');
+    touchEvent.changedTouches = touchList([{ identifier: 2 }]);
+    document.body.dispatchEvent(touchEvent);
+
+    assert.strictEqual(handleChange.callCount, 0, 'should not have called the handleChange cb');
+    assert.strictEqual(handleDragStart.callCount, 1, 'should have called the handleDragStart cb');
+    assert.strictEqual(handleDragEnd.callCount, 0, 'should not have called the handleDragEnd cb');
+
+    wrapper.simulate('touchmove', {
+      changedTouches: touchList([{ identifier: 1 }]),
+    });
+    touchEvent = new window.MouseEvent('touchend');
+    touchEvent.changedTouches = touchList([{ identifier: 1 }]);
+    document.body.dispatchEvent(touchEvent);
+
+    assert.strictEqual(handleChange.callCount, 1, 'should have called the handleChange cb');
+    assert.strictEqual(handleDragStart.callCount, 1, 'should have called the handleDragStart cb');
+    assert.strictEqual(handleDragEnd.callCount, 1, 'should have called the handleDragEnd cb');
+  });
+
   describe('when mouse leaves window', () => {
     it('should move to the end', () => {
       const handleChange = spy();