// Copyright (c) 2011, the Dart project authors. Please see the AUTHORS file // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. // @dart = 2.9 part of touch; /** * Click buster implementation, which is a behavior that prevents native clicks * from firing at undesirable times. There are two scenarios where we may want * to 'bust' a click. * * Buttons implemented with touch events usually have click handlers as well. * This is because sometimes touch events stop working, and the click handler * serves as a fallback. Here we use a click buster to prevent the native click * from firing if the touchend event was successfully handled. * * When native scrolling behavior is disabled (see Scroller), click events will * fire after the touchend event when the drag sequence is complete. The click * event also happens to fire at the location of the touchstart event which can * lead to some very strange behavior. * * This class puts a single click handler on the body, and calls preventDefault * on the click event if we detect that there was a touchend event that already * fired in the same spot recently. */ class ClickBuster { /** * The threshold for how long we allow a click to occur after a touchstart. */ static const _TIME_THRESHOLD = 2500; /** * The threshold for how close a click has to be to the saved coordinate for * us to allow it. */ static const _DISTANCE_THRESHOLD = 25; /** * The list of coordinates that we use to measure the distance of clicks from. * If a click is within the distance threshold of any of these coordinates * then we allow the click. */ static DoubleLinkedQueue _coordinates; /** The last time preventGhostClick was called. */ static int _lastPreventedTime; /** * This handler will prevent the default behavior for any clicks unless the * click is within the distance threshold of one of the temporary allowed * coordinates. */ static void _onClick(Event e) { if (TimeUtil.now() - _lastPreventedTime > _TIME_THRESHOLD) { return; } final coord = new Coordinate.fromClient(e); // TODO(rnystrom): On Android, we get spurious click events at (0, 0). We // *do* want those clicks to be busted, so commenting this out fixes it. // Leaving it commented out instead of just deleting it because I'm not sure // what this code was intended to do to begin with. /* if (coord.x < 1 && coord.y < 1) { // TODO(jacobr): implement a configurable logging framework. // _logger.warning( // "Not busting click on label elem at(${coord.x}, ${coord.y})"); return; } */ DoubleLinkedQueueEntry entry = _coordinates.firstEntry(); while (entry != null) { if (_hitTest( entry.element, entry.nextEntry().element, coord.x, coord.y)) { entry.nextEntry().remove(); entry.remove(); return; } else { entry = entry.nextEntry().nextEntry(); } } // TODO(jacobr): implement a configurable logging framework. // _logger.warning("busting click at ${coord.x}, ${coord.y}"); e.stopPropagation(); e.preventDefault(); } /** * This handler will temporarily allow a click to occur near the touch event's * coordinates. */ static void _onTouchStart(Event e) { TouchEvent te = e; final coord = new Coordinate.fromClient(te.touches[0]); _coordinates.add(coord.x); _coordinates.add(coord.y); new Timer(const Duration(milliseconds: _TIME_THRESHOLD), () { _removeCoordinate(coord.x, coord.y); }); _toggleTapHighlights(true); } /** * Hit test for whether a coordinate is within the distance threshold of an * event. */ static bool _hitTest(num x, num y, num eventX, num eventY) { return (eventX - x).abs() < _DISTANCE_THRESHOLD && (eventY - y).abs() < _DISTANCE_THRESHOLD; } /** * Remove one specified coordinate from the coordinates list. */ static void _removeCoordinate(num x, num y) { DoubleLinkedQueueEntry entry = _coordinates.firstEntry(); while (entry != null) { if (entry.element == x && entry.nextEntry().element == y) { entry.nextEntry().remove(); entry.remove(); return; } else { entry = entry.nextEntry().nextEntry(); } } } /** * Enable or disable tap highlights. They are disabled when preventGhostClick * is called so that the flicker on links is not invoked when the ghost click * does fire. This is due to a bug: links get highlighted even if the click * event has preventDefault called on it. */ static void _toggleTapHighlights(bool enable) { document.body.style.setProperty( "-webkit-tap-highlight-color", enable ? "" : "rgba(0,0,0,0)", ""); } /** * Registers new touches to create temporary "allowable zones" and registers * new clicks to be prevented unless they fall in one of the current * "allowable zones". Note that if the touchstart and touchend locations are * different, it is still possible for a ghost click to be fired if you * called preventDefault on all touchmove events. In this case the ghost * click will be fired at the location of the touchstart event, so the * coordinate you pass in should be the coordinate of the touchstart. */ static void preventGhostClick(num x, num y) { // First time this is called the following occurs: // 1) Attaches a handler to touchstart events so that each touch will // temporarily create an "allowable zone" for clicks to occur in. // 2) Attaches a handler to click events so that each click will be // prevented unless it is in an "allowable zone". // // Every time this is called (including the first) the following occurs: // 1) Removes an allowable zone that contains the specified coordinate. // // How this enables click busting: // 1) User performs first click. // - No attached touchstart handler yet. // - preventGhostClick is called before the click event occurs, it // attaches the touchstart and click handlers. // - The click handler captures the user's click event and prevents it // from propagating since there is no "allowable zone". // // 2) User performs subsequent, to-be-busted click. // - touchstart event triggers the attached handler and creates a // temporary "allowable zone". // - preventGhostClick is called and removes the "allowable zone". // - The click handler captures the user's click event and prevents it // from propagating since there is no "allowable zone". // // 3) User performs a should-not-be-busted click. // - touchstart event triggers the attached handler and creates a // temporary "allowable zone". // - The click handler captures the user's click event and allows it to // propagate since the click falls in the "allowable zone". if (_coordinates == null) { // Listen to clicks on capture phase so they can be busted before anything // else gets a chance to handle them. Element.clickEvent.forTarget(document, useCapture: true).listen((e) { _onClick(e); }); Element.focusEvent.forTarget(document, useCapture: true).listen((e) { _lastPreventedTime = 0; }); // Listen to touchstart on capture phase since it must be called prior to // every click or else we will accidentally prevent the click even if we // don't call preventGhostClick. Function startFn = (e) { _onTouchStart(e); }; if (!Device.supportsTouch) { startFn = mouseToTouchCallback(startFn); } var stream; if (Device.supportsTouch) { stream = Element.touchStartEvent.forTarget(document, useCapture: true); } else { stream = Element.mouseDownEvent.forTarget(document, useCapture: true); } EventUtil.observe(document, stream, startFn, true); _coordinates = new DoubleLinkedQueue(); } // Turn tap highlights off until we know the ghost click has fired. _toggleTapHighlights(false); // Above all other rules, we won't bust any clicks if there wasn't some call // to preventGhostClick in the last time threshold. _lastPreventedTime = TimeUtil.now(); DoubleLinkedQueueEntry entry = _coordinates.firstEntry(); while (entry != null) { if (_hitTest(entry.element, entry.nextEntry().element, x, y)) { entry.nextEntry().remove(); entry.remove(); return; } else { entry = entry.nextEntry().nextEntry(); } } } }