export interface SilkyScrollOpts {
target?: HTMLElement // 滚动目标元素,默认为 document.documentElement
orientation?: 'vertical' | 'horizontal' // 滚动方向,默认为 vertical
multiplier?: number // 滚轮事件的乘数,默认为 1
lerp?: number // 线性插值强度,[0, 1],默认为 0.1。如果定义了 duration 则无用
fps?: number // 画面刷新率(以帧为单位),默认为 60。定义了 lerp 则使用
duration?: number // 滚动动画的持续时间(以秒为单位)
easing?: (t: number) => number // 缓动函数,默认为 easeInOut。定义了 duration 则使用
onUpdate?: (value: number) => void // 滚动回调,默认为滚动 target
}
type RequiredSilkyScrollOpts = Required<SilkyScrollOpts>
/**
* 平滑滚动
*/
export default class SilkyScroll {
private _target: RequiredSilkyScrollOpts['target'] // 滚动目标元素
private _orientation: RequiredSilkyScrollOpts['orientation'] // 滚动方向
private _multiplier: RequiredSilkyScrollOpts['multiplier'] // 滚轮事件的乘数
private _lerp: RequiredSilkyScrollOpts['lerp'] // 线性插值强度,[0, 1]
private _fps: RequiredSilkyScrollOpts['fps'] // 画面刷新率(以帧为单位)。
private _duration?: RequiredSilkyScrollOpts['duration'] // 滚动动画的持续时间(以秒为单位)。
private _easing: RequiredSilkyScrollOpts['easing'] // 缓动函数
private _onUpdateOriginal: RequiredSilkyScrollOpts['onUpdate'] // 滚动回调
private _isRunning = false // 是否正在滚动
private _rafTimeRecord: number = 0 // RAF 时间记录
private _rafHandle: number = 0 // RAF handle
private _curScroll = 0 // 当前滚动距离
private _toScroll = 0 // 目标滚动距离
private _fromScroll = 0 // 上一次滚动距离
private _curTime = 0 // 当前时间
private _onUpdate?: RequiredSilkyScrollOpts['onUpdate'] // 内部滚动回调
constructor(opts?: SilkyScrollOpts) {
const {
target = document.documentElement,
orientation = 'vertical',
multiplier = 1,
lerp = 0.1,
duration,
fps = 60,
easing = easeInOut,
onUpdate,
} = opts || {}
let onUpdateOriginal = onUpdate
if (!onUpdateOriginal) {
onUpdateOriginal = v => {
switch (orientation) {
case 'vertical':
target.scrollTop = v
break
case 'horizontal':
target.scrollLeft = v
break
}
}
}
// 初始化
this._target = target
this._orientation = orientation
this._multiplier = multiplier
this._lerp = lerp
this._fps = fps
this._duration = duration
this._easing = easing
this._onUpdateOriginal = onUpdateOriginal
// RAF 更新
this._target.addEventListener('wheel', this._onWheel as any, { passive: false })
const rafFunc = (time: DOMHighResTimeStamp) => {
this._raf(time)
this._rafHandle = requestAnimationFrame(rafFunc)
}
this._rafHandle = requestAnimationFrame(rafFunc)
}
/**
* @return {boolean} SilkyScroll 实例当前是否正在运行
*/
get isRunning(): boolean {
return this._isRunning
}
/**
* 停止动画
*/
stop() {
this._rafTimeRecord = 0
this._isRunning = false
this._curScroll = 0
this._toScroll = 0
this._fromScroll = 0
this._curTime = 0
}
/**
* 销毁 SilkyScroll 实例
*/
destroy() {
this._target.removeEventListener('wheel', this._onWheel as any)
this._rafHandle && cancelAnimationFrame(this._rafHandle)
}
/**
* 根据当前时间更新动画帧
* @param {DOMHighResTimeStamp} time 高精度格式的当前时间
*/
private _raf(time: DOMHighResTimeStamp) {
if (!this._isRunning) return
const deltaTime = time - (this._rafTimeRecord || time)
this._rafTimeRecord = time
this._advance(deltaTime * 0.001) // 单位转化为秒
}
/**
* 按给定的增量时间推进动画
* @param {number} deltaTime 自上一帧以来经过的时间,单位为秒
*/
private _advance(deltaTime: number) {
let completed = false
let value = 0
if (this._duration === undefined) {
// 阻尼效果线性插值
value = damp(this._curScroll, this._toScroll, this._lerp * this._fps, deltaTime)
if (Math.round(value) === Math.round(this._toScroll)) {
completed = true
}
} else {
// 动画持续时间 + 缓动函数
this._curTime += deltaTime
const linearProgress = clamp(0, this._curTime / this._duration, 1)
completed = linearProgress >= 1
const easedProgress = completed ? 1 : this._easing(linearProgress)
value = this._fromScroll + (this._toScroll - this._fromScroll) * easedProgress
}
this._onUpdate?.(value)
if (completed) {
this._isRunning = false
}
}
/**
* wheel 事件回调函数
* @param {WheelEvent} e
*/
private _onWheel = (e: WheelEvent) => {
e.preventDefault() // 阻止默认事件,停止滚动
let delta = 0
switch (this._orientation) {
case 'vertical':
delta = (e as any).wheelDeltaY
break
case 'horizontal':
delta = (e as any).wheelDeltaX
break
}
this._onVirtualScroll(this._curScroll - delta * this._multiplier)
}
/**
* 处理虚拟滚动事件并更新滚动位置
* @param {number} targetScroll - 目标滚动位置
*/
private _onVirtualScroll(targetScroll: number) {
this._isRunning = true
this._toScroll = targetScroll
this._fromScroll = this._curScroll
this._curTime = 0
this._onUpdate = (value: number) => {
this._onUpdateOriginal(value)
this._curScroll = value // 记录滚动后的距离
}
}
}
/**
* 缓动函数:缓入缓出,慢快慢
* @param {number} t
* @return {number}
*/
export const easeInOut = (t: number) => Math.min(1, 1.001 - Math.pow(2, -10 * t))
/**
* 缓动函数:指数反向缓动,先快后慢
* @param {number} t
* @return {number}
*/
export const easeOut = (t: number) => 1 - Math.pow(1 - t, 2)
/**
* 对两个值进行线性插值
* @param {number} start 起始值
* @param {number} end 结束值
* @param {number} amt 线性插值强度,[0, 1]
* @return {number} 插值
*/
export const lerp = (start: number, end: number, amt: number): number => (1 - amt) * start + amt * end
/**
* 对两个值进行阻尼效果线性插值
* @param {number} start 起始值
* @param {number} end 结束值
* @param {number} lambda 阻尼系数
* @param {number} deltaTime 间隔时间
* @return {number} 插值
*/
export const damp = (start: number, end: number, lambda: number, deltaTime: number): number => lerp(start, end, 1 - Math.exp(-lambda * deltaTime))
/**
* 如果输入值处于 [min, max] 之间,返回其值,否则返回 min 或 max
* @param {number} min 最小边界
* @param {number} input 输入值
* @param {number} max 最大边界
* @return 值
*/
export const clamp = (min: number, input: number, max: number) => Math.max(min, Math.min(input, max))