Мне пришлось написать свою библиотеку плавной прокрутки для Angular приложения. О том, что у меня получилось, и почему я это вообще затеял — под катом. Попутно расскажу о своих любимых приёмах оформления модулей для AngularJS.
Вместо введения
Первым делом я быстро сделал минимальный пулл-реквест, особо не вникая в весь код, чтобы у меня хоть что-то заработало (переписал полностью директивы), но когда полезли неприятные баги (дёрганая анимация, срабатывание через раз), я просмотрел весь файл и понял — чтобы исправить ситуацию, тут нужно переписать почти всё, и такой пулл-реквест вряд ли автор когда-то примет, плюс — там не хватало достаточно важных фич, и, так как скролл мне нужен был уже к вечеру, я решил быстро написать свой вариант smooth-scroll на Angular.
Долго не мог определиться, на чем акцентировать внимание в статье: либо на самоей библиотеке, либо на советах по стилю кода, либо на плавной анимации и её отладке… В итоге решил писать, как пишется. Так что всего будет понемножку-вперемежку. Надеюсь, не запутаемся.
Цели
- плавная прокрутка страницы при выполнении заданного условия
- отсутствие дополнительных зависимостей (кроме AngularJS)
- использование для плавной прокрутки requestAnimationFrame вместо setTimeout
- возможность настраивать: отступ от верха экрана после прокрутки, длительность анимации, easing, задержку, а также указывать коллбэк завершения прокрутки
- показать
своё кунг-фусвой стиль оформления Angular-модулей (вдруг кто-нибудь подкинет новые идеи) - развести холивар (план-максимум, если успею дописать статью к пятнице) 🙂
Поехали
(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 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?
Пруфы и ссылки
- Прекрасный style guide от John Papa. Кто еще не видел — вдохновляйтесь, кто видел — перечитайте
- Что провоцирует пересчёт лэйаута в браузерах
- Исходник полифила requestAnimationFrame
- Либа, из-за которой я психанул и написал свою
- strong-smooth-scroll
Всем спасибо за внимание!
ссылка на оригинал статьи http://habrahabr.ru/post/270245/
Добавить комментарий