diff --git a/src/ui/handler/drag_pan.js b/src/ui/handler/drag_pan.js index e39b0346e91..8cc618e5b95 100644 --- a/src/ui/handler/drag_pan.js +++ b/src/ui/handler/drag_pan.js @@ -28,10 +28,13 @@ class DragPanHandler { _mouseDownPos: Point; _prevPos: Point; _lastPos: Point; + _startTouch: ?Array; + _lastTouch: ?Array; _lastMoveEvent: MouseEvent | TouchEvent | void; _inertia: Array<[number, Point]>; _frameId: ?TaskID; _clickTolerance: number; + _shouldStart: ?boolean; /** * @private @@ -126,8 +129,12 @@ class DragPanHandler { } onTouchStart(e: TouchEvent) { - if (this._state !== 'enabled') return; - if (e.touches.length > 1) return; + if (!this.isEnabled()) return; + if (e.touches && e.touches.length > 1) { // multi-finger touch + // If we are already dragging (e.g. with one finger) and add another finger, + // keep the handler active but don't attempt to ._start() again + if (this._state === 'pending' || this._state === 'active') return; + } // Bind window-level event listeners for touchmove/end events. In the absence of // the pointer capture API, which is not supported by all necessary platforms, @@ -147,28 +154,36 @@ class DragPanHandler { this._state = 'pending'; this._startPos = this._mouseDownPos = this._prevPos = this._lastPos = DOM.mousePos(this._el, e); + this._startTouch = this._lastTouch = e instanceof window.TouchEvent ? DOM.touchPos(this._el, e) : null; this._inertia = [[browser.now(), this._startPos]]; } + _touchesMatch(lastTouch: ?Array, thisTouch: ?Array) { + if (!lastTouch || !thisTouch || lastTouch.length !== thisTouch.length) return false; + return lastTouch.every((pos, i) => thisTouch[i] === pos); + } + _onMove(e: MouseEvent | TouchEvent) { e.preventDefault(); + const touchPos = e instanceof window.TouchEvent ? DOM.touchPos(this._el, e) : null; const pos = DOM.mousePos(this._el, e); - if (this._lastPos.equals(pos) || (this._state === 'pending' && pos.dist(this._mouseDownPos) < this._clickTolerance)) { + + const matchesLastPos = touchPos ? this._touchesMatch(this._lastTouch, touchPos) : this._lastPos.equals(pos); + + if (matchesLastPos || (this._state === 'pending' && pos.dist(this._mouseDownPos) < this._clickTolerance)) { return; } this._lastMoveEvent = e; this._lastPos = pos; + this._lastTouch = touchPos; this._drainInertiaBuffer(); this._inertia.push([browser.now(), this._lastPos]); if (this._state === 'pending') { - // we treat the first move event (rather than the mousedown event) - // as the start of the drag this._state = 'active'; - this._fireEvent('dragstart', e); - this._fireEvent('movestart', e); + this._shouldStart = true; } if (!this._frameId) { @@ -185,6 +200,22 @@ class DragPanHandler { const e = this._lastMoveEvent; if (!e) return; + + if (this._map.touchZoomRotate.isActive()) { + this._abort(e); + return; + } + + if (this._shouldStart) { + // we treat the first drag frame (rather than the mousedown event) + // as the start of the drag + this._fireEvent('dragstart', e); + this._fireEvent('movestart', e); + this._shouldStart = false; + } + + if (!this.isActive()) return; // It's possible for the dragstart event to trigger a disable() call (#2419) so we must account for that + const tr = this._map.transform; tr.setLocationAtPoint(tr.pointLocation(this._prevPos), this._lastPos); this._fireEvent('drag', e); @@ -215,42 +246,76 @@ class DragPanHandler { } _onTouchEnd(e: TouchEvent) { - switch (this._state) { - case 'active': - this._state = 'enabled'; - this._unbind(); - this._deactivate(); - this._inertialPan(e); - break; - case 'pending': - this._state = 'enabled'; - this._unbind(); - break; - default: - assert(false); - break; + if (!e.touches || e.touches.length === 0) { // only stop drag if all fingers have been removed + switch (this._state) { + case 'active': + this._state = 'enabled'; + this._unbind(); + this._deactivate(); + this._inertialPan(e); + break; + case 'pending': + this._state = 'enabled'; + this._unbind(); + break; + case 'enabled': + this._unbind(); + break; + default: + assert(false); + break; + } + } else { // some finger(s) still touching the screen + switch (this._state) { + case 'pending': + case 'active': + // we are already dragging; continue + break; + case 'enabled': + // not currently dragging; get ready to start a new drag + this.onTouchStart(e); + break; + default: + assert(false); + break; + } } } - _onBlur(e: FocusEvent) { + _abort(e: FocusEvent | MouseEvent | TouchEvent) { switch (this._state) { case 'active': this._state = 'enabled'; + if (!this._shouldStart) { // If we scheduled the dragstart but never fired, nothing to end + // We already started the drag, end it + this._fireEvent('dragend', e); + this._fireEvent('moveend', e); + } this._unbind(); this._deactivate(); - this._fireEvent('dragend', e); - this._fireEvent('moveend', e); + if (e instanceof window.TouchEvent && e.touches.length > 1) { + // If there are multiple fingers touching, reattach touchend listener in case + // all but one finger is removed and we need to restart a drag on touchend + DOM.addEventListener(window.document, 'touchend', this._onTouchEnd); + } break; case 'pending': this._state = 'enabled'; this._unbind(); break; + case 'enabled': + this._unbind(); + break; default: assert(false); break; } } + _onBlur(e: FocusEvent) { + this._abort(e); + } + _unbind() { DOM.removeEventListener(window.document, 'touchmove', this._onMove, {capture: true, passive: false}); DOM.removeEventListener(window.document, 'touchend', this._onTouchEnd); @@ -269,6 +334,9 @@ class DragPanHandler { delete this._prevPos; delete this._mouseDownPos; delete this._lastPos; + delete this._startTouch; + delete this._lastTouch; + delete this._shouldStart; } _inertialPan(e: MouseEvent | TouchEvent) { diff --git a/src/ui/handler/touch_zoom_rotate.js b/src/ui/handler/touch_zoom_rotate.js index 0f2ab959d81..404721c30d1 100644 --- a/src/ui/handler/touch_zoom_rotate.js +++ b/src/ui/handler/touch_zoom_rotate.js @@ -112,6 +112,16 @@ class TouchZoomRotateHandler { this._rotationDisabled = false; } + /** + * Returns true if the handler is enabled and has detected the start of a zoom/rotate gesture. + * + * @returns {boolean} + * @memberof TouchZoomRotateHandler + */ + isActive(): boolean { + return this.isEnabled() && !!this._gestureIntent; + } + onStart(e: TouchEvent) { if (!this.isEnabled()) return; if (e.touches.length !== 2) return; @@ -197,7 +207,6 @@ class TouchZoomRotateHandler { } tr.zoom = tr.scaleZoom(this._startScale * scale); - tr.setLocationAtPoint(this._startAround, aroundPoint); this._map.fire(new Event(gestureIntent, {originalEvent: this._lastTouchEvent})); diff --git a/test/unit/ui/handler/drag_pan.test.js b/test/unit/ui/handler/drag_pan.test.js index 34cd2e515ac..62aeba5bf2b 100644 --- a/test/unit/ui/handler/drag_pan.test.js +++ b/test/unit/ui/handler/drag_pan.test.js @@ -741,3 +741,197 @@ test('DragPanHandler does not begin a touch drag if moved less than click tolera map.remove(); t.end(); }); + +test('DragPanHandler does not begin a touch drag on multi-finger touch event if zooming', (t) => { + const map = createMap(t); + + const dragstart = t.spy(); + const drag = t.spy(); + const dragend = t.spy(); + + map.on('dragstart', dragstart); + map.on('drag', drag); + map.on('dragend', dragend); + + simulate.touchstart(map.getCanvas(), {touches: [{clientX: 10, clientY: 0}, {clientX: 20, clientY: 0}]}); + map._renderTaskQueue.run(); + t.equal(dragstart.callCount, 0); + t.equal(drag.callCount, 0); + t.equal(dragend.callCount, 0); + + simulate.touchmove(map.getCanvas(), {touches: [{clientX: 5, clientY: 10}, {clientX: 25, clientY: 10}]}); + map._renderTaskQueue.run(); + t.equal(dragstart.callCount, 0); + t.equal(drag.callCount, 0); + t.equal(dragend.callCount, 0); + + simulate.touchend(map.getCanvas()); + map._renderTaskQueue.run(); + t.equal(dragstart.callCount, 0); + t.equal(drag.callCount, 0); + t.equal(dragend.callCount, 0); + + map.remove(); + t.end(); +}); + +test('DragPanHandler starts a drag on a multi-finger no-zoom touch, and continues if it becomes a single-finger touch', (t) => { + const map = createMap(t); + + const dragstart = t.spy(); + map.on('dragstart', dragstart); + + const drag = t.spy(); + map.on('drag', drag); + + simulate.touchstart(map.getCanvas(), {touches: [{clientX: 20, clientY: 20}, {clientX: 30, clientY: 30}]}); + map._renderTaskQueue.run(); + t.notOk(map.dragPan.isActive()); + t.equals(map.dragPan._state, 'pending'); + t.notOk(dragstart.called); + t.notOk(drag.called); + + simulate.touchmove(map.getCanvas(), {touches: [{clientX: 10, clientY: 10}, {clientX: 20, clientY: 20}]}); + map._renderTaskQueue.run(); + t.ok(map.dragPan.isActive()); + t.equal(dragstart.callCount, 1); + t.equal(drag.callCount, 1); + + simulate.touchend(map.getCanvas(), {touches: [{clientX: 10, clientY: 10}]}); + map._renderTaskQueue.run(); + t.ok(map.dragPan.isActive()); + t.equal(dragstart.callCount, 1); + t.equal(drag.callCount, 1); + + simulate.touchmove(map.getCanvas(), {touches: [{clientX: 0, clientY: 0}]}); + map._renderTaskQueue.run(); + t.ok(map.dragPan.isActive()); + t.equal(dragstart.callCount, 1); + t.equal(drag.callCount, 2); + + map.remove(); + t.end(); +}); + +test('DragPanHandler stops/starts touch-triggered drag appropriately when transitioning between single- and multi-finger touch', (t) => { + const map = createMap(t); + + const dragstart = t.spy(); + const drag = t.spy(); + const dragend = t.spy(); + + map.on('dragstart', dragstart); + map.on('drag', drag); + map.on('dragend', dragend); + + // Single-finger touch starts drag + simulate.touchstart(map.getCanvas(), {touches: [{clientX: 10, clientY: 10}]}); + map._renderTaskQueue.run(); + t.notOk(map.dragPan.isActive()); + t.equal(dragstart.callCount, 0); + t.equal(drag.callCount, 0); + t.equal(dragend.callCount, 0); + + simulate.touchmove(map.getCanvas(), {touches: [{clientX: 20, clientY: 20}]}); + map._renderTaskQueue.run(); + t.ok(map.dragPan.isActive()); + t.equal(dragstart.callCount, 1); + t.equal(drag.callCount, 1); + t.equal(dragend.callCount, 0); + + // Adding a second finger and panning (without zoom/rotate) continues the drag + simulate.touchstart(map.getCanvas(), {touches: [{clientX: 10, clientY: 10}, {clientX: 20, clientY: 20}]}); + map._renderTaskQueue.run(); + t.ok(map.dragPan.isActive()); + t.equal(dragstart.callCount, 1); + t.equal(drag.callCount, 1); + t.equal(dragend.callCount, 0); + + simulate.touchmove(map.getCanvas(), {touches: [{clientX: 10, clientY: 20}, {clientX: 20, clientY: 30}]}); + map._renderTaskQueue.run(); + t.ok(map.dragPan.isActive()); + t.equal(dragstart.callCount, 1); + t.equal(drag.callCount, 2); + t.equal(dragend.callCount, 0); + + // Starting a two-finger zoom/rotate stops drag (will trigger touchZoomRotate instead) + simulate.touchmove(map.getCanvas(), {touches: [{clientX: 10, clientY: 20}, {clientX: 30, clientY: 30}]}); + map._renderTaskQueue.run(); + t.notOk(map.dragPan.isActive()); + t.equal(dragstart.callCount, 1); + t.equal(drag.callCount, 2); + t.equal(dragend.callCount, 1); + + // Continuing to pan with two fingers does not start a drag (handled by touchZoomRotate instead) + simulate.touchmove(map.getCanvas(), {touches: [{clientX: 10, clientY: 10}, {clientX: 30, clientY: 20}]}); + map._renderTaskQueue.run(); + t.notOk(map.dragPan.isActive()); + t.equal(dragstart.callCount, 1); + t.equal(drag.callCount, 2); + t.equal(dragend.callCount, 1); + + // Removing all but one finger starts another drag + simulate.touchend(map.getCanvas(), {touches: [{clientX: 30, clientY: 20}]}); + map._renderTaskQueue.run(); + t.notOk(map.dragPan.isActive()); + t.equal(dragstart.callCount, 1); + t.equal(drag.callCount, 2); + t.equal(dragend.callCount, 1); + + simulate.touchmove(map.getCanvas(), {touches: [{clientX: 20, clientY: 20}]}); + map._renderTaskQueue.run(); + t.ok(map.dragPan.isActive()); + t.equal(dragstart.callCount, 2); + t.equal(drag.callCount, 3); + t.equal(dragend.callCount, 1); + + // Removing last finger stops drag + simulate.touchend(map.getCanvas()); + map._renderTaskQueue.run(); + t.notOk(map.dragPan.isActive()); + t.equal(dragstart.callCount, 2); + t.equal(drag.callCount, 3); + t.equal(dragend.callCount, 2); + + map.remove(); + t.end(); +}); + +test('DragPanHandler fires dragstart, drag, dragend events in response to multi-touch pan', (t) => { + const map = createMap(t); + + const dragstart = t.spy(); + const drag = t.spy(); + const dragend = t.spy(); + + map.on('dragstart', dragstart); + map.on('drag', drag); + map.on('dragend', dragend); + + simulate.touchstart(map.getCanvas(), {touches: [{clientX: 0, clientY: 0}, {clientX: 5, clientY: 0}]}); + map._renderTaskQueue.run(); + t.equal(dragstart.callCount, 0); + t.equal(drag.callCount, 0); + t.equal(dragend.callCount, 0); + + simulate.touchmove(map.getCanvas(), {touches: [{clientX: 0, clientY: 10}, {clientX: 5, clientY: 10}]}); + map._renderTaskQueue.run(); + t.equal(dragstart.callCount, 1); + t.equal(drag.callCount, 1); + t.equal(dragend.callCount, 0); + + simulate.touchmove(map.getCanvas(), {touches: [{clientX: 0, clientY: 5}, {clientX: 5, clientY: 5}]}); + map._renderTaskQueue.run(); + t.equal(dragstart.callCount, 1); + t.equal(drag.callCount, 2); + t.equal(dragend.callCount, 0); + + simulate.touchend(map.getCanvas(), {touches: []}); + map._renderTaskQueue.run(); + t.equal(dragstart.callCount, 1); + t.equal(drag.callCount, 2); + t.equal(dragend.callCount, 1); + + map.remove(); + t.end(); +}); diff --git a/test/util/simulate_interaction.js b/test/util/simulate_interaction.js index b85479ae7f8..b61602403df 100644 --- a/test/util/simulate_interaction.js +++ b/test/util/simulate_interaction.js @@ -63,7 +63,8 @@ events.magicWheelZoomDelta = 4.000244140625; [ 'touchstart', 'touchend', 'touchmove', 'touchcancel' ].forEach((event) => { events[event] = function (target, options) { // Should be using Touch constructor here, but https://github.com/jsdom/jsdom/issues/2152. - options = Object.assign({bubbles: true, touches: [{clientX: 0, clientY: 0}]}, options); + const defaultTouches = event.endsWith('end') || event.endsWith('cancel') ? [] : [{clientX: 0, clientY: 0}]; + options = Object.assign({bubbles: true, touches: defaultTouches}, options); const TouchEvent = window(target).TouchEvent; target.dispatchEvent(new TouchEvent(event, options)); };