From d0047cda446adf1f30e55b056e83bb31ac5c6061 Mon Sep 17 00:00:00 2001 From: Adam Bradley Date: Thu, 17 Apr 2014 08:26:25 -0500 Subject: [PATCH] refactor(tap): Refactor tap system for improved tap/click/keyboard/scroll/focus Overhaul of the tap system so the keyboard does not cover up focused inputs, correctly bring up the keyboard on text input focus, disabling focus during scroll, disabling clicks after a hold then scroll, removing 300ms delay without additional event handlers on each element, etc. Refactored the tap/click/scroll/activator events for more testability, along with adding more tests. --- js/angular/directive/navBackButton.js | 9 +- js/angular/directive/ngClick.js | 43 -- js/angular/directive/tabNav.js | 8 +- js/angular/directive/touch.js | 65 -- js/utils/activator.js | 64 +- js/utils/keyboard.js | 109 +-- js/utils/tap.js | 663 +++++++++++-------- js/views/scrollView.js | 568 ++++++++++------ scss/_scaffolding.scss | 9 +- test/html/clickTests2.html | 346 ++++++++++ test/html/input.html | 138 ++-- test/html/sideMenu2.html | 6 +- test/unit/angular/service/tap.unit.js | 920 +++++++++++++++++++++----- 13 files changed, 2054 insertions(+), 894 deletions(-) delete mode 100644 js/angular/directive/touch.js create mode 100644 test/html/clickTests2.html diff --git a/js/angular/directive/navBackButton.js b/js/angular/directive/navBackButton.js index 267ebf5e43..74385c4078 100644 --- a/js/angular/directive/navBackButton.js +++ b/js/angular/directive/navBackButton.js @@ -62,9 +62,8 @@ */ IonicModule .directive('ionNavBackButton', [ - '$ionicNgClick', '$animate', -function($ionicNgClick, $animate) { +function($animate) { return { restrict: 'E', require: '^ionNavBar', @@ -73,7 +72,11 @@ function($ionicNgClick, $animate) { return function($scope, $element, $attr, navBarCtrl) { if (!$attr.ngClick) { $scope.$navBack = navBarCtrl.back; - $ionicNgClick($scope, $element, '$navBack($event)'); + $element.on('click', function(event){ + $scope.$apply(function() { + $scope.$navBack(event); + }); + }); } //If the current viewstate does not allow a back button, diff --git a/js/angular/directive/ngClick.js b/js/angular/directive/ngClick.js index d1f63913b5..c84f66880f 100644 --- a/js/angular/directive/ngClick.js +++ b/js/angular/directive/ngClick.js @@ -3,49 +3,6 @@ IonicModule -.config(['$provide', function($provide) { - $provide.decorator('ngClickDirective', ['$delegate', function($delegate) { - // drop the default ngClick directive - $delegate.shift(); - return $delegate; - }]); -}]) - -/** - * @private - */ -.factory('$ionicNgClick', ['$parse', function($parse) { - function onRelease(e) { - // wire this up to Ionic's tap/click simulation - ionic.tap.simulateClick(e.target, e); - } - return function(scope, element, clickExpr) { - var clickHandler = $parse(clickExpr); - - element.on('click', function(event) { - scope.$apply(function() { - clickHandler(scope, {$event: (event)}); - }); - }); - - ionic.on("release", onRelease, element[0]); - - // Hack for iOS Safari's benefit. It goes searching for onclick handlers and is liable to click - // something else nearby. - element.onclick = function(event) { }; - - scope.$on('$destroy', function () { - ionic.off("release", onRelease, element[0]); - }); - }; -}]) - -.directive('ngClick', ['$ionicNgClick', function($ionicNgClick) { - return function(scope, element, attr) { - $ionicNgClick(scope, element, attr.ngClick); - }; -}]) - .directive('ionStopEvent', function () { function stopEvent(e) { e.stopPropagation(); diff --git a/js/angular/directive/tabNav.js b/js/angular/directive/tabNav.js index c80792e583..e49880a203 100644 --- a/js/angular/directive/tabNav.js +++ b/js/angular/directive/tabNav.js @@ -1,5 +1,5 @@ IonicModule -.directive('ionTabNav', ['$ionicNgClick', function($ionicNgClick) { +.directive('ionTabNav', [function() { return { restrict: 'E', replace: true, @@ -33,7 +33,11 @@ IonicModule tabsCtrl.select(tabCtrl.$scope, true); }; if (!$attrs.ngClick) { - $ionicNgClick($scope, $element, 'selectTab($event)'); + $element.on('click', function(event) { + $scope.$apply(function() { + $scope.selectTab(event); + }); + }); } $scope.getIconOn = function() { diff --git a/js/angular/directive/touch.js b/js/angular/directive/touch.js deleted file mode 100644 index bff629e603..0000000000 --- a/js/angular/directive/touch.js +++ /dev/null @@ -1,65 +0,0 @@ - -// Similar to Angular's ngTouch, however it uses Ionic's tap detection -// and click simulation. ngClick - -(function(angular, ionic) {'use strict'; - -angular.module('ionic.ui.touch', []) - - .config(['$provide', function($provide) { - $provide.decorator('ngClickDirective', ['$delegate', function($delegate) { - // drop the default ngClick directive - $delegate.shift(); - return $delegate; - }]); - }]) - - /** - * @private - */ - .factory('$ionicNgClick', ['$parse', function($parse) { - function onRelease(e) { - // wire this up to Ionic's tap/click simulation - ionic.tap.simulateClick(e.target, e); - } - return function(scope, element, clickExpr) { - var clickHandler = $parse(clickExpr); - - element.on('click', function(event) { - scope.$apply(function() { - clickHandler(scope, {$event: (event)}); - }); - }); - - ionic.on("release", onRelease, element[0]); - - // Hack for iOS Safari's benefit. It goes searching for onclick handlers and is liable to click - // something else nearby. - element.onclick = function(event) { }; - - scope.$on('$destroy', function () { - ionic.off("release", onRelease, element[0]); - }); - }; - }]) - - .directive('ngClick', ['$ionicNgClick', function($ionicNgClick) { - return function(scope, element, attr) { - $ionicNgClick(scope, element, attr.ngClick); - }; - }]) - - .directive('ionStopEvent', function () { - function stopEvent(e) { - e.stopPropagation(); - } - return { - restrict: 'A', - link: function (scope, element, attr) { - element.bind(attr.ionStopEvent, stopEvent); - } - }; - }); - - -})(window.angular, window.ionic); diff --git a/js/utils/activator.js b/js/utils/activator.js index 10e46d9299..c478c5b1c9 100644 --- a/js/utils/activator.js +++ b/js/utils/activator.js @@ -5,13 +5,10 @@ var activeElements = {}; // elements that are currently active var keyId = 0; // a counter for unique keys for the above ojects var ACTIVATED_CLASS = 'activated'; - var touchMoveClearTimer; ionic.activator = { start: function(e) { - clearTimeout(touchMoveClearTimer); - // when an element is touched/clicked, it climbs up a few // parents to see if it is an .item or .button element ionic.requestAnimationFrame(function(){ @@ -19,12 +16,12 @@ var eleToActivate; for(var x=0; x<4; x++) { - if(!ele) break; + if(!ele || ele.nodeType !== 1) break; if(eleToActivate && ele.classList.contains('item')) { eleToActivate = ele; break; } - if( ele.tagName == 'A' || ele.tagName == 'BUTTON' || ele.getAttribute('ng-click') ) { + if( ele.tagName == 'A' || ele.tagName == 'BUTTON' || ele.hasAttribute('ng-click') ) { eleToActivate = ele; } if( ele.classList.contains('button') ) { @@ -39,15 +36,9 @@ queueElements[keyId] = eleToActivate; // in XX milliseconds, set the queued elements to active - // add listeners to clear all queued/active elements onMove if(e.type === 'touchstart') { - document.body.removeEventListener('mousedown', ionic.activator.start); - touchMoveClearTimer = setTimeout(function(){ - document.body.addEventListener('touchmove', onTouchMove, false); - }, 80); setTimeout(activateElements, 80); } else { - document.body.addEventListener('mousemove', clear, false); ionic.requestAnimationFrame(activateElements); } @@ -55,9 +46,23 @@ } }); + }, + + end: function() { + // clear out any active/queued elements after XX milliseconds + setTimeout(clear, 200); } + }; + function clear() { + // clear out any elements that are queued to be set to active + queueElements = {}; + + // in the next frame, remove the active class from all active elements + ionic.requestAnimationFrame(deactivateElements); + } + function activateElements() { // activate all elements in the queue for(var key in queueElements) { @@ -78,41 +83,4 @@ } } - function onTouchMove(e) { - if( ionic.tap.hasTouchScrolled(e) ) { - clear(); - } - } - - function onEnd(e) { - // clear out any active/queued elements after XX milliseconds - setTimeout(clear, 200); - } - - function clear() { - clearTimeout(touchMoveClearTimer); - - // clear out any elements that are queued to be set to active - queueElements = {}; - - // in the next frame, remove the active class from all active elements - ionic.requestAnimationFrame(deactivateElements); - - // remove onMove listeners that clear out active elements - document.body.removeEventListener('mousemove', clear); - document.body.removeEventListener('touchmove', clear); - } - - // use window.onload because this doesn't need to run immediately - window.addEventListener('load', function(){ - // start an active element - document.body.addEventListener('touchstart', ionic.activator.start, false); - document.body.addEventListener('mousedown', ionic.activator.start, false); - - // clear all active elements after XX milliseconds - document.body.addEventListener('touchend', onEnd, false); - document.body.addEventListener('mouseup', onEnd, false); - document.body.addEventListener('touchcancel', onEnd, false); - }, false); - })(document, ionic); diff --git a/js/utils/keyboard.js b/js/utils/keyboard.js index 491e0f22bd..77e16e9b18 100644 --- a/js/utils/keyboard.js +++ b/js/utils/keyboard.js @@ -1,51 +1,86 @@ (function(ionic) { ionic.Platform.ready(function() { - if (ionic.Platform.is('android')) { - androidKeyboardFix(); - } -}); - -function androidKeyboardFix() { var rememberedDeviceWidth = window.innerWidth; var rememberedDeviceHeight = window.innerHeight; var keyboardHeight; + var rememberedActiveEl; + var alreadyOpen = false; - window.addEventListener('resize', resize); - - function resize() { - - //If the width of the window changes, we have an orientation change - if (rememberedDeviceWidth !== window.innerWidth) { - rememberedDeviceWidth = window.innerWidth; - rememberedDeviceHeight = window.innerHeight; - console.info('orientation change. deviceWidth =', rememberedDeviceWidth, ', deviceHeight =', rememberedDeviceHeight); - - //If the height changes, and it's less than before, we have a keyboard open - } else if (rememberedDeviceHeight !== window.innerHeight && - window.innerHeight < rememberedDeviceHeight) { - document.body.classList.add('footer-hide'); - //Wait for next frame so document.activeElement is set - ionic.requestAnimationFrame(handleKeyboardChange); - } else { - //Otherwise we have a keyboard close or a *really* weird resize - document.body.classList.remove('footer-hide'); + if(ionic.Platform.isWebView() && window.cordova && cordova.plugins && cordova.plugins.Keyboard) { + if (ionic.Platform.isIOS()) { + window.addEventListener('focusin', onBrowserFocusIn); } + window.addEventListener('native.showkeyboard', onNativeKeyboardShow); + window.addEventListener('native.hidekeyboard', onNativeKeyboardHide); - function handleKeyboardChange() { - //keyboard opens - keyboardHeight = rememberedDeviceHeight - window.innerHeight; - var activeEl = document.activeElement; - if (activeEl) { - //This event is caught by the nearest parent scrollView - //of the activeElement - ionic.trigger('scrollChildIntoView', { - target: activeEl - }, true); - } + } else if (ionic.Platform.isAndroid()){ + window.addEventListener('resize', onBrowserResize); + window.addEventListener('focusin', onBrowserFocusIn); + } + function onBrowserFocusIn(e) { + if(e.srcElement.tagName == 'INPUT' || e.srcElement.tagName == 'TEXTAREA' || e.srcElement.isContentEditable) { + //stop browser from scrolling the whole frame, use virtual scroll instead + document.body.scrollTop = 0; } } -} + + function onBrowserResize() { + if(rememberedDeviceWidth !== window.innerWidth) { + // If the width of the window changes, we have an orientation change + rememberedDeviceWidth = window.innerWidth; + rememberedDeviceHeight = window.innerHeight; + + } else if(rememberedDeviceHeight !== window.innerHeight && + window.innerHeight < rememberedDeviceHeight) { + // If the height changes, and it's less than before, we have a keyboard open + document.body.classList.add('keyboard-open'); + + keyboardHeight = rememberedDeviceHeight - window.innerHeight; + setTimeout(function() { + ionic.trigger('scrollChildIntoView', { + target: document.activeElement + }, true); + }, 100); + + } else { + // Otherwise we have a keyboard close or a *really* weird resize + document.body.classList.remove('keyboard-open'); + } + } + + function onNativeKeyboardShow(e) { + rememberedActiveEl = document.activeElement; + if(rememberedActiveEl) { + // This event is caught by the nearest parent scrollView + // of the activeElement + if(cordova.plugins.Keyboard.isVisible) { + document.body.classList.add('keyboard-open'); + ionic.trigger('scrollChildIntoView', { + keyboardHeight: e.keyboardHeight, + target: rememberedActiveEl, + firstKeyboardShow: !alreadyOpen + }, true); + + if(!alreadyOpen) alreadyOpen = true; + } + } + } + + function onNativeKeyboardHide() { + // wait to see if we're just switching inputs + setTimeout(function() { + if(!cordova.plugins.Keyboard.isVisible) { + document.body.classList.remove('keyboard-open'); + alreadyOpen = false; + ionic.trigger('resetScrollView', { + target: rememberedActiveEl + }, true); + } + }, 100); + } + +}); })(window.ionic); diff --git a/js/utils/tap.js b/js/utils/tap.js index a8720aa6ca..2e81c84105 100644 --- a/js/utils/tap.js +++ b/js/utils/tap.js @@ -1,305 +1,438 @@ -(function(window, document, ionic) { - 'use strict'; - var CLICK_PREVENT_DURATION = 1500; // max milliseconds ghostclicks in the same area should be prevented - var REMOVE_PREVENT_DELAY = 380; // delay after a touchend/mouseup before removing the ghostclick prevent - var REMOVE_PREVENT_DELAY_GRADE_C = 800; // same as REMOVE_PREVENT_DELAY, but for grade c devices - var HIT_RADIUS = 15; // surrounding area of a click that if a ghostclick happens it would get ignored - var TOUCH_TOLERANCE_X = 10; // how much the X coordinates can be off between start/end, but still a click - var TOUCH_TOLERANCE_Y = 6; // how much the Y coordinates can be off between start/end, but still a click - var tapCoordinates = {}; // used to remember coordinates to ignore if they happen again quickly - var startCoordinates = {}; // used to remember where the coordinates of the start of a touch - var clickPreventTimerId; - var _hasTouchScrolled = false; // if the touchmove already exceeded the touchmove tolerance - var _activeElement; // the element which has focus +/* + IONIC TAP + --------------- + - Both touch and mouse events are added to the document.body on DOM ready + - If a touch event happens, it removes the mouse event listeners (temporarily) + - Remembers the last touchstart event + - On touchend, if the distance between start and end was small, trigger a click + - In the triggered click event, add a 'isIonicTap' property + - The triggered click receives the same x,y coordinates as as the end event + - On document.body click listener (with useCapture=true), only allow clicks with 'isIonicTap' + - After XXms, bring back the mouse event listeners incase they switch from touch and mouse + - If no touch events and only mouse, then touch events never fire, only mouse + - Triggering clicks with mouse events work the same as touch, except with mousedown/mouseup + - Tapping inputs is disabled during scrolling - ionic.tap = { + - Does not require other libraries to hook into ionic.tap, it just works + - Elements can come and go from the DOM and it doesn't have to keep adding and removing listeners + - No "tap delay" after the first "tap" (you can tap as fast as you want, they all click) + - Minimal events listeners, only being added to document.body + - Correct focus in/out on each input type on each platform/device + - Shows and hides virtual keyboard correctly for each platform/device + - No user-agent sniffing + - Works with labels surrounding inputs + - Does not fire off a click if the user moves the pointer too far + - Adds and removes an 'activated' css class + - Multiple unit tests for each scenario - tapInspect: function(orgEvent) { - // if the event doesn't have a gesture then don't continue - if(!orgEvent.gesture || !orgEvent.gesture.srcEvent) return; +*/ - var e = orgEvent.gesture.srcEvent; // evaluate the actual source event, not the created event by gestures.js - var ele = e.target; // get the target element that was actually tapped +var tapDoc; // the element which the listeners are on (document.body) +var tapActiveEle; // the element which is active (probably has focus) +var tapEnabledTouchEvents; +var tapMouseResetTimer; +var tapPointerMoved; +var tapPointerStart; +var tapTouchFocusedInput; - if( ionic.tap.ignoreTapInspect(e) ) { - // if a tap in the same area just happened, - // or it was a touchcanel event, don't continue - console.debug('tapInspect stopEvent', e.type, ele.tagName); - return stopEvent(e); +var TAP_RELEASE_TOLERANCE = 6; // how much the coordinates can be off between start/end, but still a click + +var tapEventListeners = { + 'click': tapClickGateKeeper, + + 'mousedown': tapMouseDown, + 'mouseup': tapMouseUp, + 'mousemove': tapMouseMove, + + 'touchstart': tapTouchStart, + 'touchend': tapTouchEnd, + 'touchcancel': tapTouchCancel, + 'touchmove': tapTouchMove, + + 'focusin': tapFocusIn, + 'focusout': tapFocusOut +}; + +ionic.tap = { + + register: function(ele) { + tapDoc = ele; + + tapEventListener('click', true, true); + tapEventListener('mouseup'); + tapEventListener('mousedown'); + tapEventListener('touchstart'); + tapEventListener('touchend'); + tapEventListener('touchcancel'); + tapEventListener('focusin'); + tapEventListener('focusout'); + + return function() { + for(var type in tapEventListeners) { + tapEventListener(type, false); } + tapDoc = null; + tapActiveEle = null; + tapEnabledTouchEvents = false; + tapPointerMoved = false; + tapPointerStart = null; + }; + }, - for(var x=0; x<5; x++) { - // climb up the DOM looking to see if the tapped element is, or has a parent, of one of these - // only climb up a max of 5 parents, anything more probably isn't beneficial - if(!ele) break; + ignoreScrollStart: function(e) { + return (e.defaultPrevented) || // defaultPrevented has been assigned by another component handling the event + (e.target.isContentEditable) || + (e.target.type === 'range') || + (e.target.dataset ? e.target.dataset.preventScroll : e.target.getAttribute('data-prevent-default')) == 'true' || // manually set within an elements attributes + (!!(/object|embed/i).test(e.target.tagName)); // flash/movie/object touches should not try to scroll + }, - if( ionic.tap.isTapElement(ele.tagName) ) { - return ionic.tap.simulateClick(ele, e); - } - ele = ele.parentElement; - } + isTextInput: function(ele) { + return !!ele && + (ele.tagName == 'TEXTAREA' || + (ele.tagName == 'INPUT' && !(/radio|checkbox|range|file|submit|reset/i).test(ele.type))); + }, - // they didn't tap one of the above elements - // if the currently active element is an input, and they tapped outside - // of the current input, then unset its focus (blur) so the keyboard goes away - ionic.tap.blurActive(); - }, + isLabelWithInput: function(ele) { + var container = tapContainingElement(ele, false); - ignoreTapInspect: function(e) { - return !!ionic.tap.isRecentTap(e) || - ionic.tap.hasTouchScrolled(e) || - e.type === 'touchcancel'; - }, + return container && + ionic.tap.isTextInput( tapTargetElement( container ) ); + }, - isTapElement: function(tagName) { - return tagName == 'A' || - tagName == 'INPUT' || - tagName == 'BUTTON' || - tagName == 'LABEL' || - tagName == 'TEXTAREA' || - tagName == 'SELECT'; - }, + cloneFocusedInput: function(container, instance) { + if(ionic.tap.hasCheckedClone) return; + ionic.tap.hasCheckedClone = true; - simulateClick: function(target, e) { - // simulate a normal click by running the element's click method then focus on it - - var ele = target.control || target; - - if( ionic.tap.ignoreSimulateClick(ele) ) return; - - console.debug('simulateClick', e.type, ele.tagName, ele.className); - - var c = ionic.tap.getCoordinates(e); - - // using initMouseEvent instead of MouseEvent for our Android friends - var clickEvent = document.createEvent("MouseEvents"); - clickEvent.initMouseEvent('click', true, true, window, - 1, 0, 0, c.x, c.y, - false, false, false, false, 0, null); - - ele.dispatchEvent(clickEvent); - - // if it's an input, focus in on the target, otherwise blur - ionic.tap.handleFocus(ele); - - // remember the coordinates of this tap so if it happens again we can ignore it - // but only if the coordinates are not already being actively disabled - if( !ionic.tap.isRecentTap(e) ) { - ionic.tap.recordCoordinates(e); - } - - if(target.control) { - console.debug('simulateClick, target.control, stop'); - return stopEvent(e); - } - - }, - - ignoreSimulateClick: function(ele) { - return ele.disabled || ele.type === 'range'; - }, - - handleFocus: function(ele) { - if(ionic.tap.activeElement() !== ele) { - // only set the focus if it doesn't already have it - if( ele.tagName.match(/input|textarea|select/i) ) { - ionic.tap.activeElement(ele); - ele.focus(); - } else { - ionic.tap.activeElement(null); - ionic.tap.blurActive(); + ionic.requestAnimationFrame(function(){ + var focusInput = container.querySelector(':focus'); + if( ionic.tap.isTextInput(focusInput) ) { + var clonedInput = focusInput.parentElement.querySelector('.cloned-text-input'); + if(!clonedInput) { + clonedInput = document.createElement(focusInput.tagName); + clonedInput.type = focusInput.type; + clonedInput.value = focusInput.value; + clonedInput.className = 'cloned-text-input'; + focusInput.parentElement.insertBefore(clonedInput, focusInput); + focusInput.classList.add('previous-input-focus'); } } - }, + }); + }, - preventGhostClick: function(e) { + hasCheckedClone: false, - console.debug((function(){ - // Great for debugging, and thankfully this gets removed from the build, OMG it's ugly + removeClonedInputs: function(container) { + ionic.tap.hasCheckedClone = false; - if(e.target.control) { - // this is a label that has an associated input - // the native layer will send the actual event, so stop this one - console.debug('preventGhostClick', 'label'); + ionic.requestAnimationFrame(function(){ + var clonedInputs = container.querySelectorAll('.cloned-text-input'); + var previousInputFocus = container.querySelectorAll('.previous-input-focus'); + var x; - } else if(ionic.tap.isRecentTap(e)) { - // a tap has already happened at these coordinates recently, ignore this event - console.debug('preventGhostClick', 'isRecentTap', e.target.tagName); - - } else if(ionic.tap.hasTouchScrolled(e)) { - // this click's coordinates are different than its touchstart/mousedown, must have been scrolling - console.debug('preventGhostClick', 'hasTouchScrolled'); - } - - var c = ionic.tap.getCoordinates(e); - return 'click(' + c.x + ',' + c.y + '), start(' + startCoordinates.x + ',' + startCoordinates.y + ')'; - })()); - - - if(e.target.control || ionic.tap.isRecentTap(e) || ionic.tap.hasTouchScrolled(e)) { - return stopEvent(e); + for(x=0; x startCoordinates.x + TOUCH_TOLERANCE_X || - c.x < startCoordinates.x - TOUCH_TOLERANCE_X || - c.y > startCoordinates.y + TOUCH_TOLERANCE_Y || - c.y < startCoordinates.y - TOUCH_TOLERANCE_Y); - }, +// MOUSE +function tapMouseDown(e) { + if(e.isIonicTap || tapIgnoreEvent(e)) return; - recordCoordinates: function(event) { - // get the coordinates of this event and remember them for later - var c = ionic.tap.getCoordinates(event); - if(c.x && c.y) { - var tapId = Date.now(); + if(tapEnabledTouchEvents) { + console.debug('mousedown', 'stop event'); + e.stopPropagation(); - // only record tap coordinates if we have valid ones - tapCoordinates[tapId] = { x: c.x, y: c.y, id: tapId }; - - setTimeout(function() { - // delete the tap coordinates after X milliseconds, basically allowing - // it so a tap can happen again in the same area in the future - // this is only a fallback, most tap coordinates will be removed - // from the removeClickPrevent event fired by touchend/mouseup - delete tapCoordinates[tapId]; - }, CLICK_PREVENT_DURATION); - } - }, - - removeClickPrevent: function(e) { - // fired by touchend/mouseup - // after X milliseconds, remove tap coordinates - clearTimeout(clickPreventTimerId); - clickPreventTimerId = setTimeout(function(){ - var tap = ionic.tap.isRecentTap(e); - if(tap) delete tapCoordinates[tap.id]; - }, REMOVE_PREVENT_DELAY); - }, - - isRecentTap: function(event) { - // loop through the tap coordinates and see if the same area has been tapped recently - var tapId, existingCoordinates, currentCoordinates; - - for(tapId in tapCoordinates) { - existingCoordinates = tapCoordinates[tapId]; - if(!currentCoordinates) currentCoordinates = ionic.tap.getCoordinates(event); // lazy load it when needed - - if(currentCoordinates.x > existingCoordinates.x - HIT_RADIUS && - currentCoordinates.x < existingCoordinates.x + HIT_RADIUS && - currentCoordinates.y > existingCoordinates.y - HIT_RADIUS && - currentCoordinates.y < existingCoordinates.y + HIT_RADIUS) { - // the current tap coordinates are in the same area as a recent tap - return existingCoordinates; - } - } - }, - - blurActive: function() { - var ele = ionic.tap.activeElement(); - if(ele && ele.tagName.match(/input|textarea|select/i) ) { - setTimeout(function(){ - ele.blur(); - ionic.tap.activeElement(null); - }, 400); - } - }, - - activeElement: function(ele) { - if(arguments.length) { - _activeElement = ele; - } - return _activeElement || document.activeElement; - }, - - setTouchStart: function(e) { - _hasTouchScrolled = false; - startCoordinates = ionic.tap.getCoordinates(e); - document.body.addEventListener('touchmove', ionic.tap.onTouchMove, false); - }, - - onTouchMove: function(e) { - if( ionic.tap.hasTouchScrolled(e) ) { - _hasTouchScrolled = true; - document.body.removeEventListener('touchmove', ionic.tap.onTouchMove); - console.debug('hasTouchScrolled'); - } - }, - - reset: function() { - tapCoordinates = {}; - startCoordinates = {}; - }, - - ignoreScrollStart: function(e) { - return (e.defaultPrevented) || // defaultPrevented has been assigned by another component handling the event - (e.target.tagName.match(/input|textarea/i) && ionic.tap.activeElement() === e.target) || // target is the active element, so its a second tap to select input text - (e.target.isContentEditable) || - (e.target.dataset ? e.target.dataset.preventScroll : e.target.getAttribute('data-prevent-default')) == 'true' || // manually set within an elements attributes - (!!e.target.tagName.match(/object|embed/i)); // flash/movie/object touches should not try to scroll + if( !ionic.tap.isTextInput(e.target) ) { + // If you preventDefault on a text input then you cannot move its text caret/cursor. + // Allow through only the text input default. However, without preventDefault on an + // input the 300ms delay can change focus on inputs after the keyboard shows up. + // The focusin event handles the chance of focus changing after the keyboard shows. + e.preventDefault(); } - }; - - function stopEvent(e){ - e.stopPropagation(); - e.preventDefault(); return false; } - ionic.Platform.ready(function(){ - if(ionic.Platform.grade === 'c') { - // low performing devices should have a longer ghostclick prevent - REMOVE_PREVENT_DELAY = REMOVE_PREVENT_DELAY_GRADE_C; + tapPointerMoved = false; + tapPointerStart = getPointerCoordinates(e); + + tapEventListener('mousemove'); + ionic.activator.start(e); +} + +function tapMouseUp(e) { + if( tapIgnoreEvent(e) ) return; + + if( !tapHasPointerMoved(e) ) { + tapClick(e); + } + tapEventListener('mousemove', false); + ionic.activator.end(); + tapPointerMoved = false; +} + +function tapMouseMove(e) { + if( tapHasPointerMoved(e) ) { + tapEventListener('mousemove', false); + ionic.activator.end(); + tapPointerMoved = true; + return false; + } +} + + +// TOUCH +function tapTouchStart(e) { + if( tapIgnoreEvent(e) ) return; + + tapPointerMoved = false; + + tapEnableTouchEvents(); + tapPointerStart = getPointerCoordinates(e); + + tapEventListener('touchmove'); + ionic.activator.start(e); +} + +function tapTouchEnd(e) { + if( tapIgnoreEvent(e) ) return; + + tapEnableTouchEvents(); + if( !tapHasPointerMoved(e) ) { + tapClick(e); + } + + tapTouchCancel(); +} + +function tapTouchMove(e) { + if( tapHasPointerMoved(e) ) { + tapPointerMoved = true; + tapEventListener('touchmove', false); + ionic.activator.end(); + return false; + } +} + +function tapTouchCancel(e) { + tapEventListener('touchmove', false); + ionic.activator.end(); + tapPointerMoved = false; +} + +function tapEnableTouchEvents() { + if(!tapEnabledTouchEvents) { + tapEventListener('mouseup', false); + tapEnabledTouchEvents = true; + } + clearTimeout(tapMouseResetTimer); + tapMouseResetTimer = setTimeout(tapResetMouseEvent, 2500); +} + +function tapResetMouseEvent() { + tapEventListener('mouseup', false); + tapEnabledTouchEvents = false; +} + +function tapIgnoreEvent(e) { + if(e.isTapHandled) return true; + e.isTapHandled = true; + + if( ionic.scroll.isScrolling ) { + e.preventDefault(); + return true; + } +} + +function tapHandleFocus(ele) { + tapTouchFocusedInput = null; + + if(ele.tagName == 'SELECT') { + // trick to force Android options to show up + console.debug('tapHandleFocus', ele.tagName); + triggerMouseEvent('mousedown', ele, 0, 0); + tapActiveElement(ele); + ele.focus && ele.focus(); + + } else if(tapActiveElement() !== ele) { + if( (/input|textarea/i).test(ele.tagName) ) { + console.debug('tapHandleFocus', ele.tagName, ele.id); + tapActiveElement(ele); + ele.focus && ele.focus(); + ele.value = ele.value; + if( tapEnabledTouchEvents ) { + tapTouchFocusedInput = ele; + } + } else { + tapFocusOutActive(); } - }); + } +} - // set click handler and check if the event should be stopped or not - document.addEventListener('click', ionic.tap.preventGhostClick, true); +function tapFocusOutActive() { + var ele = tapActiveElement(); + if(ele && (/input|textarea|select/i).test(ele.tagName) ) { + console.debug('tapFocusOutActive', ele.tagName); + ele.blur(); + } + tapActiveElement(null); +} - // set release event listener for HTML elements that were tapped or held - ionic.on("release", ionic.tap.tapInspect, document); +function tapFocusIn(e) { + // Because a text input doesn't preventDefault (so the caret still works) there's a chance + // that it's mousedown event 300ms later will change the focus to another element after + // the keyboard shows up. - // listeners used to clear out active taps which are used to prevention ghostclicks - document.addEventListener('touchend', ionic.tap.removeClickPrevent, false); - document.addEventListener('mouseup', ionic.tap.removeClickPrevent, false); + if( tapEnabledTouchEvents && + ionic.tap.isTextInput( tapActiveElement() ) && + ionic.tap.isTextInput(tapTouchFocusedInput) && + tapTouchFocusedInput !== e.target ) { - // remember where the user first started touching the screen - // so that if they scrolled, it shouldn't fire the click - document.addEventListener('touchstart', ionic.tap.setTouchStart, false); + // 1) The pointer is from touch events + // 2) There is an active element which is a text input + // 3) A text input was just set to be focused on by a touch event + // 4) A new focus has been set, however the target isn't the one the touch event wanted + console.debug('focusin', 'tapTouchFocusedInput'); + tapTouchFocusedInput.focus(); + tapTouchFocusedInput = null; + } + ionic.scroll.isScrolling = false; +} -})(this, document, ionic); +function tapFocusOut() { + tapActiveElement(null); +} + +function tapActiveElement(ele) { + if(arguments.length) { + tapActiveEle = ele; + } + return tapActiveEle || document.activeElement; +} + +function tapHasPointerMoved(endEvent) { + if(!endEvent || !tapPointerStart || ( tapPointerStart.x === 0 && tapPointerStart.y === 0 )) { + return false; + } + var endCoordinates = getPointerCoordinates(endEvent); + + return Math.abs(tapPointerStart.x - endCoordinates.x) > TAP_RELEASE_TOLERANCE || + Math.abs(tapPointerStart.y - endCoordinates.y) > TAP_RELEASE_TOLERANCE; +} + +function getPointerCoordinates(event) { + // This method can get coordinates for both a mouse click + // or a touch depending on the given event + var c = { x:0, y:0 }; + if(event) { + var touches = event.touches && event.touches.length ? event.touches : [event]; + var e = (event.changedTouches && event.changedTouches[0]) || touches[0]; + if(e) { + c.x = e.clientX || e.pageX || 0; + c.y = e.clientY || e.pageY || 0; + } + } + return c; +} + +function tapContainingElement(ele, allowSelf) { + var climbEle = ele; + for(var x=0; x<6; x++) { + if(!climbEle) break; + if(climbEle.tagName === 'LABEL') return climbEle; + climbEle = ele.parentElement; + } + if(allowSelf !== false) return ele; +} + +function tapTargetElement(ele) { + if(ele && ele.tagName === 'LABEL') { + if(ele.control) return ele.control; + + // older devices do not support the "control" property + if(ele.querySelector) { + var control = ele.querySelector('input,textarea,select'); + if(control) return control; + } + } + return ele; +} + +ionic.DomUtil.ready(function(){ + + ionic.tap.register(document.body); + +}); diff --git a/js/views/scrollView.js b/js/views/scrollView.js index d42bc26fa2..24545b0440 100644 --- a/js/views/scrollView.js +++ b/js/views/scrollView.js @@ -23,218 +23,218 @@ * based on the pure time difference. */ (function(global) { - var time = Date.now || function() { - return +new Date(); - }; - var desiredFrames = 60; - var millisecondsPerSecond = 1000; - var running = {}; - var counter = 1; + var time = Date.now || function() { + return +new Date(); + }; + var desiredFrames = 60; + var millisecondsPerSecond = 1000; + var running = {}; + var counter = 1; - // Create namespaces - if (!global.core) { - var core = global.core = { effect : {} }; + // Create namespaces + if (!global.core) { + var core = global.core = { effect : {} }; - } else if (!core.effect) { - core.effect = {}; - } + } else if (!core.effect) { + core.effect = {}; + } - core.effect.Animate = { + core.effect.Animate = { - /** - * A requestAnimationFrame wrapper / polyfill. - * - * @param callback {Function} The callback to be invoked before the next repaint. - * @param root {HTMLElement} The root element for the repaint - */ - requestAnimationFrame: (function() { + /** + * A requestAnimationFrame wrapper / polyfill. + * + * @param callback {Function} The callback to be invoked before the next repaint. + * @param root {HTMLElement} The root element for the repaint + */ + requestAnimationFrame: (function() { - // Check for request animation Frame support - var requestFrame = global.requestAnimationFrame || global.webkitRequestAnimationFrame || global.mozRequestAnimationFrame || global.oRequestAnimationFrame; - var isNative = !!requestFrame; + // Check for request animation Frame support + var requestFrame = global.requestAnimationFrame || global.webkitRequestAnimationFrame || global.mozRequestAnimationFrame || global.oRequestAnimationFrame; + var isNative = !!requestFrame; - if (requestFrame && !/requestAnimationFrame\(\)\s*\{\s*\[native code\]\s*\}/i.test(requestFrame.toString())) { - isNative = false; - } + if (requestFrame && !/requestAnimationFrame\(\)\s*\{\s*\[native code\]\s*\}/i.test(requestFrame.toString())) { + isNative = false; + } - if (isNative) { - return function(callback, root) { - requestFrame(callback, root) - }; - } + if (isNative) { + return function(callback, root) { + requestFrame(callback, root) + }; + } - var TARGET_FPS = 60; - var requests = {}; - var requestCount = 0; - var rafHandle = 1; - var intervalHandle = null; - var lastActive = +new Date(); + var TARGET_FPS = 60; + var requests = {}; + var requestCount = 0; + var rafHandle = 1; + var intervalHandle = null; + var lastActive = +new Date(); - return function(callback, root) { - var callbackHandle = rafHandle++; + return function(callback, root) { + var callbackHandle = rafHandle++; - // Store callback - requests[callbackHandle] = callback; - requestCount++; + // Store callback + requests[callbackHandle] = callback; + requestCount++; - // Create timeout at first request - if (intervalHandle === null) { + // Create timeout at first request + if (intervalHandle === null) { - intervalHandle = setInterval(function() { + intervalHandle = setInterval(function() { - var time = +new Date(); - var currentRequests = requests; + var time = +new Date(); + var currentRequests = requests; - // Reset data structure before executing callbacks - requests = {}; - requestCount = 0; + // Reset data structure before executing callbacks + requests = {}; + requestCount = 0; - for(var key in currentRequests) { - if (currentRequests.hasOwnProperty(key)) { - currentRequests[key](time); - lastActive = time; - } - } + for(var key in currentRequests) { + if (currentRequests.hasOwnProperty(key)) { + currentRequests[key](time); + lastActive = time; + } + } - // Disable the timeout when nothing happens for a certain - // period of time - if (time - lastActive > 2500) { - clearInterval(intervalHandle); - intervalHandle = null; - } + // Disable the timeout when nothing happens for a certain + // period of time + if (time - lastActive > 2500) { + clearInterval(intervalHandle); + intervalHandle = null; + } - }, 1000 / TARGET_FPS); - } + }, 1000 / TARGET_FPS); + } - return callbackHandle; - }; + return callbackHandle; + }; - })(), + })(), - /** - * Stops the given animation. - * - * @param id {Integer} Unique animation ID - * @return {Boolean} Whether the animation was stopped (aka, was running before) - */ - stop: function(id) { - var cleared = running[id] != null; - if (cleared) { - running[id] = null; - } + /** + * Stops the given animation. + * + * @param id {Integer} Unique animation ID + * @return {Boolean} Whether the animation was stopped (aka, was running before) + */ + stop: function(id) { + var cleared = running[id] != null; + if (cleared) { + running[id] = null; + } - return cleared; - }, + return cleared; + }, - /** - * Whether the given animation is still running. - * - * @param id {Integer} Unique animation ID - * @return {Boolean} Whether the animation is still running - */ - isRunning: function(id) { - return running[id] != null; - }, + /** + * Whether the given animation is still running. + * + * @param id {Integer} Unique animation ID + * @return {Boolean} Whether the animation is still running + */ + isRunning: function(id) { + return running[id] != null; + }, - /** - * Start the animation. - * - * @param stepCallback {Function} Pointer to function which is executed on every step. - * Signature of the method should be `function(percent, now, virtual) { return continueWithAnimation; }` - * @param verifyCallback {Function} Executed before every animation step. - * Signature of the method should be `function() { return continueWithAnimation; }` - * @param completedCallback {Function} - * Signature of the method should be `function(droppedFrames, finishedAnimation) {}` - * @param duration {Integer} Milliseconds to run the animation - * @param easingMethod {Function} Pointer to easing function - * Signature of the method should be `function(percent) { return modifiedValue; }` - * @param root {Element} Render root, when available. Used for internal - * usage of requestAnimationFrame. - * @return {Integer} Identifier of animation. Can be used to stop it any time. - */ - start: function(stepCallback, verifyCallback, completedCallback, duration, easingMethod, root) { + /** + * Start the animation. + * + * @param stepCallback {Function} Pointer to function which is executed on every step. + * Signature of the method should be `function(percent, now, virtual) { return continueWithAnimation; }` + * @param verifyCallback {Function} Executed before every animation step. + * Signature of the method should be `function() { return continueWithAnimation; }` + * @param completedCallback {Function} + * Signature of the method should be `function(droppedFrames, finishedAnimation) {}` + * @param duration {Integer} Milliseconds to run the animation + * @param easingMethod {Function} Pointer to easing function + * Signature of the method should be `function(percent) { return modifiedValue; }` + * @param root {Element} Render root, when available. Used for internal + * usage of requestAnimationFrame. + * @return {Integer} Identifier of animation. Can be used to stop it any time. + */ + start: function(stepCallback, verifyCallback, completedCallback, duration, easingMethod, root) { - var start = time(); - var lastFrame = start; - var percent = 0; - var dropCounter = 0; - var id = counter++; + var start = time(); + var lastFrame = start; + var percent = 0; + var dropCounter = 0; + var id = counter++; - if (!root) { - root = document.body; - } + if (!root) { + root = document.body; + } - // Compacting running db automatically every few new animations - if (id % 20 === 0) { - var newRunning = {}; - for (var usedId in running) { - newRunning[usedId] = true; - } - running = newRunning; - } + // Compacting running db automatically every few new animations + if (id % 20 === 0) { + var newRunning = {}; + for (var usedId in running) { + newRunning[usedId] = true; + } + running = newRunning; + } - // This is the internal step method which is called every few milliseconds - var step = function(virtual) { + // This is the internal step method which is called every few milliseconds + var step = function(virtual) { - // Normalize virtual value - var render = virtual !== true; + // Normalize virtual value + var render = virtual !== true; - // Get current time - var now = time(); + // Get current time + var now = time(); - // Verification is executed before next animation step - if (!running[id] || (verifyCallback && !verifyCallback(id))) { + // Verification is executed before next animation step + if (!running[id] || (verifyCallback && !verifyCallback(id))) { - running[id] = null; - completedCallback && completedCallback(desiredFrames - (dropCounter / ((now - start) / millisecondsPerSecond)), id, false); - return; + running[id] = null; + completedCallback && completedCallback(desiredFrames - (dropCounter / ((now - start) / millisecondsPerSecond)), id, false); + return; - } + } - // For the current rendering to apply let's update omitted steps in memory. - // This is important to bring internal state variables up-to-date with progress in time. - if (render) { + // For the current rendering to apply let's update omitted steps in memory. + // This is important to bring internal state variables up-to-date with progress in time. + if (render) { - var droppedFrames = Math.round((now - lastFrame) / (millisecondsPerSecond / desiredFrames)) - 1; - for (var j = 0; j < Math.min(droppedFrames, 4); j++) { - step(true); - dropCounter++; - } + var droppedFrames = Math.round((now - lastFrame) / (millisecondsPerSecond / desiredFrames)) - 1; + for (var j = 0; j < Math.min(droppedFrames, 4); j++) { + step(true); + dropCounter++; + } - } + } - // Compute percent value - if (duration) { - percent = (now - start) / duration; - if (percent > 1) { - percent = 1; - } - } + // Compute percent value + if (duration) { + percent = (now - start) / duration; + if (percent > 1) { + percent = 1; + } + } - // Execute step callback, then... - var value = easingMethod ? easingMethod(percent) : percent; - if ((stepCallback(value, now, render) === false || percent === 1) && render) { - running[id] = null; - completedCallback && completedCallback(desiredFrames - (dropCounter / ((now - start) / millisecondsPerSecond)), id, percent === 1 || duration == null); - } else if (render) { - lastFrame = now; - core.effect.Animate.requestAnimationFrame(step, root); - } - }; + // Execute step callback, then... + var value = easingMethod ? easingMethod(percent) : percent; + if ((stepCallback(value, now, render) === false || percent === 1) && render) { + running[id] = null; + completedCallback && completedCallback(desiredFrames - (dropCounter / ((now - start) / millisecondsPerSecond)), id, percent === 1 || duration == null); + } else if (render) { + lastFrame = now; + core.effect.Animate.requestAnimationFrame(step, root); + } + }; - // Mark as running - running[id] = true; + // Mark as running + running[id] = true; - // Init first step - core.effect.Animate.requestAnimationFrame(step, root); + // Init first step + core.effect.Animate.requestAnimationFrame(step, root); - // Return unique animation ID - return id; - } - }; + // Return unique animation ID + return id; + } + }; })(this); /* @@ -254,28 +254,28 @@ var Scroller; (function(ionic) { - var NOOP = function(){}; + var NOOP = function(){}; - // Easing Equations (c) 2003 Robert Penner, all rights reserved. - // Open source under the BSD License. + // Easing Equations (c) 2003 Robert Penner, all rights reserved. + // Open source under the BSD License. - /** - * @param pos {Number} position between 0 (start of effect) and 1 (end of effect) - **/ - var easeOutCubic = function(pos) { - return (Math.pow((pos - 1), 3) + 1); - }; + /** + * @param pos {Number} position between 0 (start of effect) and 1 (end of effect) + **/ + var easeOutCubic = function(pos) { + return (Math.pow((pos - 1), 3) + 1); + }; - /** - * @param pos {Number} position between 0 (start of effect) and 1 (end of effect) - **/ - var easeInOutCubic = function(pos) { - if ((pos /= 0.5) < 1) { - return 0.5 * Math.pow(pos, 3); - } + /** + * @param pos {Number} position between 0 (start of effect) and 1 (end of effect) + **/ + var easeInOutCubic = function(pos) { + if ((pos /= 0.5) < 1) { + return 0.5 * Math.pow(pos, 3); + } - return 0.5 * (Math.pow((pos - 2), 3) + 2); - }; + return 0.5 * (Math.pow((pos - 2), 3) + 2); + }; /** @@ -301,7 +301,7 @@ ionic.views.Scroll = ionic.views.View.inherit({ } }); - this.options = { + this.options = { /** Disable scrolling on x-axis by default */ scrollingX: false, @@ -370,17 +370,42 @@ ionic.views.Scroll = ionic.views.View.inherit({ // The ms interval for triggering scroll events scrollEventInterval: 50 - }; + }; - for (var key in options) { - this.options[key] = options[key]; - } + for (var key in options) { + this.options[key] = options[key]; + } this.hintResize = ionic.debounce(function() { self.resize(); }, 1000, true); + this.onScroll = function(scrollTop) { + + if(!ionic.scroll.isScrolling) { + setTimeout(self.setScrollStart, 50); + } else { + clearTimeout(self.scrollTimer); + self.scrollTimer = setTimeout(self.setScrollStop, 80); + } + + }; + + this.setScrollStart = function() { + ionic.scroll.isScrolling = Math.abs(ionic.scroll.lastTop - self.__scrollTop) > 1; + clearTimeout(self.scrollTimer); + self.scrollTimer = setTimeout(self.setScrollStop, 80); + }; + + this.setScrollStop = function() { + ionic.scroll.isScrolling = false; + ionic.scroll.lastTop = self.__scrollTop; + }; + this.triggerScrollEvent = ionic.throttle(function() { + + self.onScroll(); + ionic.trigger('scroll', { scrollTop: self.__scrollTop, scrollLeft: self.__scrollLeft, @@ -401,7 +426,7 @@ ionic.views.Scroll = ionic.views.View.inherit({ // Get the render update function, initialize event handlers, // and calculate the size of the scroll container - this.__callback = this.getRenderFn(); + this.__callback = this.getRenderFn(); this.__initEventHandlers(); this.__createScrollbars(); @@ -412,7 +437,7 @@ ionic.views.Scroll = ionic.views.View.inherit({ // Fade them out this.__fadeScrollbars('out', this.options.scrollbarResizeFadeDelay); - }, + }, @@ -597,18 +622,49 @@ ionic.views.Scroll = ionic.views.View.inherit({ //Broadcasted when keyboard is shown on some platforms. //See js/utils/keyboard.js container.addEventListener('scrollChildIntoView', function(e) { + var keyboardHeight = e.detail.keyboardHeight || 0; var deviceHeight = window.innerHeight; - var element = e.target; - var elementHeight = e.target.offsetHeight; - //getBoundingClientRect() will actually give us position relative to the viewport - var elementDeviceTop = element.getBoundingClientRect().top; - var elementScrollTop = ionic.DomUtil.getPositionInParent(element, container).top; + var frameHeight; + if (ionic.Platform.isIOS() && ionic.Platform.version() >= 7.0 || (!ionic.Platform.isWebView() && ionic.Platform.isAndroid())){ + frameHeight = deviceHeight; + } + else { + frameHeight = deviceHeight - keyboardHeight; + } + + var element = e.target; + + //getBoundingClientRect() will give us position relative to the viewport + var elementDeviceBottom = element.getBoundingClientRect().bottom; + + if (e.detail.firstKeyboardShow){ + //shrink scrollview so we can actually scroll if the input is hidden + //if it isn't shrink so we can scroll to inputs under the keyboard + container.style.height = (container.clientHeight - keyboardHeight) + "px"; + + //update scroll view + self.resize(); + } //If the element is positioned under the keyboard... - if (elementDeviceTop + elementHeight > deviceHeight) { + if (elementDeviceBottom > frameHeight) { //Put element in middle of visible screen - self.scrollTo(0, elementScrollTop + elementHeight - (deviceHeight * 0.5), true); + //Wait for resize() to reset scroll position + setTimeout(function(){ + //distance from top of input to the top of the keyboard + var keyboardTopOffset = element.getBoundingClientRect().top - frameHeight; + //middle of the scrollview, where we want to scroll to + var scrollViewMidpointOffset = container.clientHeight * 0.5; + var scrollOffset = keyboardTopOffset + scrollViewMidpointOffset; + self.scrollBy(0, scrollOffset, true); + + //please someone tell me there's a better way to do this + //wait until input is scrolled into view, then fix focus + setTimeout(function(){ + element.value = element.value; //thanks @adambradley 1337h4x + }, 600); + }, 32); } //Only the first scrollView parent of the element that broadcasted this event @@ -616,26 +672,95 @@ ionic.views.Scroll = ionic.views.View.inherit({ e.stopPropagation(); }); - if ('ontouchstart' in window) { + container.addEventListener('resetScrollView', function(e) { + //return scrollview to original height once keyboard has hidden + container.style.height = ""; + self.resize(); + }); - container.addEventListener("touchstart", function(e) { - if ( ionic.tap.ignoreScrollStart(e) ) { - return; - } + + self.touchStart = function(e) { + self.startCoordinates = getPointerCoordinates(e); + + if ( ionic.tap.ignoreScrollStart(e) ) { + return; + } + + if( ionic.tap.isTextInput(e.target) || ionic.tap.isLabelWithInput(e.target) ) { + // do not start if the target is a text input + // if there is a touchmove on this input, then we can start the scroll + self.__hasStarted = false; + return; + } + + self.__isSelectable = true; + self.__enableScrollY = true; + self.__hasStarted = true; + self.doTouchStart(e.touches, e.timeStamp); + e.preventDefault(); + }; + + self.touchMove = function(e) { + if(e.defaultPrevented) { + return; + } + + if( !self.__hasStarted && ( (ionic.tap.isTextInput(e.target) || ionic.tap.isLabelWithInput(e.target)) ) ) { + // the target is a text input and scroll has started + // since the text input doesn't start on touchStart, do it here + self.__hasStarted = true; self.doTouchStart(e.touches, e.timeStamp); e.preventDefault(); - }, false); + return; + } - document.addEventListener("touchmove", function(e) { - if(e.defaultPrevented) { - return; + if(self.startCoordinates) { + // we have start coordinates, so get this touch move's current coordinates + var currentCoordinates = getPointerCoordinates(e); + + if( self.__isSelectable && + ionic.tap.isTextInput(e.target) && + Math.abs(self.startCoordinates.x - currentCoordinates.x) > 20 ) { + // user slid the text input's caret on its x axis, disable any future y scrolling + self.__enableScrollY = false; + self.__isSelectable = true; } - self.doTouchMove(e.touches, e.timeStamp); - }, false); - document.addEventListener("touchend", function(e) { - self.doTouchEnd(e.timeStamp); - }, false); + if( self.__enableScrollY && Math.abs(self.startCoordinates.y - currentCoordinates.y) > 10 ) { + // user scrolled the entire view on the y axis + // disabled being able to select text on an input + // hide the input which has focus, and show a cloned one that doesn't have focus + self.__isSelectable = false; + ionic.tap.cloneFocusedInput(self.__container); + } + } + + self.doTouchMove(e.touches, e.timeStamp); + }; + + self.touchEnd = function(e) { + self.doTouchEnd(e.timeStamp); + self.__hasStarted = false; + self.__isSelectable = true; + self.__enableScrollY = true; + + if( !self.__isDragging && !self.__isDecelerating && !self.__isAnimating ) { + ionic.tap.removeClonedInputs(self.__container); + } + }; + + self.options.orgScrollingComplete = self.options.scrollingComplete; + self.options.scrollingComplete = function() { + ionic.tap.removeClonedInputs(self.__container); + self.options.orgScrollingComplete(); + }; + + + if ('ontouchstart' in window) { + container.addEventListener("touchstart", self.touchStart, false); + document.addEventListener("touchmove", self.touchMove, false); + document.addEventListener("touchend", self.touchEnd, false); + document.addEventListener("touchcancel", self.touchEnd, false); } else { @@ -898,9 +1023,9 @@ ionic.views.Scroll = ionic.views.View.inherit({ // Update Scroller dimensions for changed content // Add padding to bottom of content this.setDimensions( - this.__container.clientWidth, - this.__container.clientHeight, - Math.max(this.__content.scrollWidth, this.__content.offsetWidth), + this.__container.clientWidth, + this.__container.clientHeight, + Math.max(this.__content.scrollWidth, this.__content.offsetWidth), Math.max(this.__content.scrollHeight, this.__content.offsetHeight) ); }, @@ -915,7 +1040,7 @@ ionic.views.Scroll = ionic.views.View.inherit({ var content = this.__content; - var docStyle = document.documentElement.style; + var docStyle = document.documentElement.style; var engine; if ('MozAppearance' in docStyle) { @@ -2070,4 +2195,9 @@ ionic.views.Scroll = ionic.views.View.inherit({ } }); +ionic.scroll = { + isScrolling: false, + lastTop: 0 +}; + })(ionic); diff --git a/scss/_scaffolding.scss b/scss/_scaffolding.scss index a740eb7c2c..3dfc7b0404 100644 --- a/scss/_scaffolding.scss +++ b/scss/_scaffolding.scss @@ -18,7 +18,6 @@ body, @include tap-highlight-transparent(); @include user-select(none); - position: fixed; top: 0; right: 0; bottom: 0; @@ -138,6 +137,14 @@ body.grade-c { } } +.previous-input-focus, +.cloned-text-input + input, +.cloned-text-input + textarea { + position: absolute; + left: -9999px; + width: 200px; +} + @keyframes refresh-spin { 0% { transform: translate3d(0,0,0) rotate(0); } 100% { transform: translate3d(0,0,0) rotate(-180deg); } diff --git a/test/html/clickTests2.html b/test/html/clickTests2.html new file mode 100644 index 0000000000..04d7120bf3 --- /dev/null +++ b/test/html/clickTests2.html @@ -0,0 +1,346 @@ + + + + + + + + + +
+ + Link + + + +
+ +
+ + +
div
+
+ +
+
ng
+ Link + + + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ + + +
+
click
+
mousedown
+
mouseup
+
mousemove
+
touchstart
+
touchend
+
touchmove
+
touchcancel
+
focusin
+
focusout
+
clickblocked
+
+ +
+ + + + + + diff --git a/test/html/input.html b/test/html/input.html index 61178a6c57..8bc0350939 100644 --- a/test/html/input.html +++ b/test/html/input.html @@ -3,20 +3,33 @@ Inputs - + + +