import BScroll, { MountedBScrollHTMLElement } from '@better-scroll/core' import { Direction, EventEmitter, extend, warn, findIndex, } from '@better-scroll/shared-utils' import BScrollFamily from './BScrollFamily' import propertiesConfig from './propertiesConfig' export const DEFAUL_GROUP_ID = 'INTERNAL_NESTED_SCROLL' export type NestedScrollGroupId = string | number export interface NestedScrollConfig { groupId: NestedScrollGroupId } export type NestedScrollOptions = NestedScrollConfig | true declare module '@better-scroll/core' { interface CustomOptions { nestedScroll?: NestedScrollOptions } interface CustomAPI { nestedScroll: PluginAPI } } interface PluginAPI { purgeNestedScroll(groupId: NestedScrollGroupId): void } interface NestedScrollInstancesMap { [key: string]: NestedScroll [index: number]: NestedScroll } const forceScrollStopHandler = (scrolls: BScroll[]) => { scrolls.forEach((scroll) => { if (scroll.pending) { scroll.stop() scroll.resetPosition() } }) } const enableScrollHander = (scrolls: BScroll[]) => { scrolls.forEach((scroll) => { scroll.enable() }) } const disableScrollHander = (scrolls: BScroll[], currentScroll: BScroll) => { scrolls.forEach((scroll) => { if ( scroll.hasHorizontalScroll === currentScroll.hasHorizontalScroll || scroll.hasVerticalScroll === currentScroll.hasVerticalScroll ) { scroll.disable() } }) } const syncTouchstartData = (scrolls: BScroll[]) => { scrolls.forEach((scroll) => { const { actions, scrollBehaviorX, scrollBehaviorY } = scroll.scroller // prevent click triggering many times actions.fingerMoved = true actions.contentMoved = false actions.directionLockAction.reset() scrollBehaviorX.start() scrollBehaviorY.start() scrollBehaviorX.resetStartPos() scrollBehaviorY.resetStartPos() actions.startTime = +new Date() }) } const isOutOfBoundary = (scroll: BScroll): boolean => { const { hasHorizontalScroll, hasVerticalScroll, x, y, minScrollX, maxScrollX, minScrollY, maxScrollY, movingDirectionX, movingDirectionY, } = scroll let ret = false const outOfLeftBoundary = x >= minScrollX && movingDirectionX === Direction.Negative const outOfRightBoundary = x <= maxScrollX && movingDirectionX === Direction.Positive const outOfTopBoundary = y >= minScrollY && movingDirectionY === Direction.Negative const outOfBottomBoundary = y <= maxScrollY && movingDirectionY === Direction.Positive if (hasVerticalScroll) { ret = outOfTopBoundary || outOfBottomBoundary } else if (hasHorizontalScroll) { ret = outOfLeftBoundary || outOfRightBoundary } return ret } const isResettingPosition = (scroll: BScroll): boolean => { const { hasHorizontalScroll, hasVerticalScroll, x, y, minScrollX, maxScrollX, minScrollY, maxScrollY, } = scroll let ret = false const outOfLeftBoundary = x > minScrollX const outOfRightBoundary = x < maxScrollX const outOfTopBoundary = y > minScrollY const outOfBottomBoundary = y < maxScrollY if (hasVerticalScroll) { ret = outOfTopBoundary || outOfBottomBoundary } else if (hasHorizontalScroll) { ret = outOfLeftBoundary || outOfRightBoundary } return ret } const resetPositionHandler = (scroll: BScroll) => { scroll.scroller.reflow() scroll.resetPosition(0 /* Immediately */) } const calculateDistance = ( childNode: HTMLElement, parentNode: HTMLElement ): number => { let distance = 0 let parent = childNode.parentNode while (parent && parent !== parentNode) { distance++ parent = parent.parentNode } return distance } export default class NestedScroll implements PluginAPI { static pluginName = 'nestedScroll' static instancesMap: NestedScrollInstancesMap = {} store: BScrollFamily[] options: NestedScrollConfig private hooksFn: Array<[EventEmitter, string, Function]> constructor(scroll: BScroll) { const groupId = this.handleOptions(scroll) let instance = NestedScroll.instancesMap[groupId] if (!instance) { instance = NestedScroll.instancesMap[groupId] = this instance.store = [] instance.hooksFn = [] } instance.init(scroll) return instance } static getAllNestedScrolls(): NestedScroll[] { const instancesMap = NestedScroll.instancesMap return Object.keys(instancesMap).map((key) => instancesMap[key]) } static purgeAllNestedScrolls() { const nestedScrolls = NestedScroll.getAllNestedScrolls() nestedScrolls.forEach((ns) => ns.purgeNestedScroll()) } private handleOptions(scroll: BScroll): number | string { const userOptions = (scroll.options.nestedScroll === true ? {} : scroll.options.nestedScroll) as NestedScrollConfig const defaultOptions: NestedScrollConfig = { groupId: DEFAUL_GROUP_ID, } this.options = extend(defaultOptions, userOptions) const groupIdType = typeof this.options.groupId if (groupIdType !== 'string' && groupIdType !== 'number') { warn('groupId must be string or number for NestedScroll plugin') } return this.options.groupId } private init(scroll: BScroll) { scroll.proxy(propertiesConfig) this.addBScroll(scroll) this.buildBScrollGraph() this.analyzeBScrollGraph() this.ensureEventInvokeSequence() this.handleHooks(scroll) } private handleHooks(scroll: BScroll) { this.registerHooks(scroll.hooks, scroll.hooks.eventTypes.destroy, () => { this.deleteScroll(scroll) }) } deleteScroll(scroll: BScroll) { const wrapper = scroll.wrapper as MountedBScrollHTMLElement wrapper.isBScrollContainer = undefined const store = this.store const hooksFn = this.hooksFn const i = findIndex(store, (bscrollFamily) => { return bscrollFamily.selfScroll === scroll }) if (i > -1) { const bscrollFamily = store[i] bscrollFamily.purge() store.splice(i, 1) } const k = findIndex(hooksFn, ([hooks]) => { return hooks === scroll.hooks }) if (k > -1) { const [hooks, eventType, handler] = hooksFn[k] hooks.off(eventType, handler) hooksFn.splice(k, 1) } } addBScroll(scroll: BScroll) { this.store.push(BScrollFamily.create(scroll)) } private buildBScrollGraph() { const store = this.store let bf1: BScrollFamily let bf2: BScrollFamily let wrapper1: MountedBScrollHTMLElement let wrapper2: MountedBScrollHTMLElement let len = this.store.length // build graph for (let i = 0; i < len; i++) { bf1 = store[i] wrapper1 = bf1.selfScroll.wrapper for (let j = 0; j < len; j++) { bf2 = store[j] wrapper2 = bf2.selfScroll.wrapper // same bs if (bf1 === bf2) continue if (!wrapper1.contains(wrapper2)) continue // bs1 contains bs2 const distance = calculateDistance(wrapper2, wrapper1) if (!bf1.hasDescendants(bf2)) { bf1.addDescendant(bf2, distance) } if (!bf2.hasAncestors(bf1)) { bf2.addAncestor(bf1, distance) } } } } private analyzeBScrollGraph() { this.store.forEach((bscrollFamily) => { if (bscrollFamily.analyzed) { return } const { ancestors, descendants, selfScroll: currentScroll, } = bscrollFamily const beforeScrollStartHandler = () => { // always get the latest scroll const ancestorScrolls = ancestors.map( ([bscrollFamily]) => bscrollFamily.selfScroll ) const descendantScrolls = descendants.map( ([bscrollFamily]) => bscrollFamily.selfScroll ) forceScrollStopHandler([...ancestorScrolls, ...descendantScrolls]) if (isResettingPosition(currentScroll)) { resetPositionHandler(currentScroll) } syncTouchstartData(ancestorScrolls) disableScrollHander(ancestorScrolls, currentScroll) } const touchEndHandler = () => { const ancestorScrolls = ancestors.map( ([bscrollFamily]) => bscrollFamily.selfScroll ) const descendantScrolls = descendants.map( ([bscrollFamily]) => bscrollFamily.selfScroll ) enableScrollHander([...ancestorScrolls, ...descendantScrolls]) } bscrollFamily.registerHooks( currentScroll, currentScroll.eventTypes.beforeScrollStart, beforeScrollStartHandler ) bscrollFamily.registerHooks( currentScroll, currentScroll.eventTypes.touchEnd, touchEndHandler ) const selfActionsHooks = currentScroll.scroller.actions.hooks bscrollFamily.registerHooks( selfActionsHooks, selfActionsHooks.eventTypes.detectMovingDirection, () => { const ancestorScrolls = ancestors.map( ([bscrollFamily]) => bscrollFamily.selfScroll ) const parentScroll = ancestorScrolls[0] const otherAncestorScrolls = ancestorScrolls.slice(1) const contentMoved = currentScroll.scroller.actions.contentMoved const isTopScroll = ancestorScrolls.length === 0 if (contentMoved) { disableScrollHander(ancestorScrolls, currentScroll) } else if (!isTopScroll) { if (isOutOfBoundary(currentScroll)) { disableScrollHander([currentScroll], currentScroll) if (parentScroll) { enableScrollHander([parentScroll]) } disableScrollHander(otherAncestorScrolls, currentScroll) return true } } } ) bscrollFamily.setAnalyzed(true) }) } // make sure touchmove|touchend invoke from child to parent private ensureEventInvokeSequence() { const copied = this.store.slice() const sequencedScroll = copied.sort((a, b) => { return a.descendants.length - b.descendants.length }) sequencedScroll.forEach((bscrollFamily) => { const scroll = bscrollFamily.selfScroll scroll.scroller.actionsHandler.rebindDOMEvents() }) } private registerHooks(hooks: EventEmitter, name: string, handler: Function) { hooks.on(name, handler, this) this.hooksFn.push([hooks, name, handler]) } purgeNestedScroll() { const groupId = this.options.groupId this.store.forEach((bscrollFamily) => { bscrollFamily.purge() }) this.store = [] this.hooksFn.forEach(([hooks, eventType, handler]) => { hooks.off(eventType, handler) }) this.hooksFn = [] delete NestedScroll.instancesMap[groupId] } }