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

от автора

Мне пришлось написать свою библиотеку плавной прокрутки для 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?

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

Всем спасибо за внимание!

ссылка на оригинал статьи http://habrahabr.ru/post/270245/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *