From 54c27ff85bae2b76d34ff8bae6ea738b71c1fd96 Mon Sep 17 00:00:00 2001 From: perry Date: Wed, 14 Jan 2015 17:04:42 -0600 Subject: [PATCH] feat(scrolling): Allow native scrolling to be configurable, add infinite scroll support for native scrolling --- .../controller/infiniteScrollController.js | 115 +++++++++++++ js/angular/directive/content.js | 6 +- js/angular/directive/infiniteScroll.js | 101 ++++------- js/angular/service/ionicConfig.js | 7 + scss/_scaffolding.scss | 20 ++- test/unit/angular/directive/content.unit.js | 11 +- .../angular/directive/infiniteScroll.unit.js | 157 ++++++++++++++---- 7 files changed, 305 insertions(+), 112 deletions(-) create mode 100644 js/angular/controller/infiniteScrollController.js diff --git a/js/angular/controller/infiniteScrollController.js b/js/angular/controller/infiniteScrollController.js new file mode 100644 index 00000000000..9f7dce53074 --- /dev/null +++ b/js/angular/controller/infiniteScrollController.js @@ -0,0 +1,115 @@ +IonicModule +.controller('$ionInfiniteScroll', [ + '$scope', + '$attrs', + '$element', + '$timeout', + function($scope, $attrs, $element, $timeout) { + var self = this; + self.isLoading = false; + + $scope.icon = function() { + return angular.isDefined($attrs.icon) ? $attrs.icon : 'ion-loading-d'; + }; + + $scope.$on('scroll.infiniteScrollComplete', function() { + finishInfiniteScroll(); + }); + + $scope.$on('$destroy', function() { + if (self.scrollCtrl && self.scrollCtrl.$element) self.scrollCtrl.$element.off('scroll', self.checkBounds); + if (self.scrollEl && self.scrollEl.removeEventListener) { + self.scrollEl.removeEventListener('scroll', self.checkBounds); + } + }); + + // debounce checking infinite scroll events + self.checkBounds = ionic.Utils.throttle(checkInfiniteBounds, 300); + + function onInfinite() { + ionic.requestAnimationFrame(function() { + $element[0].classList.add('active'); + }); + self.isLoading = true; + $scope.$parent && $scope.$parent.$apply($attrs.onInfinite || ''); + } + + function finishInfiniteScroll() { + ionic.requestAnimationFrame(function() { + $element[0].classList.remove('active'); + }); + $timeout(function() { + if (self.jsScrolling) self.scrollView.resize(); + self.checkBounds(); + }, 30, false); + self.isLoading = false; + } + + // check if we've scrolled far enough to trigger an infinite scroll + function checkInfiniteBounds() { + if (self.isLoading) return; + var maxScroll = {}; + + if (self.jsScrolling) { + maxScroll = self.getJSMaxScroll(); + var scrollValues = self.scrollView.getValues(); + if ((maxScroll.left !== -1 && scrollValues.left >= maxScroll.left) || + (maxScroll.top !== -1 && scrollValues.top >= maxScroll.top)) { + onInfinite(); + } + } else { + maxScroll = self.getNativeMaxScroll(); + if (( + maxScroll.left !== -1 && + self.scrollEl.scrollLeft >= maxScroll.left - self.scrollEl.clientWidth + ) || ( + maxScroll.top !== -1 && + self.scrollEl.scrollTop >= maxScroll.top - self.scrollEl.clientHeight + )) { + onInfinite(); + } + } + } + + // determine the threshold at which we should fire an infinite scroll + // note: this gets processed every scroll event, can it be cached? + self.getJSMaxScroll = function() { + var maxValues = self.scrollView.getScrollMax(); + return { + left: self.scrollView.options.scrollingX ? + calculateMaxValue(maxValues.left) : + -1, + top: self.scrollView.options.scrollingY ? + calculateMaxValue(maxValues.top) : + -1 + }; + }; + + self.getNativeMaxScroll = function() { + var maxValues = { + left: self.scrollEl.scrollWidth, + top: self.scrollEl.scrollHeight + }; + var computedStyle = window.getComputedStyle(self.scrollEl) || {}; + return { + left: computedStyle.overflowX === 'scroll' || + computedStyle.overflowX === 'auto' || + self.scrollEl.style['overflow-x'] === 'scroll' ? + calculateMaxValue(maxValues.left) : -1, + top: computedStyle.overflowY === 'scroll' || + computedStyle.overflowY === 'auto' || + self.scrollEl.style['overflow-y'] === 'scroll' ? + calculateMaxValue(maxValues.top) : -1 + }; + }; + + // determine pixel refresh distance based on % or value + function calculateMaxValue(maximum) { + distance = ($attrs.distance || '2.5%').trim(); + isPercent = distance.indexOf('%') !== -1; + return isPercent ? + maximum * (1 - parseFloat(distance) / 100) : + maximum - parseFloat(distance); + } + +}]); diff --git a/js/angular/directive/content.js b/js/angular/directive/content.js index 3fbf7348cb3..8264ffff42f 100644 --- a/js/angular/directive/content.js +++ b/js/angular/directive/content.js @@ -45,7 +45,8 @@ IonicModule '$timeout', '$controller', '$ionicBind', -function($timeout, $controller, $ionicBind) { + '$ionicConfig', +function($timeout, $controller, $ionicBind, $ionicConfig) { return { restrict: 'E', require: '^?ionNavView', @@ -108,7 +109,8 @@ function($timeout, $controller, $ionicBind) { if ($attr.scroll === "false") { //do nothing - } else if(attr.overflowScroll === "true") { + } else if (attr.overflowScroll === "true" || !$ionicConfig.scrolling.jsScrolling()) { + // use native scrolling $element.addClass('overflow-scroll'); } else { var scrollViewOptions = { diff --git a/js/angular/directive/infiniteScroll.js b/js/angular/directive/infiniteScroll.js index fd9f916f536..163186af5ab 100644 --- a/js/angular/directive/infiniteScroll.js +++ b/js/angular/directive/infiniteScroll.js @@ -19,6 +19,7 @@ * @param {string=} distance The distance from the bottom that the scroll must * reach to trigger the on-infinite expression. Default: 1%. * @param {string=} icon The icon to show while loading. Default: 'ion-loading-d'. + * @param {boolean=} immediate-check Whether to check the infinite scroll bounds immediately on load. * * @usage * ```html @@ -63,84 +64,40 @@ */ IonicModule .directive('ionInfiniteScroll', ['$timeout', function($timeout) { - function calculateMaxValue(distance, maximum, isPercent) { - return isPercent ? - maximum * (1 - parseFloat(distance,10) / 100) : - maximum - parseFloat(distance, 10); - } return { restrict: 'E', - require: ['^$ionicScroll', 'ionInfiniteScroll'], - template: '', - scope: { - load: '&onInfinite' - }, - controller: ['$scope', '$attrs', function($scope, $attrs) { - this.isLoading = false; - this.scrollView = null; //given by link function - this.getMaxScroll = function() { - var distance = ($attrs.distance || '2.5%').trim(); - var isPercent = distance.indexOf('%') !== -1; - var maxValues = this.scrollView.getScrollMax(); - return { - left: this.scrollView.options.scrollingX ? - calculateMaxValue(distance, maxValues.left, isPercent) : - -1, - top: this.scrollView.options.scrollingY ? - calculateMaxValue(distance, maxValues.top, isPercent) : - -1 - }; - }; - }], + require: ['?^$ionicScroll', 'ionInfiniteScroll'], + template: '', + scope: true, + controller: '$ionInfiniteScroll', link: function($scope, $element, $attrs, ctrls) { - var scrollCtrl = ctrls[0]; var infiniteScrollCtrl = ctrls[1]; - var scrollView = infiniteScrollCtrl.scrollView = scrollCtrl.scrollView; - - $scope.icon = function() { - return angular.isDefined($attrs.icon) ? $attrs.icon : 'ion-loading-d'; - }; - - var onInfinite = function() { - $element[0].classList.add('active'); - infiniteScrollCtrl.isLoading = true; - $scope.load(); - }; - - var finishInfiniteScroll = function() { - $element[0].classList.remove('active'); - $timeout(function() { - scrollView.resize(); - checkBounds(); - }, 0, false); - infiniteScrollCtrl.isLoading = false; - }; - - $scope.$on('scroll.infiniteScrollComplete', function() { - finishInfiniteScroll(); - }); - - $scope.$on('$destroy', function() { - if(scrollCtrl && scrollCtrl.$element)scrollCtrl.$element.off('scroll', checkBounds); - }); - - var checkBounds = ionic.animationFrameThrottle(checkInfiniteBounds); - - //Check bounds on start, after scrollView is fully rendered - $timeout(checkBounds, 0, false); - scrollCtrl.$element.on('scroll', checkBounds); - - function checkInfiniteBounds() { - if (infiniteScrollCtrl.isLoading) return; - - var scrollValues = scrollView.getValues(); - var maxScroll = infiniteScrollCtrl.getMaxScroll(); - - if ((maxScroll.left !== -1 && scrollValues.left >= maxScroll.left) || - (maxScroll.top !== -1 && scrollValues.top >= maxScroll.top)) { - onInfinite(); + var scrollCtrl = infiniteScrollCtrl.scrollCtrl = ctrls[0]; + var jsScrolling = infiniteScrollCtrl.jsScrolling = !!scrollCtrl; + // if this view is not beneath a scrollCtrl, it can't be injected, proceed w/ native scrolling + if (jsScrolling) { + infiniteScrollCtrl.scrollView = scrollCtrl.scrollView; + } else { + // grabbing the scrollable element, to determine dimensions, and current scroll pos + var scrollEl = ionic.DomUtil.getParentOrSelfWithClass($element[0].parentNode,'overflow-scroll'); + infiniteScrollCtrl.scrollEl = scrollEl; + // if there's no scroll controller, and no overflow scroll div, infinite scroll wont work + if (!scrollEl) { + throw 'Infinite scroll must be used inside a scrollable div'; } } + //bind to appropriate scroll event + if (jsScrolling) { + $scope.scrollingType = 'js-scrolling'; + scrollCtrl.$element.on('scroll', infiniteScrollCtrl.checkBounds); + } else { + infiniteScrollCtrl.scrollEl.addEventListener('scroll', infiniteScrollCtrl.checkBounds); + } + // Optionally check bounds on start after scrollView is fully rendered + var doImmediateCheck = angular.isDefined($attrs.immediateCheck) ? $scope.$eval($attrs.immediateCheck) : true; + if (doImmediateCheck) { + $timeout(function() { infiniteScrollCtrl.checkBounds(); }); + } } }; }]); diff --git a/js/angular/service/ionicConfig.js b/js/angular/service/ionicConfig.js index d564f56f618..c451bf1f245 100644 --- a/js/angular/service/ionicConfig.js +++ b/js/angular/service/ionicConfig.js @@ -222,6 +222,9 @@ IonicModule form: { checkbox: PLATFORM }, + scrolling: { + jsScrolling: PLATFORM + }, tabs: { style: PLATFORM, position: PLATFORM @@ -262,6 +265,10 @@ IonicModule checkbox: 'circle' }, + scrolling: { + jsScrolling: true + }, + tabs: { style: 'standard', position: 'bottom' diff --git a/scss/_scaffolding.scss b/scss/_scaffolding.scss index 8ecdf1f2945..3ca318abbb8 100644 --- a/scss/_scaffolding.scss +++ b/scss/_scaffolding.scss @@ -261,25 +261,37 @@ body.grade-c { ion-infinite-scroll { height: 60px; width: 100%; - opacity: 0; + display: block; - @include transition(opacity 0.25s); +// @include transition(opacity 0.25s); @include display-flex(); @include flex-direction(row); @include justify-content(center); @include align-items(center); .icon { + color: #666666; font-size: 30px; color: $scroll-refresh-icon-color; + &:before{ + -webkit-transform: translate3d(0,0,0); + transform: translate3d(0,0,0); + } } + &:not(.active) .icon:before{ + -webkit-transform: translate3d(-1000px,0,0); + transform: translate3d(-1000px,0,0); - &.active { - opacity: 1; } } +// removing the animation when the spinner isn't shown +// this breaks up animations on iOS, so they are left with unnecessary reflows +body:not(.platform-ios) ion-infinite-scroll:not(.active) .icon{ + -webkit-animation: none; + animation:none; +} .overflow-scroll { overflow-x: hidden; diff --git a/test/unit/angular/directive/content.unit.js b/test/unit/angular/directive/content.unit.js index 6b6201ae5c0..bf22887f25b 100644 --- a/test/unit/angular/directive/content.unit.js +++ b/test/unit/angular/directive/content.unit.js @@ -1,13 +1,14 @@ describe('Ionic Content directive', function() { - var compile, scope, timeout, window; + var compile, scope, timeout, window, ionicConfig; beforeEach(module('ionic')); - beforeEach(inject(function($compile, $rootScope, $timeout, $window) { + beforeEach(inject(function($compile, $rootScope, $timeout, $window, $ionicConfig) { compile = $compile; scope = $rootScope; timeout = $timeout; window = $window; + ionicConfig = $ionicConfig; ionic.Platform.setPlatform('Android'); })); @@ -128,6 +129,12 @@ describe('Ionic Content directive', function() { expect(vals.top).toBe(300); }); + it('Should allow native scrolling to be set by $ionicConfig ', function() { + ionicConfig.scrolling.jsScrolling(false); + var element = compile('')(scope); + expect(element.hasClass('overflow-scroll')).toBe(true); + }); + }); /* Tests #555, #1155 */ describe('Ionic Content Directive scoping', function() { diff --git a/test/unit/angular/directive/infiniteScroll.unit.js b/test/unit/angular/directive/infiniteScroll.unit.js index 9b93b8d04c9..54692c35236 100644 --- a/test/unit/angular/directive/infiniteScroll.unit.js +++ b/test/unit/angular/directive/infiniteScroll.unit.js @@ -1,21 +1,17 @@ describe('ionicInfiniteScroll directive', function() { beforeEach(module('ionic')); - var scrollTopValue; - var scrollTopMaxValue; - var scrollLeftValue; - var scrollLeftMaxValue; + var scrollTopValue = 50; + var scrollTopMaxValue = 60; + var scrollLeftValue = 101; + var scrollLeftMaxValue = 121; var ctrl; - function setup(attrs, scopeProps, options) { + function setupJS(attrs, scopeProps, options) { var element; - scrollTopValue = 50; - scrollLeftValue = 60; - scrollLeftMaxValue = 101; - scrollTopMaxValue = 121; inject(function($rootScope, $compile) { var scope = $rootScope.$new(); angular.extend(scope, scopeProps || {}); - element = angular.element(''); + element = angular.element(''); ionic.animationFrameThrottle = function(cb) { return function() { cb(); }; }; element.data('$$ionicScrollController', { scrollView: { @@ -47,7 +43,40 @@ describe('ionicInfiniteScroll directive', function() { return element; } - it('should error if no ionicScroll parent', function() { + function setupNative(attrs, scopeProps, options) { + var element, parent; + inject(function($rootScope, $compile, $document) { + var scope = $rootScope.$new(); + angular.extend(scope, scopeProps || {}); + parent = angular.element(''); + if (options && !!options.scrollingX) parent[0].style['overflow-x'] ='scroll'; + if (options && !!options.scrollingY) parent[0].style['overflow-y'] ='scroll'; + element = parent.find('ion-infinite-scroll'); + ionic.animationFrameThrottle = function(cb) { return function() { cb(); }; }; + $compile(element)(scope); + ctrl = element.controller('ionInfiniteScroll'); + // create a fake scrollEl since they can't be faked if we're passing in scroll data + if (options) { + ctrl.scrollEl = {style:{ + 'overflow-x':'hidden', + 'overflow-y':'hidden' + }}; + if (!!options.scrollingX) ctrl.scrollEl.style['overflow-x'] ='scroll'; + if (!!options.scrollingY) ctrl.scrollEl.style['overflow-y'] ='scroll'; + ctrl.scrollEl.clientWidth = ctrl.scrollEl.scrollLeft = scrollLeftValue; + ctrl.scrollEl.clientHeight = ctrl.scrollEl.scrollTop = scrollTopValue; + ctrl.scrollEl.scrollWidth = scrollLeftMaxValue; + ctrl.scrollEl.scrollHeight = scrollTopMaxValue; + } + + scope.$apply(); + }); + + return element; + } + + it('should error if no ionicScroll or native scroll parent', function() { expect(function() { inject(function($compile, $rootScope) { $compile('')($rootScope.$new()); @@ -56,34 +85,42 @@ describe('ionicInfiniteScroll directive', function() { }); it('should not have class or be loading by default', function() { - var el = setup(); + var el = setupJS(); + expect(el.hasClass('active')).toBe(false); + expect(ctrl.isLoading).toBe(false); + + el = setupNative(); expect(el.hasClass('active')).toBe(false); expect(ctrl.isLoading).toBe(false); }); it('should unbind scroll event on destroy', function() { - var el = setup(); + var el = setupJS(); spyOn(el.controller('$ionicScroll').$element, 'off'); el.scope().$destroy(); expect(el.controller('$ionicScroll').$element.off).toHaveBeenCalledWith('scroll', jasmine.any(Function)); + el = setupNative(); + spyOn(ctrl.scrollEl, 'removeEventListener'); + el.scope().$destroy(); + expect(ctrl.scrollEl.removeEventListener).toHaveBeenCalled(); }); describe('icon', function() { it('should have default icon ion-loading-d', function() { - var el = setup(); + var el = setupJS(); var icon = angular.element(el[0].querySelector('.icon')); expect(icon.hasClass('ion-loading-d')).toBe(true); }); it('should allow icon attr blank', function() { - var el = setup('icon=""'); + var el = setupJS('icon=""'); var icon = angular.element(el[0].querySelector('.icon')); expect(icon.hasClass('ion-loading-d')).toBe(false); }); it('should allow interpolated icon attr', function() { - var el = setup('icon="{{someIcon}}"'); + var el = setupJS('icon="{{someIcon}}"'); var icon = angular.element(el[0].querySelector('.icon')); expect(icon.hasClass('ion-loading-d')).toBe(false); el.scope().$apply('someIcon = "super-icon"'); @@ -92,31 +129,49 @@ describe('ionicInfiniteScroll directive', function() { }); describe('getMaxScroll', function() { - [ { scrollingX: true, scrollingY: true, }, - { scrollingX: false, scrollingY: true }, - { scrollingX: true, scrollingY: false } + [{ scrollingX: true, scrollingY: true }, + { scrollingX: false, scrollingY: true }, + { scrollingX: true, scrollingY: false } ].forEach(function(opts) { - describe('with scrollingX='+opts.scrollingX+', scrollingY='+opts.scrollingY, function() { + describe('with scrollingX=' + opts.scrollingX + ', scrollingY=' + opts.scrollingY, function() { it('should default to 2.5%', function() { - var el = setup('', {}, opts); - expect(ctrl.getMaxScroll()).toEqual({ + setupJS('', {}, opts); + expect(ctrl.getJSMaxScroll()).toEqual({ + left: opts.scrollingX ? scrollLeftMaxValue * 0.975 : -1, + top: opts.scrollingY ? scrollTopMaxValue * 0.975 : -1 + }); + + setupNative('', {}, opts); + expect(ctrl.getNativeMaxScroll()).toEqual({ left: opts.scrollingX ? scrollLeftMaxValue * 0.975 : -1, top: opts.scrollingY ? scrollTopMaxValue * 0.975 : -1 }); }); it('should use attr.distance as number', function() { - var el = setup('distance=3', {}, opts); - expect(ctrl.getMaxScroll()).toEqual({ + setupJS('distance=3', {}, opts); + expect(ctrl.getJSMaxScroll()).toEqual({ + left: opts.scrollingX ? scrollLeftMaxValue - 3 : -1, + top: opts.scrollingY ? scrollTopMaxValue - 3 : -1 + }); + + setupNative('distance=3', {}, opts); + expect(ctrl.getNativeMaxScroll()).toEqual({ left: opts.scrollingX ? scrollLeftMaxValue - 3 : -1, top: opts.scrollingY ? scrollTopMaxValue - 3 : -1 }); }); it('should use attr.distance as percent', function() { - var el = setup('distance=5%', {}, opts); - expect(ctrl.getMaxScroll()).toEqual({ + setupJS('distance=5%', {}, opts); + expect(ctrl.getJSMaxScroll()).toEqual({ + left: opts.scrollingX ? scrollLeftMaxValue * 0.95 : -1, + top: opts.scrollingY ? scrollTopMaxValue * 0.95 : -1 + }); + + setupNative('distance=5%', {}, opts); + expect(ctrl.getNativeMaxScroll()).toEqual({ left: opts.scrollingX ? scrollLeftMaxValue * 0.95 : -1, top: opts.scrollingY ? scrollTopMaxValue * 0.95 : -1 }); @@ -129,34 +184,57 @@ describe('ionicInfiniteScroll directive', function() { describe('scroll event', function() { it('should do nothing if < left and top', function() { - var el = setup('on-infinite="foo=1"'); + var el = setupJS('on-infinite="foo=1"'); el.controller('$ionicScroll').$element.triggerHandler('scroll'); expect(el.hasClass('active')).toBe(false); expect(ctrl.isLoading).toBe(false); expect(el.scope().foo).not.toBe(1); + + var el = setupNative('on-infinite="foo=1"'); + var scrollEvent = new Event('scroll'); + ctrl.scrollEl.dispatchEvent(scrollEvent); + + expect(el.hasClass('active')).toBe(false); + expect(ctrl.isLoading).toBe(false); + expect(el.scope().foo).not.toBe(1); }); it('should add active and call attr.onInfinite if >= top', function() { - var el = setup('on-infinite="foo=1"'); + var el = setupJS('on-infinite="foo=1"'); scrollTopValue = scrollTopMaxValue; el.controller('$ionicScroll').$element.triggerHandler('scroll'); expect(el.hasClass('active')).toBe(true); expect(ctrl.isLoading).toBe(true); expect(el.scope().foo).toBe(1); + + scrollTopValue = scrollTopMaxValue; + var el = setupNative('on-infinite="foo=1"', {}, { scrollingX: true, scrollingY: true }); + ctrl.checkBounds(); + expect(el.hasClass('active')).toBe(true); + expect(ctrl.isLoading).toBe(true); + expect(el.scope().foo).toBe(1); }); it('should add active and call attr.onInfinite if >= left', function() { - var el = setup('on-infinite="foo=1"'); + var el = setupJS('on-infinite="foo=1"'); scrollLeftValue = scrollLeftMaxValue; el.controller('$ionicScroll').$element.triggerHandler('scroll'); expect(el.hasClass('active')).toBe(true); expect(ctrl.isLoading).toBe(true); expect(el.scope().foo).toBe(1); + + scrollLeftValue = scrollLeftMaxValue; + var el = setupNative('on-infinite="foo=1"', {}, { scrollingX: true, scrollingY: true }); + ctrl.checkBounds(); + + expect(el.hasClass('active')).toBe(true); + expect(ctrl.isLoading).toBe(true); + expect(el.scope().foo).toBe(1); }); it('should not run the event twice if isLoading is true', function() { var onScrollSpy = jasmine.createSpy('onInfiniteScroll'); - var el = setup('', { $onInfiniteScroll: onScrollSpy }); + var el = setupJS('', { $onInfiniteScroll: onScrollSpy }); scrollTopValue = scrollTopMaxValue; el.controller('$ionicScroll').$element.triggerHandler('scroll'); @@ -170,8 +248,24 @@ describe('ionicInfiniteScroll directive', function() { }); + it('should checkbounds on launch', inject(function($timeout) { + var el = setupJS(); + spyOn(el.controller('ionInfiniteScroll'),'checkBounds'); + expect(el.controller('ionInfiniteScroll').checkBounds).not.toHaveBeenCalled(); + $timeout.flush(); + expect(el.controller('ionInfiniteScroll').checkBounds).toHaveBeenCalled(); + })); + + it('should not checkbounds on launch if immediate-check=false', inject(function($timeout) { + var el = setupJS('immediate-check="false"'); + spyOn(el.controller('ionInfiniteScroll'),'checkBounds'); + expect(el.controller('ionInfiniteScroll').checkBounds).not.toHaveBeenCalled(); + $timeout.flush(); + expect(el.controller('ionInfiniteScroll').checkBounds).not.toHaveBeenCalled(); + })); + it('scroll.infiniteScrollComplete should work', inject(function($timeout) { - var el = setup(); + var el = setupJS(); ctrl.isLoading = true; el.addClass('active'); el.scope().$broadcast('scroll.infiniteScrollComplete'); @@ -181,5 +275,4 @@ describe('ionicInfiniteScroll directive', function() { $timeout.flush(); expect(el.controller('$ionicScroll').scrollView.resize).toHaveBeenCalled(); })); - });