Skip to content

Instantly share code, notes, and snippets.

@ovcharik
Last active November 2, 2016 03:24
Show Gist options
  • Save ovcharik/5798a847ed592b5b3ae5 to your computer and use it in GitHub Desktop.
Save ovcharik/5798a847ed592b5b3ae5 to your computer and use it in GitHub Desktop.
Drag and drop scrolling and kinetic animation

Прокрутка, как на телефоне, только в браузере

Описание

Кинетическая прокрутка элементов c Drag-and-Drop в виде плагина к jQuery. Подключается в полпинка, простая логика и ничего лишнего, только физика и матан.

Определения

Ускорение

Абстрактная величина (или правило), отображает изменение скорости за одну миллисекунду.

Скорость

Абстрактная величина, отображающая смещение в пикселях за одну миллисекунду.

Вверх/вниз

Тут я сам запутался, проще опытным путем выяснить, какое направление (-1 | 1) нужно задавать, в том или ином случае.

Импульс/инерция/энергия/...

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

Пример использования

-// template.jade

-// Прокручиваемый элемент завернутый в контейнер, ограничивающий его по высоте
.wrapper
  .longScrollableElement

-// Кнопки при зажатии которых, будет активироваться прокрутка элемента
.buttonScrollUp
.buttonScrollDown
# app.coffee

# Подключаем плагин к элементу
$(".longScrollableElement").kineticScroll()

# Обработчик для кнопки прокрутки элемента вверх
$(".buttonScrollUp").on "mousedown touchstart", (event) ->
  event.stopPropagation() # Блокируем всплытие события
  event.preventDefault()  # Блокируем стандартный обработчик
  
  # Вызываем метод прокрутки с постоянной скоростю
  $(".longScrollableElement").kineticScroll("buttonScroll", -1) # Вверх

# Обработчик для кнопки прокрутки элемента вниз
$(".buttonScrollDown").on "mousedown touchstart", (event) ->
  event.stopPropagation() # Блокируем всплытие события
  event.preventDefault()  # Блокируем стандартный обработчик
  
  # Вызываем метод прокрутки с постоянной скоростю
  $(".longScrollableElement").kineticScroll("buttonScroll", 1) # Вниз

После чего мы получаем следующий функционал:

  1. При "протаскивании" элемента курсором, он будет прокручиваться в след за курсором;
  2. При отпускании курсора в момент, когда он движется, элементу будет передана кинетическая энергия курсора, и элемент продолжит двигаться по инерции;
  3. При попытке подвигать мышью элемент в момент движения по инерции, он мгновенно начнет следовать за мышью;
  4. При вращении колеса мыши, элемент будет плавно прокручиваться, за счет эффекта передачи импульса, хотя тут может возникать дерганье анимация, при быстрой прокрутке колесиком, так как скорость будет стремиться к постоянной, но из-за особенности процесса прокрутки колесика, события будут не достаточно часто генерироваться);
  5. При попытке проскролить колесиком в момент инерционого движения, элементу будет задан импульс от колесика;
  6. При зажатии кнопок промоток, будет происходить анимация прокрутки с постоянной скорость;
  7. При отжатии кнопок промоток, промотка продолжится по инерции, по заданной скорости (в данном случае скорости промотки);
  8. При клике на кнопки промоток, будет задан импульс промотки элемента, и запустится анимация движения;
  9. При работе с кнопками промотки, в момент когда элемент движется по инерции, эффекты кнопок немедлено переопределят текущую анимацю;
  10. При достижении границы прокрутки во время движения по инерции, прокрутка сразу прекратится, без эффектов и замедления (это не положительное свойство, а просто факт).

Api

$(selector).kineticScroll([firstArg[, otherArgs...]])
  • firstArg Object | String - передавая объект можно задать свойства для плагина, передавая строку можно вызвать метод.
  • otherArgs... - аргументы к методам

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

Методы

Использование: $(selector).kineticScroll(<methodName>[, args...]).

⚠️ Предупреждение: попытка вызвать метод не из списка вызовет исключение о недопустимости использования данного метода.

on

Активирует прокрутку на элементе, просто включает прослушку событий, и после активации становятся доступны другие методы.

off

Деактивирует прокрутку на элементе.

scroll

Параметры:

  • direction -1 | 1 - направление прокрутки
  • velocity Number - начальная скорость прокрутки
  • [static=false] Boolean - прокручивать с постоянной скоростью
  • [callback] Function - вызовется, после остановки прокрутки

Заставляет прокручиваться элемент по инерции с заданой начальной скоростью. Каждый новый кадр просчитывается с помощью requestAnimationFrame, в цикле просчета учитываются граничные условия (скорость, края), и события из-вне (перехват анимации с помощью, курсора, колесика, кнопок и т.д) для остановки анимации. Анимация для одного элемента гарантировано рассчитывается в одном цикле, это делает прокрутку отзывчивой и предотвращает ошибки. В рассчете смещения во время анимации производятся операции, только над кинетическими свойствами системы, с учетом разницы времени с момента последнего кадра.

Тут следует пояснить, благодаря тому, что движение основано лишь на физических свойствах системы, будет проблематично проссчитать точное место остановки, или запланировать его. Так как для этого, по ходу движения, придется корректировать скорость, для схождения к нужной точке, а корректировать ее потребуется на основе начального значения скорости, которое может задавать пользователь. А далее нужно будет составлять многочлен, который в зависимости от скорости и граничных условий, будет сводить систему к заданной точке, что влечет много матана, особенно если захочится, что бы замедление было не линейным.

stop

Останавливает анимацию прокрутки.

buttonScroll

Параметры:

  • direction -1 | 1 - направление прокрутки

Прокрутика при зажатии кнопки какого-либо контролла, начальная скорость задается через свойства инициализации плагина. Перестает просчитываться при отпускании кнопки мыши в окне (onMouseup у window). При вызове метода активируется метод scroll с постояной скоростью прокрутки, при отпускании кнопки мыши вызвается метод scroll, который заставляет двигаться элемент по инерции.

Свойства

Свойства можно передать в объекте вызывая $(selector).kineticScroll(<options>).

acceleration Function (по умолчанию: (v, l) -> -(v ** 0.5) * 0.8)

Функция может принимать два параметра:

  • velocity Number - мгновенная скорость в момент вызова функции
  • limit Number - Количество пикселей оставшихся до границы прокрутки, с учетом направления прокрутки (сначала была идея сделать плавное торможения на граничных условиях)

Функция высчитывает мгновенное ускорение при заданных параметрах, далее в процессе расчета анимации к скорости добавляется значение производной от ускорения по времени: deltaVelocity = acceleration(velocity, limit) / deltaTime, а к значению смещения прокрутки добавляется производная от скорости по времени (с учетом направления): delatOffset = deltaVelocity / delataTime. Как можно заметить, по умолчанию ускорение принимает отрицательное значения, что будет вести к замедлению прокрутки и остановке (так сказать действие силы сопротивления), расчет анимации в данном случае закончится, как только скорость достигнет 0. Если ускорение будет равняться 0, то прокрутка будет происходить с постоянной начальной скоростью, и остановится при достижения границы элемента. Если ускорение будет больше 0, то скорость движения будет возрастать, и остановится только на границе.

velocity Function (по умолчанию: (do, dt) -> do / dt)

Функция может принимать два параметра:

  • deltaOffset <Number> - дельта смещения
  • deltaTime <Number> - дельта времени

Функция вызывается для определения начальной скорости прокрутки. Замер разниц во времени производится на основе движения курсора, вычисляется разница в значении координаты Y у курсора, при условии, что последнее измерение произошло не ранее чем 15 миллисекунд назад (учитывая, что отрисовка происходит примерно раз в 16.6 мс (60fps), а считывание координат зависит от чуствительности мыши, значение в 15 мс позволяет не плохо избавится от погрешности измерений, и позволяет исключить лаги, когда начальная скорость становится очень большой или даже бесконечной).

traceholder Number (по умолчанию: 5)

Смещение курсора в пикселях (по двум координатам), когда прокрутка не активируется. Для предотвращения случайной прокрутки на маленьких значениях смещения курсора.

scrollFactor Number > 0 (по умолчанию: 1.1)

Смещение курсора по координате Y домнажается на это значение, и получается итоговое значение смещения прокрутки. В общем, чем больше это значение, тем больше будет прокручиваться элемент при меньшем смещении курсора, для значения равного 1, прокрутка при перетаскивании будет следовать точь-в-точь за курсором.

dropFactor dropFactor > 0 (по умолчанию: 0.9)

Смещение мыши по координате Y домнажается на это значение, при замере смещения для определения начальной скорости. В общем, чем больше это значения тем выше будет начальная скорость в инерционной прокрутке и наоборот, при значении равном 1 начальная скорость будет примерно соответствовать скорости движения курсора в момент отжатия клавиши мыши.

wheelVelocity Number >= 0 (по умолчанию: 2)

Начальная скорость движения, при прокрутке колеса мыши. Если Boolean(wheelVelocity) === false, то события прокрутки колеса мыши слушаться не будут.

buttonScroll Object (по умолчанию: {moveVelocity: 1.5, endVelocity: 1.5})

Задает свойства начальной скорости при прокрутке с помощью метода buttonScroll. Свойства объекта:

  • moveVelocity - задает скорость прокрутки пока кнопка зажата;
  • endVelocity - задает начальную скорость прокрутки при отжатии кнопки.

TODO

  • Перенести в репозиторий на github;
  • Сделать страницу с примером, где можно будет посмотреть работу плагина;
  • Добавить сборщик и скомпилированную версию в репозиторий;
  • Полифил для requestAnimationFrame;
  • Добавить возможность подключать через CommonJS или AMD;
  • Создать npm пакет;
  • Touch events;
  • Тесты.
(($) ->
$window = $(window)
now = -> (new Date).getTime()
sign = Math.sign ? (v) -> if v > 0 then 1 else (if v < 0 then -1 else 0)
instances = 0
fps = 16
# RAF Polyfill
requestAnimationFrame = window.requestAnimationFrame
cancelAnimationFrame = window.cancelAnimationFrame
for vendor in ['ms', 'moz', 'webkit', 'o'] when not requestAnimationFrame
requestAnimationFrame = window["#{vendor}RequestAnimationFrame"]
cancelAnimationFrame = (
window["#{vendor}CancelAnimationFrame"] ||
window["#{vendor}CancelRequestAnimationFrame"]
)
rafTargetTime = 0
requestAnimationFrame ||= (callback, element) ->
currentTime = now()
rafTargetTime = Math.max(rafTargetTime + fps, currentTime)
return setTimeout () ->
callback(now())
, rafTargetTime - currentTime
cancelAnimationFrame ||= (id) -> clearTimeout(id)
# Plugin class
class KineticScroll
defaultOptions:
acceleration : (v, limit) -> -(v ** 0.5) * 0.8
velocity : (dy, dt) -> dy / dt
traceholder : 5
scrollFactor : 1.1
dropFactor : 0.9
wheelVelocity: 2
buttonScroll:
moveVelocity: 1.5
endVelocity : 1.5
methods: ["on", "off", "scroll", "stop", "buttonScroll"]
constructor: (@$el) ->
@el = @$el[0]
instances += 1
@started = false
@kineting = false
@options = {}
@_number = instances
@_eventkey = "KineticScroll#{@_number}"
@_eventbuttonkey = "KineticScrollButton#{@_number}"
cssOptimizationOn : -> @$el.css "will-change", "scroll-position"
cssOptimizationOff: -> @$el.css "will-change", "auto"
exec: (optormet = {}, args...) ->
if optormet? and typeof(optormet) == "string" and @methods.indexOf(optormet) >= 0
method = optormet
return @[method](args...)
else if typeof(optormet) == "object"
@options = $.extend {}, @defaultOptions, @options, optormet
return @on()
else
throw new Error("KineticScroll: wrong arguments.")
# Активировать прокрутку
on: ->
getEventInfo = (event = {}) ->
isMouse = (event) -> event.type?.indexOf("mouse") == 0
isTouch = (event) -> event.type?.indexOf("touch") == 0
result = null
if isMouse(event)
result =
moveEventType: "mousemove"
stopEventType: "mouseup"
getPosition: (event) ->
if isMouse(event) && event.button == 0
return { x: event.clientX, y: event.clientY }
if isTouch(event)
result =
moveEventType: "touchmove"
stopEventType: "touchend"
getPosition: (event) ->
touch = isTouch(event) && event.originalEvent?.touches?[0]
return { x: touch.clientX, y: touch.clientY } if touch
return result
@off() if @started
@started = true
@dragStarted = false
if @options.wheelVelocity
@$el.on "mousewheel.#{@_eventkey}", (event) =>
return if @dragStarted
try
dir = sign(event.originalEvent.wheelDeltaY)
return if not dir
event.stopPropagation()
event.preventDefault()
@cssOptimizationOn()
@scroll dir, @options.wheelVelocity, =>
@cssOptimizationOff()
@$el.on "mousedown.#{@_eventkey} touchstart.#{@_eventkey}", (event) =>
return if @dragStarted
# Собираем данные для установки последующих событий
initialEventInfo = getEventInfo(event)
return if not initialEventInfo
{ moveEventType, stopEventType, getPosition } = initialEventInfo
# Вытаскиваем нужную информацию из события
position = getPosition(event)
return if not position
@dragStarted = true
@kineting = false # Продолжает двигаться по инерции или нет
# Замер времени последнего смещения курсора
lastT = now()
deltaT = null
# Замер смещения курсора по Y
lastY = position.y
deltaY = null
startX = position.x
startY = position.y
initScroll = @el.scrollTop
# Вычисления значений последнего смещения и задержки (начальной скорости)
updateDelta = (y) =>
t = now()
return if (t - lastT) < fps * 2 # Иначе погрешность может быть очень большой
[deltaT, deltaY] = [t - lastT, (lastY - y) * @options.dropFactor]
[lastT, lastY] = [t, y]
# Вычисления растояния смещения
deltaOffset = (p) ->
Math.sqrt((startX - p.x) ** 2 + (startY - p.y) ** 2)
# Был сдвиг или нет
isMoved = false
# Замеряем скорость и смещаем скролл
$window.on "#{moveEventType}.#{@_eventkey}", (event) =>
position = getPosition(event)
return if not position
return if not isMoved and @options.traceholder > deltaOffset(position)
event.stopPropagation()
event.preventDefault()
if not isMoved
@cssOptimizationOn()
isMoved = true
updateDelta(position.y)
# Смещение во время перетаскивания
sy = Math.round(initScroll + (startY - position.y) * @options.scrollFactor)
@el.scrollTop = sy
# Обработка граничных условий
if @el.scrollTop != sy
initScroll += @el.scrollTop - sy
# При отпускании высчитываем итоговую скорость
# и запускаем анимацию инерции
$window.on "#{stopEventType}.#{@_eventkey}", (event) =>
@dragStarted = false
$window.off ".#{@_eventkey}"
return if not isMoved
event.stopPropagation()
event.preventDefault()
dir = sign(deltaY) # Направление движения
v = @options.velocity(Math.abs(deltaY), deltaT) # Начальная скорость
@scroll dir, v, =>
@cssOptimizationOff()
return
return @$el
# Деактивировать прокрутку
off: ->
@started = false
@kineting = false
@dragStarted = false
cancelAnimationFrame(@lastRequestId) if @lastRequestId
@$el.off ".#{@_eventkey}"
$window.off ".#{@_eventkey}"
$window.off ".#{@_eventbuttonkey}"
@cssOptimizationOff()
return @$el
# Крутануть с заданным направлением и начальной скоростью
scroll: (dir, v, optional..., callback) ->
# Необязательные параметры
staticScroll = optional[0] ? false
return if not @started
@stop()
lt = now() # Для вычисления дельты времени
@kineting = true
# Смещение за один кадр
kinetic = () =>
@lastRequestId = null
[min, cur, max] = [0, @el.scrollTop, @el.scrollHeight - @el.offsetHeight]
# Заканчиваем прокрутку при выполнении любого условия
@kineting &&= v > 0 && (min <= cur <= max) && @started
return callback?() if not @kineting
# Запускаем следующий кадр
@lastRequestId = requestAnimationFrame(kinetic, @$el)
t = now()
dt = t - lt
return if dt - 5 < 0
lt = t
limit = if dir > 0 then max - cur else cur - min
if not staticScroll
v += @options.acceleration(v, limit) / dt
dy = dir * v * dt
@el.scrollTop += Math.round(dy)
@lastRequestId = requestAnimationFrame(kinetic, @$el)
return @$el
# Остановить текущую прокрутку
stop: ->
return @$el if not @started
@kineting = false
@dragStarted = false
cancelAnimationFrame(@lastRequestId) if @lastRequestId
return @$el
# Cкролинг по кнопке
buttonScroll: (dir) ->
return @$el if @dragStarted
return @$el if not @started
@stop()
@cssOptimizationOn()
@scroll dir, @options.buttonScroll.moveVelocity, true, null
$window.one "mouseup.#{@_eventbuttonkey} touchend.#{@_eventbuttonkey}", (event) =>
event.stopPropagation()
event.preventDefault()
$window.off(".#{@_eventbuttonkey}")
@scroll dir, @options.buttonScroll.endVelocity, =>
@cssOptimizationOff()
return @$el
$.fn.kineticScroll = (args...) ->
@each ->
$el = $(@)
instance = $el.data('KineticScroll')
if not instance
instance = new KineticScroll($el)
$el.data('KineticScroll', instance)
instance.exec(args...)
return
)(jQuery)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment