📜 ⬆️ ⬇️

Smooth scroll on AngularJS using requestAnimationFrame + style tips

I had to write my smooth scrolling library for an Angular application. About what I did, and why I started it all - under the cut. Along the way, I’ll tell you about my favorite tricks for decorating modules for AngularJS.

Instead of introducing


Background: why another library?
There was a standard situation: I needed a smooth-scroll on a page with a minimalist Angular-application, and my internal perfectionist forbade me to pull for this jQuery. I made a `bower search smooth scroll`, I saw three or four angular lines there, of which a couple didn’t mean anything, in one last commit two years ago, and only one interested me: the last commit at that time was a week ago, version 2.0 .0 (and this is already talking about something) and, judging by the dock, it was just wonderful and perfectly suited to my needs (at least, by condition). He quickly connected and began to try - it does not work ... I carefully re-read the dock several times, I tried it and this way and that - it does not work ... Without thinking twice, I used the source code in the hope that there were mistakes in the dock, and I was horrified. My first thought was: “How could IT live to version 2.0.0 with a dozen contributors and such nonsense in the code?” Complete misunderstanding of the principles of Angular: even $ watch wasn’t elementary on the scrolling condition; directives are badly designed: incorrect and incomprehensible work with scope and attrs, arguments are incorrectly named; ignoring of dependency injection: global functions and variables are used everywhere, although the author himself has done the service for them, everywhere the global window and document are twitching; in a couple of places, the code is unreasonably wrapped in setTimeout: apparently, the author does not fully understand why this is necessary (because of this there was even a bug), and, again, there is $ timeout for this; Attributes in directives are used without prefixes (offset, duration ...), which can cause conflicts with other libs, etc. For those who are not afraid to look with their own eyes - a link at the end.

First of all, I quickly made a minimal pull request, especially without delving into all the code, so that at least something worked for me (I rewrote the directives completely), but when unpleasant bugs (jerky animation, triggering through time) got, I looked through the entire file and understood - to fix the situation, almost everything needs to be rewritten, and the author hardly ever will accept such a pull-request, plus - there were not enough sufficiently important features, and since I needed the scroll by the evening, I decided to quickly write your smooth-scroll version on Angular.


For a long time I could not decide on what to focus on in the article: either on the library itself, or on tips on style code, or on smooth animation and debugging ... As a result, I decided to write how to spell. So there will be a little bit of everything, alternately. Hope not to get confused.
')

Goals


  1. smooth scrolling of the page when the specified condition is met
  2. no additional dependencies (except AngularJS)
  3. use smooth scrolling requestAnimationFrame instead of setTimeout
  4. ability to customize: indent from the top of the screen after scrolling, animation duration, easing, delay, and also indicate the scrollback callback
  5. show your kung-fu your Angular-design style (suddenly someone will throw new ideas)
  6. Dilute holivar (maximum plan, if I have time to finish the article by Friday) :)

Go


(function() { //     IIFE,    global scope 'use strict' angular.module('StrongComponents.smoothScroll', []) //   .factory('Utils', Utils) //    .factory('stScroller', stScroller) // ,     .directive('stSmoothScroll', stSmoothScroll) //      }()); 

Here you can already notice one of my favorite features of the Javascript language - this is function hoisting, which allows me to focus all the ads as high as possible, and the implementation is at the bottom, so you can immediately imagine the structure of the module without looking at all the code (besides this, the attentive reader already here I noticed a great topic for holivar) .

In Utils, now there is only one function — extend, taken from the Angular sources and corrected so that undefined elements from src do not overwrite the corresponding elements from dst. In Angular turnips on github, there is a long time Issue on this topic, but there is no time to wait until the whole thing is fixed.

Code utils
  /** * Utils functions */ Utils.$inject = [] function Utils() { var service = { extend: extend } return service /** * Extends the destination object `dst` by copying own enumerable properties * from the `src` object(s) to `dst`. Undefined properties are not copyied. * (modified angular version) * * @param {Object} dst Destination object. * @param {...Object} src Source object(s). * @return {Object} Reference to `dst`. */ function extend(dst) { var objs = [].slice.call(arguments, 1), h = dst.$$hashKey for (var i = 0, ii = objs.length; i < ii; ++i) { var obj = objs[i] if (!angular.isObject(obj) && !angular.isFunction(obj)) continue var keys = Object.keys(obj) for (var j = 0, jj = keys.length; j < jj; j++) { var key = keys[j] var src = obj[key] if (!angular.isUndefined(src)) { dst[key] = src } } } if (h) { dst.$$hashKey = h } return dst } } 

Again function hoisting in all its glory.

Directive


Full directive code
  /** * Smooth scroll directive. */ stSmoothScroll.$inject = ['$document', '$rootScope', 'stScroller'] function stSmoothScroll($document, $rootScope, Scroller) { // subscribe to user scroll events to cancel auto scrollingj angular.forEach(['DOMMouseScroll', 'mousewheel', 'touchmove'], function(ev) { $document.on(ev, function(ev) { $rootScope.$broadcast('stSmoothScroll.documentWheel', angular.element(ev.target)) }) }) var directive = { restrict: 'A', scope: { stScrollIf: '=', stScrollDuration: '=', stScrollOffset: '=', stScrollCancelOnBounds: '=', stScrollDelay: '=', stScrollAfter: '&' }, link: link } return directive /** * Smooth scroll directive link function */ function link(scope, elem, attrs) { var scroller = null // stop scrolling if user scrolls the page himself var offDocumentWheel = $rootScope.$on('stSmoothScroll.documentWheel', function() { if (!!scroller) { scroller.cancel() } }) // unsubscribe scope.$on('$destroy', function() { offDocumentWheel() }) // init scrolling if (attrs.stScrollIf === undefined) { // no trigger specified, start scrolling immediatelly run() } else { // watch trigger and start scrolling, when it becomes `true` scope.$watch('stScrollIf', function(val) { if (!!val) run() }) } /** * Start scrolling, add callback */ function run() { scroller = new Scroller(elem[0], { duration: scope.stScrollDuration, offset: scope.stScrollOffset, easing: attrs.stScrollEasing, cancelOnBounds: scope.stScrollCancelOnBounds, delay: scope.stScrollDelay }) scroller.run().then(function() { // call `after` callback if (typeof scope.stScrollAfter === 'function') scope.stScrollAfter() // forget scroller scroller = null }) } } } 


Announcement

  /** * Smooth scroll directive. */ stSmoothScroll.$inject = ['$document', '$rootScope', 'stScroller'] function stSmoothScroll($document, $rootScope, Scroller) { ... } 


Directive Parameters

  function stSmoothScroll(...) { ... var directive = { restrict: 'A', scope: { stScrollIf: '=', stScrollDuration: '=', stScrollOffset: '=', stScrollCancelOnBounds: '=', stScrollDelay: '=', stScrollAfter: '&' }, link: link } return directive ... } 


Cancel automatic scrolling if the user himself “took the wheel”

  function stSmoothScroll(...) { angular.forEach(['DOMMouseScroll', 'mousewheel', 'touchmove'], function(ev) { $document.on(ev, function(ev) { $rootScope.$broadcast('stSmoothScroll.documentWheel', angular.element(ev.target)) }) }) var directive = {} return directive .... } 

Here we subscribe to all kinds of events that are generated by different browsers if the user starts scrolling the page himself. Note : this is not done in the link , but in the directive function itself, in order to have one single handler for all registered elements. A message to specific elements is sent via $ rootScope. $ Broadcast (...) .
Link function

  var offDocumentWheel = $rootScope.$on('stSmoothScroll.documentWheel', function() { if (!!scroller) { scroller.cancel() } }) scope.$on('$destroy', function() { offDocumentWheel() }) 

We subscribe to the sent message when the user himself starts to scroll the page to interrupt the automatic scroll, and do not subscribe to unsubscribe from it when the element is destroyed.
  if (attrs.stScrollIf === undefined) { run() } else { scope.$watch('stScrollIf', function(val) { if (!!val) run() }) } 

Check the trigger. If it is not specified in the attributes, then we scroll right away; otherwise, we wait for it to become true . We refer to attrs to check for an attribute in the element. (I hope we avoid the typeof and "undefined" discussions, not the case)

  function run() { scroller = new Scroller(elem[0], { duration: scope.stScrollDuration, offset: scope.stScrollOffset, easing: attrs.stScrollEasing, cancelOnBounds: scope.stScrollCancelOnBounds, delay: scope.stScrollDelay }) scroller.run().then(function() { if (typeof scope.stScrollAfter === 'function') scope.stScrollAfter() scroller = null }) } 

Actually, the immediate launch of the scroll. We transfer "without looking" all the parameters from the scope to the service. We subscribe to the end of scrolling, call the callback specified in the attributes ( stScroller.run () returns Promise) and clear the variable.

It was a very simple directive. The most interesting thing in our scrolling service. Let's go further!

Service


Full service code
  /** * Smooth scrolling manager */ stScroller.$inject = ['$window', '$document', '$timeout', '$q', 'Utils'] function stScroller($window, $document, $timeout, $q, Utils) { var body = $document.find('body')[0] /** * Smooth scrolling manager constructor * @param {DOM Element} elem Element which window must be scrolled to * @param {Object} opts Scroller options */ function Scroller(elem, opts) { this.opts = Utils.extend({ duration: 500, offset: 100, easing: 'easeInOutCubic', cancelOnBounds: true, delay: 0 }, opts) this.elem = elem this.startTime = null this.framesCount = 0 this.frameRequest = null this.startElemOffset = elem.getBoundingClientRect().top this.endElemOffset = this.opts.offset this.isUpDirection = this.startElemOffset > this.endElemOffset this.curElemOffset = null this.curWindowOffset = null this.donePromise = $q.defer() // this promise is resolved when scrolling is done } Scroller.prototype = { run: run, done: done, animationFrame: animationFrame, requestNextFrame: requestNextFrame, cancel: cancel, isElemReached: isElemReached, isWindowBoundReached: isWindowBoundReached, getEasingRatio: getEasingRatio } return Scroller /** * Run smooth scroll * @return {Promise} A promise which is resolved when scrolling is done */ function run() { $timeout(angular.bind(this, this.requestNextFrame), +this.opts.delay) return this.donePromise.promise } /** * Add scrolling done callback * @param {Function} cb */ function done(cb) { if (typeof cb !== 'function') return this.donePromise.promise.then(cb) } /** * Scrolling animation frame. * Calculate new element and window offsets, scroll window, * request next animation frame, check cancel conditions * @param {DOMHighResTimeStamp or Unix timestamp} time */ function animationFrame(time) { this.requestNextFrame() // set startTime if (this.framesCount++ === 0) { this.startTime = time this.curElemOffset = this.elem.getBoundingClientRect().top this.curWindowOffset = $window.pageYOffset } var timeLapsed = time - this.startTime, perc = timeLapsed / this.opts.duration, newOffset = this.startElemOffset + (this.endElemOffset - this.startElemOffset) * this.getEasingRatio(perc) this.curWindowOffset += this.curElemOffset - newOffset this.curElemOffset = newOffset $window.scrollTo(0, this.curWindowOffset) if (timeLapsed >= this.opts.duration || this.isElemReached() || this.isWindowBoundReached()) { this.cancel() } } /** * Request next animation frame for scrolling */ function requestNextFrame() { this.frameRequest = $window.requestAnimationFrame( angular.bind(this, this.animationFrame)) } /** * Cancel next animation frame, resolve done promise */ function cancel() { cancelAnimationFrame(this.frameRequest) this.donePromise.resolve() } /** * Check if element is reached already * @return {Boolean} */ function isElemReached() { if (this.curElemOffset === null) return false return this.isUpDirection ? this.curElemOffset <= this.endElemOffset : this.curElemOffset >= this.endElemOffset } /** * Check if window bound is reached * @return {Boolean} */ function isWindowBoundReached() { if (!this.opts.cancelOnBounds) { return false } return this.isUpDirection ? body.scrollHeight <= this.curWindowOffset + $window.innerHeight : this.curWindowOffset <= 0 } /** * Return the easing ratio * @param {Number} perc Animation done percentage * @return {Float} Calculated easing ratio */ function getEasingRatio(perc) { switch(this.opts.easing) { case 'easeInQuad': return perc * perc; // accelerating from zero velocity case 'easeOutQuad': return perc * (2 - perc); // decelerating to zero velocity case 'easeInOutQuad': return perc < 0.5 ? 2 * perc * perc : -1 + (4 - 2 * perc) * perc; // acceleration until halfway, then deceleration case 'easeInCubic': return perc * perc * perc; // accelerating from zero velocity case 'easeOutCubic': return (--perc) * perc * perc + 1; // decelerating to zero velocity case 'easeInOutCubic': return perc < 0.5 ? 4 * perc * perc * perc : (perc - 1) * (2 * perc - 2) * (2 * perc - 2) + 1; // acceleration until halfway, then deceleration case 'easeInQuart': return perc * perc * perc * perc; // accelerating from zero velocity case 'easeOutQuart': return 1 - (--perc) * perc * perc * perc; // decelerating to zero velocity case 'easeInOutQuart': return perc < 0.5 ? 8 * perc * perc * perc * perc : 1 - 8 * (--perc) * perc * perc * perc; // acceleration until halfway, then deceleration case 'easeInQuint': return perc * perc * perc * perc * perc; // accelerating from zero velocity case 'easeOutQuint': return 1 + (--perc) * perc * perc * perc * perc; // decelerating to zero velocity case 'easeInOutQuint': return perc < 0.5 ? 16 * perc * perc * perc * perc * perc : 1 + 16 * (--perc) * perc * perc * perc * perc; // acceleration until halfway, then deceleration default: return perc; } } } 


It was decided to arrange the service in the form of a “class” (do not hit me, I understand everything). The constructor sets the initial values ​​of the properties needed for smooth scrolling. Special attention is given to setting default values ​​for scroll options:

  this.opts = Utils.extend({ duration: 500, offset: 100, easing: 'easeInOutCubic', cancelOnBounds: true, delay: 0 }, opts) 

The extend function corrected above allows defining default values ​​that will not be erased if the corresponding options were not specified in the element attributes.

Setting initial values
  this.elem = elem this.startTime = null this.framesCount = 0 this.frameRequest = null this.startElemOffset = elem.getBoundingClientRect().top this.endElemOffset = this.opts.offset this.isUpDirection = this.startElemOffset > this.endElemOffset this.curElemOffset = null this.curWindowOffset = null this.donePromise = $q.defer() //      resolve,    


Methods

  Scroller.prototype = { run: run, //   done: done, //   animationFrame: animationFrame, //    requestNextFrame: requestNextFrame, //    cancel: cancel, //    isElemReached: isElemReached, //     isWindowBoundReached: isWindowBoundReached, //       getEasingRatio: getEasingRatio //   easing- } 

I repeat: function hoisting allows you to succinctly describe the entire prototype. The person reading the code can immediately imagine how the object works without flipping through the entire file in search of ads.

We now turn to interesting points of implementation.

Everything starts with the run method, in which the first frame of the animation is requested, and at the same time the scroll delay specified in the options is processed:

  function run() { $timeout(angular.bind(this, this.requestNextFrame), +this.opts.delay) return this.donePromise.promise } .... function requestNextFrame() { this.frameRequest = $window.requestAnimationFrame( angular.bind(this, this.animationFrame)) } function cancel() { cancelAnimationFrame(this.frameRequest) this.donePromise.resolve() } 

This method returns promis so that the “user” has the ability to subscribe to the end of the animation (for example, I use this to set the focus in the input after the scrolling is completed, to avoid jerking, since different browsers will scroll the page differently when outside the screen).

The requestNextFrame method requests a new animation frame and saves its identifier so that it can be canceled in the cancel method.

The cancel method, in addition to canceling the next frame, resolves a callback.

It is time to move to the place where all the magic of smooth scrolling takes place - the animationFrame method:

All method code
  function animationFrame(time) { this.requestNextFrame() // set startTime if (this.framesCount++ === 0) { this.startTime = time this.curElemOffset = this.elem.getBoundingClientRect().top this.curWindowOffset = $window.pageYOffset } var timeLapsed = time - this.startTime, perc = timeLapsed / this.opts.duration, newOffset = this.startElemOffset + (this.endElemOffset - this.startElemOffset) * this.getEasingRatio(perc) this.curWindowOffset += this.curElemOffset - newOffset this.curElemOffset = newOffset $window.scrollTo(0, this.curWindowOffset) if (timeLapsed >= this.opts.duration || this.isElemReached() || this.isWindowBoundReached()) { this.cancel() } } 


The first line of the method calls requestNextFrame to request the next frame of the animation as soon as possible. And then there are two tricks:

  if (this.framesCount++ === 0) { this.startTime = time this.curElemOffset = this.elem.getBoundingClientRect().top this.curWindowOffset = $window.pageYOffset } 


Then everything is simple:

  var timeLapsed = time - this.startTime, perc = timeLapsed / this.opts.duration, newOffset = this.startElemOffset + (this.endElemOffset - this.startElemOffset) * this.getEasingRatio(perc) this.curWindowOffset += this.curElemOffset - newOffset this.curElemOffset = newOffset $window.scrollTo(0, this.curWindowOffset) if (timeLapsed >= this.opts.duration || this.isElemReached() || this.isWindowBoundReached()) { this.cancel() } 

Calculate the time and percentage of completion of the animation, as well as the new position of the element and screen. The scroll to the calculated position is called and the conditions for ending the animation are checked.

Results


A module written in a couple of hours does not have the disadvantages of either criticized in the introduction: the animation is smooth, the minimum necessary functionality is present.

There is still something to do:


Requests


I poured everything on the githab in an untouched form and ask those who understand licenses and “other open-end assets” to suggest and help to arrange this matter correctly:


Proofs and links



Thank you all for your attention!

Source: https://habr.com/ru/post/270245/


All Articles