@better-scroll/scroll-bar
Version:
scrollbar is used to BetterScroll, which behaves like browser scrollbar
487 lines (428 loc) • 12.6 kB
text/typescript
import BScroll, { TranslaterPoint } from '@better-scroll/core'
import {
style,
EventEmitter,
between,
getNow,
Probe,
EventRegister,
} from '@better-scroll/shared-utils'
import EventHandler from './event-handler'
export const enum IndicatorDirection {
Horizontal = 'horizontal',
Vertical = 'vertical',
}
const enum ScrollTo {
Up = -1,
Down = 1,
}
export const enum OffsetType {
Step = 'step',
Point = 'clickedPoint',
}
export interface IndicatorOptions {
wrapper: HTMLElement
direction: IndicatorDirection
fade: boolean
fadeInTime: number
fadeOutTime: number
interactive: boolean
minSize: number
isCustom: boolean
scrollbarTrackClickable: boolean
scrollbarTrackOffsetType: OffsetType
scrollbarTrackOffsetTime: number
}
interface KeysMap {
hasScroll: 'hasVerticalScroll' | 'hasHorizontalScroll'
size: 'height' | 'width'
wrapperSize: 'clientHeight' | 'clientWidth'
scrollerSize: 'scrollerHeight' | 'scrollerWidth'
maxScrollPos: 'maxScrollY' | 'maxScrollX'
pos: 'y' | 'x'
point: 'pageX' | 'pageY'
translateProperty: 'translateY' | 'translateX'
domRect: 'top' | 'left'
}
interface ScrollInfo {
maxScrollPos: number
minScrollPos: number
sizeRatio: number
baseSize: number
}
export default class Indicator {
wrapper: HTMLElement
wrapperRect: DOMRect
indicatorEl: HTMLElement
direction: IndicatorDirection
scrollInfo: ScrollInfo
currentPos: number
moved: boolean
startTime: number
keysMap: KeysMap
eventHandler: EventHandler
clickEventRegister: EventRegister
hooksFn: [EventEmitter, string, Function][] = []
constructor(public scroll: BScroll, public options: IndicatorOptions) {
this.wrapper = options.wrapper
this.direction = options.direction
this.indicatorEl = this.wrapper.children[0] as HTMLElement
this.keysMap = this.getKeysMap()
this.handleFade()
this.handleHooks()
}
private handleFade() {
if (this.options.fade) {
this.wrapper.style.opacity = '0'
}
}
private handleHooks() {
const { fade, interactive, scrollbarTrackClickable } = this.options
const scroll = this.scroll
const scrollHooks = scroll.hooks
const translaterHooks = scroll.scroller.translater.hooks
const animaterHooks = scroll.scroller.animater.hooks
this.registerHooks(
scrollHooks,
scrollHooks.eventTypes.refresh,
this.refresh
)
this.registerHooks(
translaterHooks,
translaterHooks.eventTypes.translate,
(pos: { x: number; y: number }) => {
const { hasScroll: hasScrollKey } = this.keysMap
if (this.scroll[hasScrollKey]) {
this.updatePosition(pos)
}
}
)
this.registerHooks(
animaterHooks,
animaterHooks.eventTypes.time,
this.transitionTime
)
this.registerHooks(
animaterHooks,
animaterHooks.eventTypes.timeFunction,
this.transitionTimingFunction
)
if (fade) {
this.registerHooks(scroll, scroll.eventTypes.scrollEnd, () => {
this.fade()
})
this.registerHooks(scroll, scroll.eventTypes.scrollStart, () => {
this.fade(true)
})
// for mousewheel event
if (
scroll.eventTypes.mousewheelStart &&
scroll.eventTypes.mousewheelEnd
) {
this.registerHooks(scroll, scroll.eventTypes.mousewheelStart, () => {
this.fade(true)
})
this.registerHooks(scroll, scroll.eventTypes.mousewheelMove, () => {
this.fade(true)
})
this.registerHooks(scroll, scroll.eventTypes.mousewheelEnd, () => {
this.fade()
})
}
}
if (interactive) {
const { disableMouse, disableTouch } = this.scroll.options
this.eventHandler = new EventHandler(this, {
disableMouse,
disableTouch,
})
const eventHandlerHooks = this.eventHandler.hooks
this.registerHooks(
eventHandlerHooks,
eventHandlerHooks.eventTypes.touchStart,
this.startHandler
)
this.registerHooks(
eventHandlerHooks,
eventHandlerHooks.eventTypes.touchMove,
this.moveHandler
)
this.registerHooks(
eventHandlerHooks,
eventHandlerHooks.eventTypes.touchEnd,
this.endHandler
)
}
if (scrollbarTrackClickable) {
this.bindClick()
}
}
private registerHooks(hooks: EventEmitter, name: string, handler: Function) {
hooks.on(name, handler, this)
this.hooksFn.push([hooks, name, handler])
}
private bindClick() {
const wrapper = this.wrapper
this.clickEventRegister = new EventRegister(wrapper, [
{
name: 'click',
handler: this.handleClick.bind(this),
},
])
}
private handleClick(e: MouseEvent) {
const newPos = this.calculateclickOffsetPos(e)
let { x, y } = this.scroll
x = this.direction === IndicatorDirection.Horizontal ? newPos : x
y = this.direction === IndicatorDirection.Vertical ? newPos : y
this.scroll.scrollTo(x, y, this.options.scrollbarTrackOffsetTime)
}
private calculateclickOffsetPos(e: MouseEvent) {
const { point: poinKey, domRect: domRectKey } = this.keysMap
const { scrollbarTrackOffsetType } = this.options
const clickPointOffset = e[poinKey] - this.wrapperRect[domRectKey]
const scrollToWhere =
clickPointOffset < this.currentPos ? ScrollTo.Up : ScrollTo.Down
let delta = 0
let currentPos = this.currentPos
if (scrollbarTrackOffsetType === OffsetType.Step) {
delta = this.scrollInfo.baseSize * scrollToWhere
} else {
delta = 0
currentPos = clickPointOffset
}
return this.newPos(currentPos, delta, this.scrollInfo)
}
getKeysMap(): KeysMap {
if (this.direction === IndicatorDirection.Vertical) {
return {
hasScroll: 'hasVerticalScroll',
size: 'height',
wrapperSize: 'clientHeight',
scrollerSize: 'scrollerHeight',
maxScrollPos: 'maxScrollY',
pos: 'y',
point: 'pageY',
translateProperty: 'translateY',
domRect: 'top',
}
}
return {
hasScroll: 'hasHorizontalScroll',
size: 'width',
wrapperSize: 'clientWidth',
scrollerSize: 'scrollerWidth',
maxScrollPos: 'maxScrollX',
pos: 'x',
point: 'pageX',
translateProperty: 'translateX',
domRect: 'left',
}
}
fade(visible?: boolean) {
const { fadeInTime, fadeOutTime } = this.options
const time = visible ? fadeInTime : fadeOutTime
const wrapper = this.wrapper
wrapper.style[style.transitionDuration as any] = time + 'ms'
wrapper.style.opacity = visible ? '1' : '0'
}
refresh() {
const { hasScroll: hasScrollKey } = this.keysMap
const scroll = this.scroll
const { x, y } = scroll
this.wrapperRect = this.wrapper.getBoundingClientRect()
if (this.canScroll(scroll[hasScrollKey])) {
let {
wrapperSize: wrapperSizeKey,
scrollerSize: scrollerSizeKey,
maxScrollPos: maxScrollPosKey,
} = this.keysMap
this.scrollInfo = this.refreshScrollInfo(
this.wrapper[wrapperSizeKey],
scroll[scrollerSizeKey],
scroll[maxScrollPosKey],
this.indicatorEl[wrapperSizeKey]
)
this.updatePosition({
x,
y,
})
}
}
transitionTime(time: number = 0) {
this.indicatorEl.style[style.transitionDuration as any] = time + 'ms'
}
transitionTimingFunction(easing: string) {
this.indicatorEl.style[style.transitionTimingFunction as any] = easing
}
private canScroll(hasScroll: boolean): boolean {
this.wrapper.style.display = hasScroll ? 'block' : 'none'
return hasScroll
}
private refreshScrollInfo(
wrapperSize: number,
scrollerSize: number,
maxScrollPos: number,
indicatorElSize: number
): ScrollInfo {
let baseSize = Math.max(
Math.round(
(wrapperSize * wrapperSize) / (scrollerSize || wrapperSize || 1)
),
this.options.minSize
)
if (this.options.isCustom) {
baseSize = indicatorElSize
}
const maxIndicatorScrollPos = wrapperSize - baseSize
// sizeRatio is negative
let sizeRatio = maxIndicatorScrollPos / maxScrollPos
return {
baseSize,
maxScrollPos: maxIndicatorScrollPos,
minScrollPos: 0,
sizeRatio,
}
}
updatePosition(point: TranslaterPoint) {
const { pos, size } = this.caculatePosAndSize(point, this.scrollInfo)
this.refreshStyle(size, pos)
this.currentPos = pos
}
private caculatePosAndSize(
point: TranslaterPoint,
scrollInfo: ScrollInfo
): { pos: number; size: number } {
const { pos: posKey } = this.keysMap
const { sizeRatio, baseSize, maxScrollPos, minScrollPos } = scrollInfo
const minSize = this.options.minSize
let pos = Math.round(sizeRatio * point[posKey])
let size
// when out of boundary, slow down size reduction
if (pos < minScrollPos) {
size = Math.max(baseSize + pos * 3, minSize)
pos = minScrollPos
} else if (pos > maxScrollPos) {
size = Math.max(baseSize - (pos - maxScrollPos) * 3, minSize)
pos = maxScrollPos + baseSize - size
} else {
size = baseSize
}
return {
pos,
size,
}
}
private refreshStyle(size: number, pos: number) {
const {
translateProperty: translatePropertyKey,
size: sizeKey,
} = this.keysMap
const translateZ = this.scroll.options.translateZ
this.indicatorEl.style[sizeKey] = `${size}px`
this.indicatorEl.style[
style.transform as any
] = `${translatePropertyKey}(${pos}px)${translateZ}`
}
startHandler() {
this.moved = false
this.startTime = getNow()
this.transitionTime()
this.scroll.scroller.hooks.trigger(
this.scroll.scroller.hooks.eventTypes.beforeScrollStart
)
}
moveHandler(delta: number) {
if (!this.moved && !this.indicatorNotMoved(delta)) {
this.moved = true
this.scroll.scroller.hooks.trigger(
this.scroll.scroller.hooks.eventTypes.scrollStart
)
}
if (this.moved) {
const newPos = this.newPos(this.currentPos, delta, this.scrollInfo)
this.syncBScroll(newPos)
}
}
endHandler() {
if (this.moved) {
const { x, y } = this.scroll
this.scroll.scroller.hooks.trigger(
this.scroll.scroller.hooks.eventTypes.scrollEnd,
{
x,
y,
}
)
}
}
private indicatorNotMoved(delta: number): boolean {
const currentPos = this.currentPos
const { maxScrollPos, minScrollPos } = this.scrollInfo
const notMoved =
(currentPos === minScrollPos && delta <= 0) ||
(currentPos === maxScrollPos && delta >= 0)
return notMoved
}
private syncBScroll(newPos: number) {
const timestamp = getNow()
const {
x,
y,
options,
scroller,
maxScrollY,
minScrollY,
maxScrollX,
minScrollX,
} = this.scroll
const { probeType, momentumLimitTime } = options
const position = { x, y }
if (this.direction === IndicatorDirection.Vertical) {
position.y = between(newPos, maxScrollY, minScrollY)
} else {
position.x = between(newPos, maxScrollX, minScrollX)
}
scroller.translater.translate(position)
// dispatch scroll in interval time
if (timestamp - this.startTime > momentumLimitTime) {
this.startTime = timestamp
if (probeType === Probe.Throttle) {
scroller.hooks.trigger(scroller.hooks.eventTypes.scroll, position)
}
}
// dispatch scroll all the time
if (probeType > Probe.Throttle) {
scroller.hooks.trigger(scroller.hooks.eventTypes.scroll, position)
}
}
private newPos(
currentPos: number,
delta: number,
scrollInfo: ScrollInfo
): number {
const { maxScrollPos, sizeRatio, minScrollPos } = scrollInfo
let newPos = currentPos + delta
newPos = between(newPos, minScrollPos, maxScrollPos)
return Math.round(newPos / sizeRatio)
}
destroy() {
const { interactive, scrollbarTrackClickable, isCustom } = this.options
if (interactive) {
this.eventHandler.destroy()
}
if (scrollbarTrackClickable) {
this.clickEventRegister.destroy()
}
if (!isCustom) {
this.wrapper.parentNode!.removeChild(this.wrapper)
}
this.hooksFn.forEach((item) => {
const hooks = item[0]
const hooksName = item[1]
const handlerFn = item[2]
hooks.off(hooksName, handlerFn)
})
this.hooksFn.length = 0
}
}