UNPKG

smooth-scrollbar

Version:

Customize scrollbar in modern browsers with smooth scrolling experience.

348 lines (273 loc) 7.7 kB
import { clamp, debounce } from '../../utils'; import { ScrollbarPlugin } from 'smooth-scrollbar'; import { Bounce } from './bounce'; import { Glow } from './glow'; export enum OverscrollEffect { BOUNCE = 'bounce', GLOW = 'glow', } export type Data2d = { x: number, y: number, }; export type OnScrollCallback = (this: OverscrollPlugin, position: Data2d) => void; export type OverscrollOptions = { effect?: OverscrollEffect, onScroll?: OnScrollCallback, damping: number, maxOverscroll: number, glowColor: string, }; const ALLOWED_EVENTS = /wheel|touch/; export default class OverscrollPlugin extends ScrollbarPlugin { static pluginName = 'overscroll'; static defaultOptions: OverscrollOptions = { effect: OverscrollEffect.BOUNCE, onScroll: undefined, damping: 0.2, maxOverscroll: 150, glowColor: '#87ceeb', }; options: OverscrollOptions; private _glow = new Glow(this.scrollbar); private _bounce = new Bounce(this.scrollbar); private _wheelScrollBack = { x: false, y: false, }; private _lockWheel = { x: false, y: false, }; private get _isWheelLocked() { return this._lockWheel.x || this._lockWheel.y; } private _touching = false; private _lastEventType: string; private _amplitude = { x: 0, y: 0, }; private _position = { x: 0, y: 0, }; private get _enabled() { return !!this.options.effect; } // since we can't detect whether user release touchpad // handle it with debounce is the best solution now, as a trade-off private _releaseWheel = debounce(() => { this._lockWheel.x = false; this._lockWheel.y = false; }, 30); onInit() { const { _glow, options, scrollbar, } = this; // observe let effect = options.effect; Object.defineProperty(options, 'effect', { get() { return effect; }, set(val) { if (!val) { effect = undefined; return; } if (val !== OverscrollEffect.BOUNCE && val !== OverscrollEffect.GLOW) { throw new TypeError(`unknow overscroll effect: ${val}`); } effect = val; scrollbar.options.continuousScrolling = false; if (val === OverscrollEffect.GLOW) { _glow.mount(); _glow.adjust(); } else { _glow.unmount(); } }, }); options.effect = effect; // init } onUpdate() { if (this.options.effect === OverscrollEffect.GLOW) { this._glow.adjust(); } } onRender(remainMomentum: Data2d) { if (!this._enabled) { return; } if (this.scrollbar.options.continuousScrolling) { // turn off continuous scrolling this.scrollbar.options.continuousScrolling = false; } let { x: nextX, y: nextY } = remainMomentum; // transfer remain momentum to overscroll if (!this._amplitude.x && this._willOverscroll('x', remainMomentum.x) ) { nextX = 0; this._absorbMomentum('x', remainMomentum.x); } if (!this._amplitude.y && this._willOverscroll('y', remainMomentum.y) ) { nextY = 0; this._absorbMomentum('y', remainMomentum.y); } this.scrollbar.setMomentum(nextX, nextY); this._render(); } transformDelta(delta: Data2d, fromEvent: Event): Data2d { this._lastEventType = fromEvent.type; if (!this._enabled || !ALLOWED_EVENTS.test(fromEvent.type)) { return delta; } if (this._isWheelLocked && /wheel/.test(fromEvent.type)) { this._releaseWheel(); if (this._willOverscroll('x', delta.x)) { delta.x = 0; } if (this._willOverscroll('y', delta.y)) { delta.y = 0; } } let { x: nextX, y: nextY } = delta; if (this._willOverscroll('x', delta.x)) { nextX = 0; this._addAmplitude('x', delta.x); } if (this._willOverscroll('y', delta.y)) { nextY = 0; this._addAmplitude('y', delta.y); } switch (fromEvent.type) { case 'touchstart': case 'touchmove': this._touching = true; this._glow.recordTouch(fromEvent as TouchEvent); break; case 'touchcancel': case 'touchend': this._touching = false; break; } return { x: nextX, y: nextY, }; } private _willOverscroll(direction: 'x' | 'y', delta: number): boolean { if (!delta) { return false; } // away from origin if (this._position[direction]) { return true; } const offset = this.scrollbar.offset[direction]; const limit = this.scrollbar.limit[direction]; if (limit === 0) { return false; } // cond: // 1. next scrolling position is supposed to stay unchange // 2. current position is on the edge return clamp(offset + delta, 0, limit) === offset && (offset === 0 || offset === limit); } private _absorbMomentum(direction: 'x' | 'y', remainMomentum: number) { const { options, _lastEventType, _amplitude, } = this; if (!ALLOWED_EVENTS.test(_lastEventType)) { return; } _amplitude[direction] = clamp(remainMomentum, -options.maxOverscroll, options.maxOverscroll); } private _addAmplitude(direction: 'x' | 'y', delta: number) { const { options, scrollbar, _amplitude, _position, } = this; const currentAmp = _amplitude[direction]; const isOpposite = delta * currentAmp < 0; let friction: number; if (isOpposite) { // opposite direction friction = 0; } else { friction = this._wheelScrollBack[direction] ? 1 : Math.abs(currentAmp / options.maxOverscroll); } const amp = currentAmp + delta * (1 - friction); _amplitude[direction] = scrollbar.offset[direction] === 0 ? /* top | left */ clamp(amp, -options.maxOverscroll, 0) : /* bottom | right */ clamp(amp, 0, options.maxOverscroll); if (isOpposite) { // scroll back _position[direction] = _amplitude[direction]; } } private _render() { const { options, _amplitude, _position, } = this; if (this._enabled && (_amplitude.x || _amplitude.y || _position.x || _position.y) ) { const nextX = this._nextAmp('x'); const nextY = this._nextAmp('y'); _amplitude.x = nextX.amplitude; _position.x = nextX.position; _amplitude.y = nextY.amplitude; _position.y = nextY.position; switch (options.effect) { case OverscrollEffect.BOUNCE: this._bounce.render(_position); break; case OverscrollEffect.GLOW: this._glow.render(_position, this.options.glowColor); break; } if (typeof options.onScroll === 'function') { options.onScroll.call(this, { ..._position }); } } } private _nextAmp(direction: 'x' | 'y'): { amplitude: number, position: number } { const { options, _amplitude, _position, } = this; const t = 1 - options.damping; const amp = _amplitude[direction]; const pos = _position[direction]; const nextAmp = this._touching ? amp : (amp * t | 0); const distance = nextAmp - pos; const nextPos = pos + distance - (distance * t | 0); if (!this._touching && Math.abs(nextPos) < Math.abs(pos)) { this._wheelScrollBack[direction] = true; } if (this._wheelScrollBack[direction] && Math.abs(nextPos) <= 1) { this._wheelScrollBack[direction] = false; this._lockWheel[direction] = true; } return { amplitude: nextAmp, position: nextPos, }; } }