Мне пришлось написать свою библиотеку плавной прокрутки для Angular приложения. О том, что у меня получилось, и почему я это вообще затеял — под катом. Попутно расскажу о своих любимых приёмах оформления модулей для AngularJS.
Долго не мог определиться, на чем акцентировать внимание в статье: либо на самой библиотеке, либо на советах по стилю кода, либо на плавной анимации и её отладке… В итоге решил писать, как пишется. Так что всего будет понемножку-вперемежку. Надеюсь, не запутаемся.
Тут вы уже можете заметить одну из моих любимых особенностей языка Javascript — это function hoisting, которая позволяет мне сосредоточить все объявления как можно выше, а реализацию — внизу, так можно сразу представить себе структуру модуля, не просматривая весь код (помимо этого внимательный читатель уже тут заметил прекрасную тему для холивара).
В Utils сейчас только одна функция — extend, взятая из исходников Angular и исправленная так, чтобы undefined элементы из src не затирали соответствующие элементы из dst. В репе Angular на github давно есть Issue на эту тему, но ждать, когда всё это дело поправят, времени нет.
Здесь мы подписываемся на всевозможные евенты, которые генерируют разные браузеры, если пользователь сам начинает прокручивать страницу. Обратите внимание: это делается не в link, а в самой функции директивы, чтобы иметь один единственный обработчик для всех зарегистрированных элементов. Сообщение конкретным элементам рассылается посредством $rootScope.$broadcast(...).
Подписываемся на рассылаемое сообщение, когда пользователь сам начинает прокручивать страницу, чтобы прервать автоматический скролл, и не заываем отписаться от него при разрушении элемента.
Проверяем триггер. Если он не задан в атрибутах, то выполняем прокрутку сразу, иначе — ждём, когда он станет true. Обращаемя к attrs, чтобы проверить наличие атрибута в элементе. (Надеюсь, мы избежим обсуждения typeof и «undefined», не тот случай)
Собственно, непосредственный запуск прокрутки. Передаем «не глядя» все параметры из scope в сервис. Подписываемся на завершение прокрутки, вызываем указанный в атрибутах коллбэк (stScroller.run() возвращает Promise) и очищаем переменную.
Получилась очень простая директива. Самое интересное у нас в сервисе прокрутки. Едем дальше!
Сервис было решено оформить в виде «класса» (не бейте меня, я всё понимаю). В конструкторе задаются начальные значения свойств, нужных для плавной прокрутки. Особого внимания стоит задание дефолтных значений для опций прокрутки:
Исправленная выше функция extend позволяет задать дефолтные значения, которые не будут затёрты, если в атрибутах элемента не были указаны соответствующие опции.
Повторюсь: function hoisting позволяет лаконично описать весь прототип. Человек, читающий код, может сразу себе представить, как работает объект, не листая весь файл в поисках объявлений.
Теперь перейдем к интересным моментам реализации.
Начинается всё с метода run, в котором запрашивается первый фрейм анимации, и заодно и обрабатывается задержка прокрутки, указанная в опциях:
Этот метод возвращает промис, чтобы у «пользователя» была возможность подписаться на окончание анимации (например, я это использую для установки фокуса в инпут после завершения прокрутки, чтобы избежать дёрганий, так как разные браузеры по-разному скроллят страницу при попадании фокуса на элемент за пределами экрана).
Метод requestNextFrame запрашивает новый фрейм анимации и сохраняет его идентификатор, чтобы можно было его отменить в методе cancel.
Метод cancel, помимо отмены следующего фрейма, резолвит коллбэк.
Настало время перейти к тому месту, где происходит вся магия плавной прокрутки — метод animationFrame:
В первой строке метода вызывается requestNextFrame, чтобы как можно раньше запросить следующий фрейм анимации. А дальше происходят две хитрости:
Дальше всё просто:
Рассчитываются время и процент завершённости анимации, а также новые положения элемента и экрана. Вызывается прокрутка к вычисленному положению и проверяются условия окончания анимации.
Модуль, написанный за пару часов, не имеет недостатков либы, раскритикованной во введении: анимация плавная, минимальный необходимый функционал присутствует.
Еще есть, чем заняться:
Я залил всё на гитхаб в нетронутом виде и прошу тех, кто разбирается в лицензиях и «прочих опенсорсностях», подсказать и помочь правильно оформить это дело:
Всем спасибо за внимание!
Вместо введения
Предыстория: зачем еще одна библиотека?
Произошла стандартная ситуация: понадобился 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.
Первым делом я быстро сделал минимальный пулл-реквест, особо не вникая в весь код, чтобы у меня хоть что-то заработало (переписал полностью директивы), но когда полезли неприятные баги (дёрганая анимация, срабатывание через раз), я просмотрел весь файл и понял — чтобы исправить ситуацию, тут нужно переписать почти всё, и такой пулл-реквест вряд ли автор когда-то примет, плюс — там не хватало достаточно важных фич, и, так как скролл мне нужен был уже к вечеру, я решил быстро написать свой вариант 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
Опять function hoisting во всей красе.
/**
* 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
Всем спасибо за внимание!
Комментарии (19)
Lamaster
06.11.2015 10:21
vitvad
06.11.2015 22:06+1Есть одно замечание, зачем броадкаст делать если вы в компоненте всеравно слушаете событие на $rootScope?
Слушать событие на $rootScope, и удалять его при $destroy компонента правильно, но $broadcast практика плохая, а тем более если на $rootScope.
k12th
В 2015-то году не пора ли использовать какие-нибудь модули?
alxdnlnko
Вы правы. Писал быстро под себя и забыл про тех, кто по-другому подключает скрипты. Будет сделано :)
alxdnlnko
* писАл ДЛЯ себя :)
xobotyi
XD если бы не этот коммент я бы даже не подумал про другой смысл :D:D
alxdnlnko
Посмотрел, как сделаны модули самого AngularJS. Там всё обёрнуто как раз в IIFE, только они ещё замыкают глобальный angular и undefined. Так и сделаем.
Если так подумать, то зачем сочинять какие-то модули над Angular, если они сами по себе уже модули.
k12th
Есть модули (CommonJS, AMD, SystemJS, ES6/2015), а есть ангуляровские… сущности, предназначенные для Dependency Injection. Это вообще-то ортогональные по отношению друг к другу вещи. Первое, это, скажем, compile time, а второе — runtime. Первое добавляет в область видимости классы, а второе — их инстансы. Первое оперирует файлами, второе — объектами.
С помощью angular.module я не подгружу этот ваш «модуль» в нужный момент и только тогда, когда он нужен.
alxdnlnko
Это я прекрасно понимаю, спасибо. Но, как я уже сказал, я бы предпочел воспользоваться подходом самого Angular. Если Вы как-то используете его «нативные» модули, то и мой сможете.
k12th
Нет, к сожалению, не понимаете.
alxdnlnko
Ну давайте на примерах, не углубляясь в теорию. Вы используете ng-messages или ng-animate? Вы хотите, чтобы я по-другому оформил свой модуль?
k12th
alxdnlnko
Всё, понял. Дело всё в index.js (которого у меня ещё даже нет), мы о разных вещах говорили. Я имел в виду конкретно этот файл с кодом. Проклятое слово «модуль» :)
k12th
Я вообще-то подробно разжевал, что есть модуль, а что есть ангуляровская НЕХ:)
alxdnlnko
Вообще то я подробно сказал, что я это понимаю :) Просто, так как я мыслил в рамках одного файла, я решил, что Вы намекаете на что-то подобное:
Но такого в нативных модулях нет, вот и всё непонимание :)
dannyzubarev
Да, этого от вас и пытаются добиться :)
Называется это дело UMD (Universal Module Definition). Подробнее: https://github.com/umdjs/umd
vitvad
в наш век Git-репозиториев добиться желаемого быстрее пулреквестом. :)