Smooth scroll on AngularJS using requestAnimationFrame + style tips

    I had to write my smooth scroll library for an Angular application. About what I did, and why I started it at all - under the cut. Along the way, I will talk about my favorite tricks for designing modules for AngularJS.

    Instead of introducing


    Background: Why Another Library?
    A standard situation happened: I needed a smooth-scroll on a page with a minimal Angular application, and my internal perfectionist forbade me to pull jQuery for this. I did a `bower search smooth scroll`, I saw three or four libs there for Angular, of which a couple weren’t talking about the 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 already says something) and, judging by the dock, she was just wonderful and perfectly suited my needs (at least scrolling by condition). He quickly connected and began to try - it doesn’t work ... Several times he carefully re-read the dock, tried it this way and that - it doesn’t work ... Without thinking twice, he climbed into the source in the hope that there were mistakes in the dock and was horrified. My first thought was: “How IT could survive to version 2.0. 0 with a dozen contributors and such nonsense in the code? ” A complete misunderstanding of the principles of Angular: even $ watch was not elementary on the condition of scrolling; the directives are horribly framed: incorrect and incomprehensible work with scope and attrs, arguments are incorrectly named; ignoring dependency injection: global functions and variables are used everywhere, although the author himself made a service for them, global window and document twitch everywhere; 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 the minimum pull request, without particularly delving into the whole code, so that at least something worked (I completely rewrote the directives), but when I got into unpleasant bugs (twitching animation, triggering once), I looked at the whole file and I realized - to fix the situation, you need to rewrite almost everything, and the author is unlikely to ever accept such a pull request, plus - there were not enough important features there, and since I needed the scroll by the evening, I decided to write quickly own version of smooth-scroll on Angular.


    For a long time I could not decide what to focus on in the article: either on the library itself, or on tips on the style of the code, or on smooth animation and its debugging ... In the end, I decided to write how to spell it. So there will be little by little, interspersed. I hope we don’t get confused.

    Goals


    1. smooth scrolling of the page when the specified condition is met
    2. lack of additional dependencies (except for AngularJS)
    3. use to smoothly scroll requestAnimationFrame instead of setTimeout
    4. the ability to customize: indent from the top of the screen after scrolling, animation duration, easing, delay, and also indicate the callback of scrolling completion
    5. show your kung fu your style of Angular-modules (suddenly someone will throw new ideas)
    6. dilute holivar (maximum plan, if I have time to finish writing 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 concentrate all the declarations as high as possible, and the implementation below, so you can immediately imagine the structure of the module without looking at all the code (in addition, the attentive reader already here I noticed a wonderful topic for holivar) .

    Utils now has only one function - extend, taken from Angular sources and corrected so that undefined elements from src do not overwrite the corresponding elements from dst. There has been a Issue on Angitha on github for a long time, but there is no time to wait for the whole thing to be fixed.

    Utils Code
        /**
         * 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
                    })
                }
            }
        }
    


    Ad

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

    • always write docstring before defining a function: this allows you to visually separate your code in addition to getting documentation
    • I like to use the funcName. $ inject = [...] construct for explicit dependency injection: this prevents the minification problem described a thousand times, plus - it allows renaming embedded modules, as in this case - 'stScroller' -> Scroller

    Directive parameters

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

    • again, using function hoisting, we immediately set up the directive and return the object, but we will deal with the implementation later, and return is not a hindrance to us
    • all directive attributes are prefixed with st-scroll to avoid conflicts with other libraries
    • in scope, we define several settings, the main of which is st-scroll-if - a trigger to start scrolling, and one callback

    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, which are generated by different browsers, if the user starts to scroll the page. Please note : this is done not in link , but in the directive function itself, in order to have one single handler for all registered elements. A message is sent to specific elements 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 message when the user starts to scroll the page to interrupt the automatic scroll, and we don’t ask 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 immediately, otherwise, we wait for it to become true . Referring to attrs to check for an attribute in an element. (I hope we avoid discussing typeof and "undefined" , 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 direct launch of the scroll. We pass “without looking” all the parameters from scope to the service. We subscribe to the completion of scrolling, call the callback specified in the attributes ( stScroller.run () returns Promise) and clear the variable.

    The result is a very simple directive. The most interesting thing in our scroll service. We are going 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 beat me, I understand everything). The constructor sets the initial values ​​of the properties needed for smooth scrolling. Of particular note is the setting of default values ​​for the scroll options:

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

    The extend function corrected above allows you to specify default values ​​that will not be overwritten 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. A person reading the code can immediately imagine how the object works without flipping through the entire file in search of ads.

    Now let's move on to the interesting points of implementation.

    It all 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 a promise so that the “user” has the opportunity to subscribe to the end of the animation (for example, I use this to set the focus to input after scrolling is completed, to avoid twitching, since different browsers scroll the page differently when focus is on the element for out of 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 the callback.

    It is time to go 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 animation frame as early 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
                }
    

    • in the zero frame we save the start time of the animation. This is necessary when using the requestAnimationFrame polyfile with a fallback on setTimeout . The fact is that these two options will transmit different times to the callback of the frame: in the first case it will be DOMHighResTimeStamp , and in the second - the usual Date . In all examples of using requestAnimationFrame with a polyfile, I saw how the authors initialize startTime before the start of the animation, while secondly figuring out which option will work, but I thought that you can not burden yourself with unnecessary conditions and just initialize startTime in a zero frame.
    • immediately initializes the current position of the element and the current position of the screen, which will change in subsequent frames. In the first implementation, this was not, and the current position was requested in each frame, but, as it turned out when debugging the animation, these requests force recalculation of the page layout, and we had to slightly revise the scroll algorithm to avoid brakes (proofs at the end)

    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()
                }
    

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

    Summary


    The module, written in a couple of hours, does not have the drawbacks of any criticized in the introduction: the animation is smooth, the minimum required functionality is present.

    There is still something to do:

    • write a normal README and make a page with a demo
    • make minification and drop the library in bower
    • get rid of a couple more forced recalculations of the page layout in terms of the end of scrolling
    • resolve the situation if a trigger for two or more elements fires at the same time

    Requests


    I poured everything on the github intact and I ask those who understand licenses and “other openness” to suggest and help to arrange this business correctly:

    • I copied the polyfile just to the top of the file. maybe it’s worth putting it into a separate file?
    • you need to choose a license for the lib itself and apply accordingly
    • was it possible to just copy and modify code from Angular?

    Proofs and links



    Thank you all for your attention!

    Also popular now: