5 November 2015

Smooth scroll на AngularJS с использованием requestAnimationFrame + советы по стилю

JavaScriptAngular
Мне пришлось написать свою библиотеку плавной прокрутки для Angular приложения. О том, что у меня получилось, и почему я это вообще затеял — под катом. Попутно расскажу о своих любимых приёмах оформления модулей для AngularJS.

Вместо введения


Предыстория: зачем еще одна библиотека?
Произошла стандартная ситуация: понадобился smooth-scroll на странице с минималистичным Angular-приложением, и мой внутренний перфекционист запретил мне тянуть для этого jQuery. Я сделал `bower search smooth scroll`, увидел там три-четыре либы для Angular, из которых парочка вообще не про то, в одной последний коммит двухлетней давности, и только одна меня заинтересовала: последний коммит на тот момент был неделю назад, версия 2.0.0 (а это уже о чем-то говорит) и, судя по доке, она была просто замечательная и отлично подходила под мои нужды (как минимум, скролл по условию). Быстро подключил и стал пробовать — не работает… Несколько раз внимательно перечитал доку, попробовал и так и сяк — не работает… Недолго думая, полез в исходники в надежде, что в доке допущены ошибки, и ужаснулся. Первая мысль была: «Как ЭТО смогло дожить до версии 2.0.0 с десятком контрибьюторов и таким бредом в коде?» Полное непонимание принципов Angular: элементарно даже $watch не было на условии скроллинга; директивы оформлены ужасно: неправильная и непонятная работа со scope и attrs, неправильно названы аргументы; игнорирование dependency injection: повсюду используются глобальные функции и переменные, хотя автор сам же для них сделал сервис, везде дёргаются глобальные window и document; в паре мест код необоснованно обёрнут в setTimeout: видимо, автор не до конца понимает, зачем это нужно (из-за этого даже был баг), и, опять же, для этого есть $timeout; атрибуты в директивах используются без префиксов (offset, duration...), что может вызвать коллизии с другими либами, и т.д. Для тех, кто не боится взглянуть своими глазами — линк в конце.

Первым делом я быстро сделал минимальный пулл-реквест, особо не вникая в весь код, чтобы у меня хоть что-то заработало (переписал полностью директивы), но когда полезли неприятные баги (дёрганая анимация, срабатывание через раз), я просмотрел весь файл и понял — чтобы исправить ситуацию, тут нужно переписать почти всё, и такой пулл-реквест вряд ли автор когда-то примет, плюс — там не хватало достаточно важных фич, и, так как скролл мне нужен был уже к вечеру, я решил быстро написать свой вариант smooth-scroll на Angular.


Долго не мог определиться, на чем акцентировать внимание в статье: либо на самой библиотеке, либо на советах по стилю кода, либо на плавной анимации и её отладке… В итоге решил писать, как пишется. Так что всего будет понемножку-вперемежку. Надеюсь, не запутаемся.

Цели


  1. плавная прокрутка страницы при выполнении заданного условия
  2. отсутствие дополнительных зависимостей (кроме AngularJS)
  3. использование для плавной прокрутки requestAnimationFrame вместо setTimeout
  4. возможность настраивать: отступ от верха экрана после прокрутки, длительность анимации, easing, задержку, а также указывать коллбэк завершения прокрутки
  5. показать своё кунг-фу свой стиль оформления Angular-модулей (вдруг кто-нибудь подкинет новые идеи)
  6. развести холивар (план-максимум, если успею дописать статью к пятнице) :)

Поехали


(function() {  // оборачиваем весь код в IIFE, дабы не засорять global scope
    'use strict'

    angular.module('StrongComponents.smoothScroll', [])  // создаем модуль
        .factory('Utils', Utils)                         // сервис с утилитами
        .factory('stScroller', stScroller)               // сервис, отвечающий за плавную прокрутку
        .directive('stSmoothScroll', stSmoothScroll)     // директива для задания параметров прокрутки
}());

Тут вы уже можете заметить одну из моих любимых особенностей языка Javascript — это function hoisting, которая позволяет мне сосредоточить все объявления как можно выше, а реализацию — внизу, так можно сразу представить себе структуру модуля, не просматривая весь код (помимо этого внимательный читатель уже тут заметил прекрасную тему для холивара).

В Utils сейчас только одна функция — extend, взятая из исходников Angular и исправленная так, чтобы undefined элементы из src не затирали соответствующие элементы из dst. В репе Angular на github давно есть Issue на эту тему, но ждать, когда всё это дело поправят, времени нет.

Код 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
        }
    }

Опять function hoisting во всей красе.

Директива


Полный код директивы
    /**
     * 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
                })

            }
        }
    }


Объявление

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

  • всегда пишите docstring перед определением функции: это позволяет помимо получения документации еще и визуально разделять Ваш код
  • я люблю пользоваться конструкцией funcName.$inject = [...] для явного внедрения зависимостей: это предотвращает уже тысячу раз описанную проблему с минификацией, плюс — позволяет переименовывать внедряемые модули, как в данном случае — 'stScroller' -> Scroller

Параметры директивы

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

        return directive
        ...
    }

  • опять же, пользуясь function hoisting, сразу настраиваем директиву и возвращаем объект, а с реализацией разберёмся позже, и return нам — не помеха
  • все атрибуты директивы имеют префикс st-scroll, чтобы избежать конфликтов с другими библиотеками
  • в scope мы определяем несколько настроек, главная из которых — st-scroll-if — триггер начала прокрутки, и один коллбэк

Отмена автоматической прокрутки, если пользователь сам «взялся за руль»

    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
        ....
    }

Здесь мы подписываемся на всевозможные евенты, которые генерируют разные браузеры, если пользователь сам начинает прокручивать страницу. Обратите внимание: это делается не в link, а в самой функции директивы, чтобы иметь один единственный обработчик для всех зарегистрированных элементов. Сообщение конкретным элементам рассылается посредством $rootScope.$broadcast(...).
Функция link

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

            scope.$on('$destroy', function() {
                offDocumentWheel()
            })

Подписываемся на рассылаемое сообщение, когда пользователь сам начинает прокручивать страницу, чтобы прервать автоматический скролл, и не заываем отписаться от него при разрушении элемента.
            if (attrs.stScrollIf === undefined) {
                run()
            } else {
                scope.$watch('stScrollIf', function(val) {
                    if (!!val) run()
                })
            }

Проверяем триггер. Если он не задан в атрибутах, то выполняем прокрутку сразу, иначе — ждём, когда он станет true. Обращаемя к attrs, чтобы проверить наличие атрибута в элементе. (Надеюсь, мы избежим обсуждения typeof и «undefined», не тот случай)

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

Собственно, непосредственный запуск прокрутки. Передаем «не глядя» все параметры из scope в сервис. Подписываемся на завершение прокрутки, вызываем указанный в атрибутах коллбэк (stScroller.run() возвращает Promise) и очищаем переменную.

Получилась очень простая директива. Самое интересное у нас в сервисе прокрутки. Едем дальше!

Сервис


Полный код сервиса
    /**
     * 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;
            }
        }
    }


Сервис было решено оформить в виде «класса» (не бейте меня, я всё понимаю). В конструкторе задаются начальные значения свойств, нужных для плавной прокрутки. Особого внимания стоит задание дефолтных значений для опций прокрутки:

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

Исправленная выше функция extend позволяет задать дефолтные значения, которые не будут затёрты, если в атрибутах элемента не были указаны соответствующие опции.

Задание начальных значений
            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, когда анимация завершится


Методы

        Scroller.prototype = {
            run: run,                                     // запуск анимации
            done: done,                                   // добавление коллбэка
            animationFrame: animationFrame,               // один фрейм анимации
            requestNextFrame: requestNextFrame,           // запрос следующего фрейма
            cancel: cancel,                               // отмена следующего фрейма
            isElemReached: isElemReached,                 // достигла ли прокрутка цели
            isWindowBoundReached: isWindowBoundReached,   // упёрлась ли прокрутка в край экрана
            getEasingRatio: getEasingRatio                // метод возвращает easing-коэффициент
        }

Повторюсь: function hoisting позволяет лаконично описать весь прототип. Человек, читающий код, может сразу себе представить, как работает объект, не листая весь файл в поисках объявлений.

Теперь перейдем к интересным моментам реализации.

Начинается всё с метода run, в котором запрашивается первый фрейм анимации, и заодно и обрабатывается задержка прокрутки, указанная в опциях:

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

Этот метод возвращает промис, чтобы у «пользователя» была возможность подписаться на окончание анимации (например, я это использую для установки фокуса в инпут после завершения прокрутки, чтобы избежать дёрганий, так как разные браузеры по-разному скроллят страницу при попадании фокуса на элемент за пределами экрана).

Метод requestNextFrame запрашивает новый фрейм анимации и сохраняет его идентификатор, чтобы можно было его отменить в методе cancel.

Метод cancel, помимо отмены следующего фрейма, резолвит коллбэк.

Настало время перейти к тому месту, где происходит вся магия плавной прокрутки — метод animationFrame:

Весь код метода
        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()
            }
        }


В первой строке метода вызывается requestNextFrame, чтобы как можно раньше запросить следующий фрейм анимации. А дальше происходят две хитрости:

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

  • в нулевом фрейме сохраняем время начала анимации. Это нужно именно при использовании полифила requestAnimationFrame с фоллбэком на setTimeout. Дело в том, что два этих варианта будут передавать разное время в коллбэк фрейма: в первом случае это будет DOMHighResTimeStamp, а во втором — обычный Date. Во всех примерах использования requestAnimationFrame с полифилом я видел, как авторы инициализируют startTime до начала анимации, при этом вторично выясняя, какой именно вариант сработает, но я подумал, что можно вообще не обременять себя лишними условиями и просто инициализировать startTime в нулевом фрейме.
  • тут же инициализируется текущее положение элемента и текущее положение экрана, которые будут изменяться в последующих фреймах. В первой реализации этого не было, и текущее положение запрашивалось в каждом фрейме, но, как оказалось при отладке анимации, эти запросы форсят пересчёт лэйаута страницы, и пришлось немного пересмотреть алгоритм прокрутки, чтобы избежать тормозов (пруфы в конце)

Дальше всё просто:

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

Рассчитываются время и процент завершённости анимации, а также новые положения элемента и экрана. Вызывается прокрутка к вычисленному положению и проверяются условия окончания анимации.

Итоги


Модуль, написанный за пару часов, не имеет недостатков либы, раскритикованной во введении: анимация плавная, минимальный необходимый функционал присутствует.

Еще есть, чем заняться:

  • написать нормальный README и сделать страничку с демкой
  • сделать минификацию и закинуть библиотеку в bower
  • избавиться еще от еще пары форсированных пересчётов лэйаута страницы в условиях окончания прокрутки
  • разрулить ситуацию, если одновременно сработает триггер для двух и более элементов

Просьбы


Я залил всё на гитхаб в нетронутом виде и прошу тех, кто разбирается в лицензиях и «прочих опенсорсностях», подсказать и помочь правильно оформить это дело:

  • я скопировал полифил просто в начало файла. может, стоит его вынести в отдельный файл?
  • нужно выбрать лицензию для самой либы и оформить соответствующе
  • можно ли было просто так копировать и изменять код из Angular?

Пруфы и ссылки



Всем спасибо за внимание!
Tags:angularjsjavascriptrequestanimationframeanimationsmooth scrollstyleguideплавная прокрутка
Hubs: JavaScript Angular
+10
12.5k 56
Comments 19
Popular right now