diff --git a/config/build.config.js b/config/build.config.js index ee9580e2898..6d0cb27eee3 100644 --- a/config/build.config.js +++ b/config/build.config.js @@ -55,6 +55,7 @@ module.exports = { // Views 'js/views/view.js', 'js/views/scrollView.js', + 'js/views/scrollViewNative.js', 'js/views/listView.js', 'js/views/modalView.js', 'js/views/sideMenuView.js', diff --git a/js/angular/controller/refresherController.js b/js/angular/controller/refresherController.js index 2d3ade2685b..5c5892531b8 100644 --- a/js/angular/controller/refresherController.js +++ b/js/angular/controller/refresherController.js @@ -238,8 +238,8 @@ IonicModule scrollParent = $element.parent().parent()[0]; scrollChild = $element.parent()[0]; - if (!scrollParent.classList.contains('ionic-scroll') || - !scrollChild.classList.contains('scroll')) { + if (!scrollParent || !scrollParent.classList.contains('ionic-scroll') || + !scrollChild || !scrollChild.classList.contains('scroll')) { throw new Error('Refresher must be immediate child of ion-content or ion-scroll'); } diff --git a/js/angular/controller/scrollController.js b/js/angular/controller/scrollController.js index 1f86c06680e..7ee055b0784 100644 --- a/js/angular/controller/scrollController.js +++ b/js/angular/controller/scrollController.js @@ -26,10 +26,19 @@ function($scope, self.__timeout = $timeout; self._scrollViewOptions = scrollViewOptions; //for testing + self.isNative = function() { + return !!scrollViewOptions.nativeScrolling; + }; var element = self.element = scrollViewOptions.el; var $element = self.$element = jqLite(element); - var scrollView = self.scrollView = new ionic.views.Scroll(scrollViewOptions); + var scrollView; + if (self.isNative()) { + scrollView = self.scrollView = new ionic.views.ScrollNative(scrollViewOptions); + } else { + scrollView = self.scrollView = new ionic.views.Scroll(scrollViewOptions); + } + //Attach self to element as a controller so other directives can require this controller //through `require: '$ionicScroll' diff --git a/js/angular/directive/content.js b/js/angular/directive/content.js index adfe2c404ff..b5260db175c 100644 --- a/js/angular/directive/content.js +++ b/js/angular/directive/content.js @@ -109,37 +109,55 @@ function($timeout, $controller, $ionicBind, $ionicConfig) { if ($attr.scroll === "false") { //do nothing - } else if (attr.overflowScroll === "true" || !$ionicConfig.scrolling.jsScrolling()) { - // use native scrolling - $element.addClass('overflow-scroll'); } else { - var scrollViewOptions = { - el: $element[0], - delegateHandle: attr.delegateHandle, - locking: (attr.locking || 'true') === 'true', - bouncing: $scope.$eval($scope.hasBouncing), - startX: $scope.$eval($scope.startX) || 0, - startY: $scope.$eval($scope.startY) || 0, - scrollbarX: $scope.$eval($scope.scrollbarX) !== false, - scrollbarY: $scope.$eval($scope.scrollbarY) !== false, - scrollingX: $scope.direction.indexOf('x') >= 0, - scrollingY: $scope.direction.indexOf('y') >= 0, - scrollEventInterval: parseInt($scope.scrollEventInterval, 10) || 10, - scrollingComplete: function() { - $scope.$onScrollComplete({ - scrollTop: this.__scrollTop, - scrollLeft: this.__scrollLeft - }); - } - }; + var scrollViewOptions = {}; + + if (attr.overflowScroll === "true" || !$ionicConfig.scrolling.jsScrolling()) { + // use native scrolling + $element.addClass('overflow-scroll'); + + scrollViewOptions = { + el: $element[0], + delegateHandle: attr.delegateHandle, + startX: $scope.$eval($scope.startX) || 0, + startY: $scope.$eval($scope.startY) || 0, + nativeScrolling:true + }; + + } else { + // Use JS scrolling + scrollViewOptions = { + el: $element[0], + delegateHandle: attr.delegateHandle, + locking: (attr.locking || 'true') === 'true', + bouncing: $scope.$eval($scope.hasBouncing), + startX: $scope.$eval($scope.startX) || 0, + startY: $scope.$eval($scope.startY) || 0, + scrollbarX: $scope.$eval($scope.scrollbarX) !== false, + scrollbarY: $scope.$eval($scope.scrollbarY) !== false, + scrollingX: $scope.direction.indexOf('x') >= 0, + scrollingY: $scope.direction.indexOf('y') >= 0, + scrollEventInterval: parseInt($scope.scrollEventInterval, 10) || 10, + scrollingComplete: function() { + $scope.$onScrollComplete({ + scrollTop: this.__scrollTop, + scrollLeft: this.__scrollLeft + }); + } + }; + } + + // init scroll controller with appropriate options $controller('$ionicScroll', { $scope: $scope, scrollViewOptions: scrollViewOptions }); $scope.$on('$destroy', function() { - scrollViewOptions.scrollingComplete = noop; - delete scrollViewOptions.el; + if (scrollViewOptions) { + scrollViewOptions.scrollingComplete = noop; + delete scrollViewOptions.el; + } innerElement = null; $element = null; attr.$$element = null; diff --git a/js/angular/directive/infiniteScroll.js b/js/angular/directive/infiniteScroll.js index b76f5780ff0..b23933f0d1b 100644 --- a/js/angular/directive/infiniteScroll.js +++ b/js/angular/directive/infiniteScroll.js @@ -79,10 +79,14 @@ IonicModule link: function($scope, $element, $attrs, ctrls) { var infiniteScrollCtrl = ctrls[1]; var scrollCtrl = infiniteScrollCtrl.scrollCtrl = ctrls[0]; - var jsScrolling = infiniteScrollCtrl.jsScrolling = !!scrollCtrl; + var jsScrolling = infiniteScrollCtrl.jsScrolling = !scrollCtrl.isNative(); + // if this view is not beneath a scrollCtrl, it can't be injected, proceed w/ native scrolling if (jsScrolling) { infiniteScrollCtrl.scrollView = scrollCtrl.scrollView; + $scope.scrollingType = 'js-scrolling'; + //bind to JS scroll events + scrollCtrl.$element.on('scroll', infiniteScrollCtrl.checkBounds); } else { // grabbing the scrollable element, to determine dimensions, and current scroll pos var scrollEl = ionic.DomUtil.getParentOrSelfWithClass($element[0].parentNode,'overflow-scroll'); @@ -91,14 +95,10 @@ IonicModule 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 { + //bind to native scroll events infiniteScrollCtrl.scrollEl.addEventListener('scroll', infiniteScrollCtrl.checkBounds); } + // Optionally check bounds on start after scrollView is fully rendered var doImmediateCheck = isDefined($attrs.immediateCheck) ? $scope.$eval($attrs.immediateCheck) : true; if (doImmediateCheck) { diff --git a/js/angular/directive/refresher.js b/js/angular/directive/refresher.js index 06328e1bc8b..b564ddaa1ac 100644 --- a/js/angular/directive/refresher.js +++ b/js/angular/directive/refresher.js @@ -87,10 +87,11 @@ IonicModule // JS Scrolling uses the scroll controller var scrollCtrl = ctrls[0], refresherCtrl = ctrls[1]; - - if (!!scrollCtrl) { + if (!scrollCtrl || scrollCtrl.isNative()) { + // Kick off native scrolling + refresherCtrl.init(); + } else { $element[0].classList.add('js-scrolling'); - scrollCtrl._setRefresher( $scope, $element[0], @@ -102,10 +103,6 @@ IonicModule scrollCtrl.scrollView.finishPullToRefresh(); }); }); - - } else { - // Kick off native scrolling - refresherCtrl.init(); } } diff --git a/js/views/scrollViewNative.js b/js/views/scrollViewNative.js new file mode 100644 index 00000000000..3503d4d4bd5 --- /dev/null +++ b/js/views/scrollViewNative.js @@ -0,0 +1,344 @@ +(function(ionic) { + var NOOP = function() {}; + var depreciated = function(name) { + console.error('Method not available in native scrolling: ' + name); + }; + ionic.views.ScrollNative = ionic.views.View.inherit({ + + initialize: function(options) { + var self = this; + self.__container = self.el = options.el; + self.__content = options.el.firstElementChild; + self.isNative = true; + + self.__scrollTop = self.el.scrollTop; + self.__scrollLeft = self.el.scrollLeft; + self.__clientHeight = self.__content.clientHeight, + self.__clientWidth = self.__content.clientWidth, + self.__maxScrollTop = Math.max((self.__contentHeight) - self.__clientHeight, 0), + self.__maxScrollLeft = Math.max((self.__contentWidth) - self.__clientWidth, 0), + + self.options = { + + freeze: false, + + getContentWidth: function() { + return Math.max(self.__content.scrollWidth, self.__content.offsetWidth); + }, + + getContentHeight: function() { + return Math.max(self.__content.scrollHeight, self.__content.offsetHeight + (self.__content.offsetTop * 2)); + } + + }; + + for (var key in options) { + self.options[key] = options[key]; + } + + /** + * Sets isScrolling to true, and automatically deactivates if not called again in 80ms. + */ + self.onScroll = function(event) { + if (!ionic.scroll.isScrolling) { + ionic.scroll.isScrolling = true; + } + + clearTimeout(self.scrollTimer); + self.scrollTimer = setTimeout(function() { + ionic.scroll.isScrolling = false; + }, 80); + }; + + self.freeze = NOOP; + + self.__initEventHandlers(); + }, + + /** Methods not used in native scrolling */ + __callback: function() {depreciated('__callback');}, + zoomTo: function() {depreciated('zoomTo');}, + zoomBy: function() {depreciated('zoomBy');}, + activatePullToRefresh: function() {depreciated('activatePullToRefresh');}, + + /** + * Returns the scroll position and zooming values + * + * @return {Map} `left` and `top` scroll position and `zoom` level + */ + resize: function(continueScrolling) { + var self = this; + if (!self.__container || !self.options) return; + + // Update Scroller dimensions for changed content + // Add padding to bottom of content + self.setDimensions( + self.__container.clientWidth, + self.__container.clientHeight, + self.options.getContentWidth(), + self.options.getContentHeight(), + continueScrolling + ); + }, + + /** + * Initialize the scrollview + * In native scrolling, this only means we need to gather size information + */ + run: function() { + this.resize(); + }, + + /** + * Returns the scroll position and zooming values + * + * @return {Map} `left` and `top` scroll position and `zoom` level + */ + getValues: function() { + var self = this; + self.update(); + return { + left: self.__scrollLeft, + top: self.__scrollTop, + zoom: 1 + }; + }, + + /** + * Updates the __scrollLeft and __scrollTop values to el's current value + */ + update: function() { + var self = this; + self.__scrollLeft = self.el.scrollLeft; + self.__scrollTop = self.el.scrollTop; + }, + + /** + * Configures the dimensions of the client (outer) and content (inner) elements. + * Requires the available space for the outer element and the outer size of the inner element. + * All values which are falsy (null or zero etc.) are ignored and the old value is kept. + * + * @param clientWidth {Integer} Inner width of outer element + * @param clientHeight {Integer} Inner height of outer element + * @param contentWidth {Integer} Outer width of inner element + * @param contentHeight {Integer} Outer height of inner element + */ + setDimensions: function(clientWidth, clientHeight, contentWidth, contentHeight) { + var self = this; + + if (!clientWidth && !clientHeight && !contentWidth && !contentHeight) { + // this scrollview isn't rendered, don't bother + return; + } + + // Only update values which are defined + if (clientWidth === +clientWidth) { + self.__clientWidth = clientWidth; + } + + if (clientHeight === +clientHeight) { + self.__clientHeight = clientHeight; + } + + if (contentWidth === +contentWidth) { + self.__contentWidth = contentWidth; + } + + if (contentHeight === +contentHeight) { + self.__contentHeight = contentHeight; + } + + // Refresh maximums + self.__computeScrollMax(); + }, + + /** + * Returns the maximum scroll values + * + * @return {Map} `left` and `top` maximum scroll values + */ + getScrollMax: function() { + return { + left: this.__maxScrollLeft, + top: this.__maxScrollTop + }; + }, + + /** + * Scrolls by the given amount in px. + * + * @param left {Number} Horizontal scroll position, keeps current if value is null + * @param top {Number} Vertical scroll position, keeps current if value is null + * @param animate {Boolean} Whether the scrolling should happen using an animation + */ + + scrollBy: function(left, top, animate) { + var self = this; + + // update scroll vars before refferencing them + self.update(); + + var startLeft = self.__isAnimating ? self.__scheduledLeft : self.__scrollLeft; + var startTop = self.__isAnimating ? self.__scheduledTop : self.__scrollTop; + + self.scrollTo(startLeft + (left || 0), startTop + (top || 0), animate); + }, + + /** + * Scrolls to the given position in px. + * + * @param left {Number} Horizontal scroll position, keeps current if value is null + * @param top {Number} Vertical scroll position, keeps current if value is null + * @param animate {Boolean} Whether the scrolling should happen using an animation + */ + scrollTo: function(left, top, animate) { + var self = this; + if (!animate) { + self.el.scrollTop = top; + self.el.scrollLeft = left; + self.resize(); + return; + } + animateScroll(top, left); + + function animateScroll(Y, X) { + // scroll animation loop w/ easing + // credit https://gist.github.com/dezinezync/5487119 + var start = Date.now(), + duration = 1000, //milliseconds + fromY = self.el.scrollTop, + fromX = self.el.scrollLeft; + + if (fromY === Y && fromX === X) { + self.resize(); + return; /* Prevent scrolling to the Y point if already there */ + } + + // decelerating to zero velocity + function easeOutCubic(t) { + return (--t) * t * t + 1; + } + + // scroll loop + function animateScrollStep() { + var currentTime = Date.now(), + time = Math.min(1, ((currentTime - start) / duration)), + // where .5 would be 50% of time on a linear scale easedT gives a + // fraction based on the easing method + easedT = easeOutCubic(time); + + if (fromY != Y) { + self.el.scrollTop = parseInt((easedT * (Y - fromY)) + fromY, 10); + } + if (fromX != X) { + self.el.scrollLeft = parseInt((easedT * (X - fromX)) + fromX, 10); + } + + if (time < 1) { + ionic.requestAnimationFrame(animateScrollStep); + + } else { + // done + self.resize(); + } + } + + // start scroll loop + ionic.requestAnimationFrame(animateScrollStep); + } + }, + + + + /* + --------------------------------------------------------------------------- + PRIVATE API + --------------------------------------------------------------------------- + */ + + /** + * If the scroll view isn't sized correctly on start, wait until we have at least some size + */ + __waitForSize: function() { + var self = this; + + clearTimeout(self.__sizerTimeout); + + var sizer = function() { + self.resize(true); + }; + + sizer(); + self.__sizerTimeout = setTimeout(sizer, 500); + }, + + + /** + * Recomputes scroll minimum values based on client dimensions and content dimensions. + */ + __computeScrollMax: function() { + var self = this; + + self.__maxScrollLeft = Math.max((self.__contentWidth) - self.__clientWidth, 0); + self.__maxScrollTop = Math.max((self.__contentHeight) - self.__clientHeight, 0); + + if (!self.__didWaitForSize && !self.__maxScrollLeft && !self.__maxScrollTop) { + self.__didWaitForSize = true; + self.__waitForSize(); + } + }, + + __initEventHandlers: function() { + var self = this; + + // Event Handler + var container = self.__container; + + // should be unnecessary in native scrolling, but keep in case bugs show up + self.scrollChildIntoView = NOOP; + + self.resetScrollView = function(e) { + //return scrollview to original height once keyboard has hidden + if (self.isScrolledIntoView) { + self.isScrolledIntoView = false; + container.style.height = ""; + container.style.overflow = ""; + self.resize(); + ionic.scroll.isScrolling = false; + } + }; + + container.addEventListener('resetScrollView', self.resetScrollView); + container.addEventListener('scroll', self.onScroll); + + //Broadcasted when keyboard is shown on some platforms. + //See js/utils/keyboard.js + container.addEventListener('scrollChildIntoView', self.scrollChildIntoView); + container.addEventListener('resetScrollView', self.resetScrollView); + }, + + __cleanup: function() { + var self = this; + var container = self.__container; + + container.removeEventListener('resetScrollView', self.resetScrollView); + container.removeEventListener('scroll', self.onScroll); + + container.removeEventListener('scrollChildIntoView', self.scrollChildIntoView); + container.removeEventListener('resetScrollView', self.resetScrollView); + + ionic.tap.removeClonedInputs(container, self); + + delete self.__container; + delete self.__content; + delete self.__indicatorX; + delete self.__indicatorY; + delete self.options.el; + + self.resize = self.scrollTo = self.onScroll = self.resetScrollView = NOOP; + container = null; + } + }); + +})(ionic); + diff --git a/test/html/infinite-scroll.html b/test/html/infinite-scroll.html index c060c122415..0527ba616b6 100644 --- a/test/html/infinite-scroll.html +++ b/test/html/infinite-scroll.html @@ -25,7 +25,9 @@