123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628 |
- import BScroll from '@better-scroll/core'
- import {
- between,
- prepend,
- removeChild,
- ease,
- extend,
- EaseItem,
- Direction,
- warn,
- EventEmitter,
- } from '@better-scroll/shared-utils'
- import SlidePages, { Page, Position } from './SlidePages'
- import propertiesConfig from './propertiesConfig'
- import { BASE_PAGE } from './constants'
- export interface SlideConfig {
- loop: boolean
- threshold: number
- speed: number
- easing: {
- style: string
- fn: (t: number) => number
- }
- listenFlick: boolean
- autoplay: boolean
- interval: number
- startPageXIndex: number
- startPageYIndex: number
- }
- export type SlideOptions = Partial<SlideConfig> | true
- declare module '@better-scroll/core' {
- interface CustomOptions {
- slide?: SlideOptions
- }
- interface CustomAPI {
- slide: PluginAPI
- }
- }
- interface PluginAPI {
- next(time?: number, easing?: EaseItem): void
- prev(time?: number, easing?: EaseItem): void
- goToPage(x: number, y: number, time?: number, easing?: EaseItem): void
- getCurrentPage(): Page
- startPlay(): void
- pausePlay(): void
- }
- const samePage = (p1: Page, p2: Page) => {
- return p1.pageX === p2.pageX && p1.pageY === p2.pageY
- }
- type styleConfiguration = {
- direction: 'scrollX' | 'scrollY'
- sizeType: 'offsetWidth' | 'offsetHeight'
- styleType: 'width' | 'height'
- }
- export default class Slide implements PluginAPI {
- static pluginName = 'slide'
- pages: SlidePages
- options: SlideConfig
- initialised: boolean
- contentChanged: boolean
- prevContent: HTMLElement
- exposedPage: Page
- private cachedClonedPageDOM: HTMLElement[] = []
- private oneToMorePagesInLoop: boolean
- private moreToOnePageInLoop: boolean
- private thresholdX: number
- private thresholdY: number
- private hooksFn: Array<[EventEmitter, string, Function]>
- private resetLooping = false
- private willChangeToPage: Page
- private autoplayTimer: number = 0
- constructor(public scroll: BScroll) {
- if (!this.satisfyInitialization()) {
- return
- }
- this.init()
- }
- private satisfyInitialization(): boolean {
- if (this.scroll.scroller.content.children.length <= 0) {
- warn(
- `slide need at least one slide page to be initialised.` +
- `please check your DOM layout.`
- )
- return false
- }
- return true
- }
- init() {
- this.willChangeToPage = extend({}, BASE_PAGE)
- this.handleBScroll()
- this.handleOptions()
- this.handleHooks()
- this.createPages()
- }
- private createPages() {
- this.pages = new SlidePages(this.scroll, this.options)
- }
- private handleBScroll() {
- this.scroll.registerType(['slideWillChange', 'slidePageChanged'])
- this.scroll.proxy(propertiesConfig)
- }
- private handleOptions() {
- const userOptions = (this.scroll.options.slide === true
- ? {}
- : this.scroll.options.slide) as Partial<SlideConfig>
- const defaultOptions: SlideConfig = {
- loop: true,
- threshold: 0.1,
- speed: 400,
- easing: ease.bounce,
- listenFlick: true,
- autoplay: true,
- interval: 3000,
- startPageXIndex: 0,
- startPageYIndex: 0,
- }
- this.options = extend(defaultOptions, userOptions)
- }
- private handleLoop(prevSlideContent: HTMLElement) {
- const { loop } = this.options
- const slideContent = this.scroll.scroller.content
- const currentSlidePagesLength = slideContent.children.length
- // only should respect loop scene
- if (loop) {
- if (slideContent !== prevSlideContent) {
- this.resetLoopChangedStatus()
- this.removeClonedSlidePage(prevSlideContent)
- currentSlidePagesLength > 1 &&
- this.cloneFirstAndLastSlidePage(slideContent)
- } else {
- // many pages reduce to one page
- if (currentSlidePagesLength === 3 && this.initialised) {
- this.removeClonedSlidePage(slideContent)
- this.moreToOnePageInLoop = true
- this.oneToMorePagesInLoop = false
- } else if (currentSlidePagesLength > 1) {
- // one page increases to many page
- if (this.initialised && this.cachedClonedPageDOM.length === 0) {
- this.oneToMorePagesInLoop = true
- this.moreToOnePageInLoop = false
- } else {
- this.removeClonedSlidePage(slideContent)
- this.resetLoopChangedStatus()
- }
- this.cloneFirstAndLastSlidePage(slideContent)
- } else {
- this.resetLoopChangedStatus()
- }
- }
- }
- }
- private resetLoopChangedStatus() {
- this.moreToOnePageInLoop = false
- this.oneToMorePagesInLoop = false
- }
- private handleHooks() {
- const scrollHooks = this.scroll.hooks
- const scrollerHooks = this.scroll.scroller.hooks
- const { listenFlick } = this.options
- this.prevContent = this.scroll.scroller.content
- this.hooksFn = []
- // scroll
- this.registerHooks(
- this.scroll,
- this.scroll.eventTypes.beforeScrollStart,
- this.pausePlay
- )
- this.registerHooks(
- this.scroll,
- this.scroll.eventTypes.scrollEnd,
- this.modifyCurrentPage
- )
- this.registerHooks(
- this.scroll,
- this.scroll.eventTypes.scrollEnd,
- this.startPlay
- )
- // for mousewheel event
- if (this.scroll.eventTypes.mousewheelMove) {
- this.registerHooks(
- this.scroll,
- this.scroll.eventTypes.mousewheelMove,
- () => {
- // prevent default action of mousewheelMove
- return true
- }
- )
- this.registerHooks(
- this.scroll,
- this.scroll.eventTypes.mousewheelEnd,
- (delta: { directionX: number; directionY: number }) => {
- if (
- delta.directionX === Direction.Positive ||
- delta.directionY === Direction.Positive
- ) {
- this.next()
- }
- if (
- delta.directionX === Direction.Negative ||
- delta.directionY === Direction.Negative
- ) {
- this.prev()
- }
- }
- )
- }
- // scrollHooks
- this.registerHooks(
- scrollHooks,
- scrollHooks.eventTypes.refresh,
- this.refreshHandler
- )
- this.registerHooks(
- scrollHooks,
- scrollHooks.eventTypes.destroy,
- this.destroy
- )
- // scroller
- this.registerHooks(
- scrollerHooks,
- scrollerHooks.eventTypes.beforeRefresh,
- () => {
- this.handleLoop(this.prevContent)
- this.setSlideInlineStyle()
- }
- )
- this.registerHooks(
- scrollerHooks,
- scrollerHooks.eventTypes.momentum,
- this.modifyScrollMetaHandler
- )
- this.registerHooks(
- scrollerHooks,
- scrollerHooks.eventTypes.scroll,
- this.scrollHandler
- )
- // a click operation will clearTimer, so restart a new one
- this.registerHooks(
- scrollerHooks,
- scrollerHooks.eventTypes.checkClick,
- this.startPlay
- )
- if (listenFlick) {
- this.registerHooks(
- scrollerHooks,
- scrollerHooks.eventTypes.flick,
- this.flickHandler
- )
- }
- }
- startPlay() {
- const { interval, autoplay } = this.options
- if (autoplay) {
- clearTimeout(this.autoplayTimer)
- this.autoplayTimer = window.setTimeout(() => {
- this.next()
- }, interval)
- }
- }
- pausePlay() {
- if (this.options.autoplay) {
- clearTimeout(this.autoplayTimer)
- }
- }
- private setSlideInlineStyle() {
- const styleConfigurations: styleConfiguration[] = [
- {
- direction: 'scrollX',
- sizeType: 'offsetWidth',
- styleType: 'width',
- },
- {
- direction: 'scrollY',
- sizeType: 'offsetHeight',
- styleType: 'height',
- },
- ]
- const {
- content: slideContent,
- wrapper: slideWrapper,
- } = this.scroll.scroller
- const scrollOptions = this.scroll.options
- styleConfigurations.forEach(({ direction, sizeType, styleType }) => {
- // wanna scroll in this direction
- if (scrollOptions[direction]) {
- const size = slideWrapper[sizeType]
- const children = slideContent.children
- const length = children.length
- for (let i = 0; i < length; i++) {
- const slidePageDOM = children[i] as HTMLElement
- slidePageDOM.style[styleType] = size + 'px'
- }
- slideContent.style[styleType] = size * length + 'px'
- }
- })
- }
- next(time?: number, easing?: EaseItem) {
- const { pageX, pageY } = this.pages.nextPageIndex()
- this.goTo(pageX, pageY, time, easing)
- }
- prev(time?: number, easing?: EaseItem) {
- const { pageX, pageY } = this.pages.prevPageIndex()
- this.goTo(pageX, pageY, time, easing)
- }
- goToPage(pageX: number, pageY: number, time?: number, easing?: EaseItem) {
- const pageIndex = this.pages.getValidPageIndex(pageX, pageY)
- this.goTo(pageIndex.pageX, pageIndex.pageY, time, easing)
- }
- getCurrentPage(): Page {
- return this.exposedPage || this.pages.getInitialPage(false, true)
- }
- setCurrentPage(page: Page) {
- this.pages.setCurrentPage(page)
- this.exposedPage = this.pages.getExposedPage(page)
- }
- nearestPage(x: number, y: number): Page {
- const { scrollBehaviorX, scrollBehaviorY } = this.scroll.scroller
- const {
- maxScrollPos: maxScrollPosX,
- minScrollPos: minScrollPosX,
- } = scrollBehaviorX
- const {
- maxScrollPos: maxScrollPosY,
- minScrollPos: minScrollPosY,
- } = scrollBehaviorY
- return this.pages.getNearestPage(
- between(x, maxScrollPosX, minScrollPosX),
- between(y, maxScrollPosY, minScrollPosY)
- )
- }
- private satisfyThreshold(x: number, y: number): boolean {
- const { scrollBehaviorX, scrollBehaviorY } = this.scroll.scroller
- let satisfied = true
- if (
- Math.abs(x - scrollBehaviorX.absStartPos) <= this.thresholdX &&
- Math.abs(y - scrollBehaviorY.absStartPos) <= this.thresholdY
- ) {
- satisfied = false
- }
- return satisfied
- }
- private refreshHandler(content: HTMLElement) {
- if (!this.satisfyInitialization()) {
- return
- }
- this.pages.refresh()
- this.computeThreshold()
- const contentChanged = (this.contentChanged = this.prevContent !== content)
- if (contentChanged) {
- this.prevContent = content
- }
- const initPage = this.pages.getInitialPage(
- this.oneToMorePagesInLoop || this.moreToOnePageInLoop,
- contentChanged || !this.initialised
- )
- if (this.initialised) {
- this.goTo(initPage.pageX, initPage.pageY, 0)
- } else {
- this.registerHooks(
- this.scroll.hooks,
- this.scroll.hooks.eventTypes.beforeInitialScrollTo,
- (position: { x: number; y: number }) => {
- this.initialised = true
- position.x = initPage.x
- position.y = initPage.y
- }
- )
- }
- this.startPlay()
- }
- private computeThreshold() {
- const threshold = this.options.threshold
- // Integer
- if (threshold % 1 === 0) {
- this.thresholdX = threshold
- this.thresholdY = threshold
- } else {
- // decimal
- const { width, height } = this.pages.getPageStats()
- this.thresholdX = Math.round(width * threshold)
- this.thresholdY = Math.round(height * threshold)
- }
- }
- private cloneFirstAndLastSlidePage(slideContent: HTMLElement) {
- const children = slideContent.children
- const preprendDOM = children[children.length - 1].cloneNode(
- true
- ) as HTMLElement
- const appendDOM = children[0].cloneNode(true) as HTMLElement
- prepend(preprendDOM, slideContent)
- slideContent.appendChild(appendDOM)
- this.cachedClonedPageDOM = [preprendDOM, appendDOM]
- }
- private removeClonedSlidePage(slideContent: HTMLElement) {
- // maybe slideContent has removed from DOM Tree
- const slidePages = (slideContent && slideContent.children) || []
- if (slidePages.length) {
- this.cachedClonedPageDOM.forEach((el) => {
- removeChild(slideContent, el)
- })
- }
- this.cachedClonedPageDOM = []
- }
- private modifyCurrentPage(point: Position) {
- const {
- pageX: prevExposedPageX,
- pageY: prevExposedPageY,
- } = this.getCurrentPage()
- const newPage = this.nearestPage(point.x, point.y)
- this.setCurrentPage(newPage)
- /* istanbul ignore if */
- if (this.contentChanged) {
- this.contentChanged = false
- return true
- }
- const {
- pageX: currentExposedPageX,
- pageY: currentExposedPageY,
- } = this.getCurrentPage()
- this.pageWillChangeTo(newPage)
- // loop is true, and one page becomes many pages when call bs.refresh
- if (this.oneToMorePagesInLoop) {
- this.oneToMorePagesInLoop = false
- return true
- }
- // loop is true, and many page becomes one page when call bs.refresh
- // if prevPage > 0, dispatch slidePageChanged and scrollEnd events
- /* istanbul ignore if */
- if (
- this.moreToOnePageInLoop &&
- prevExposedPageX === 0 &&
- prevExposedPageY === 0
- ) {
- this.moreToOnePageInLoop = false
- return true
- }
- if (
- prevExposedPageX !== currentExposedPageX ||
- prevExposedPageY !== currentExposedPageY
- ) {
- // only trust pageX & pageY when loop is true
- const page = this.pages.getExposedPageByPageIndex(
- currentExposedPageX,
- currentExposedPageY
- )
- this.scroll.trigger(this.scroll.eventTypes.slidePageChanged, page)
- }
- // triggered by resetLoop
- if (this.resetLooping) {
- this.resetLooping = false
- return
- }
- const changePage = this.pages.resetLoopPage()
- if (changePage) {
- this.resetLooping = true
- this.goTo(changePage.pageX, changePage.pageY, 0)
- // stop user's scrollEnd
- // since it is a seamless scroll
- return true
- }
- }
- private goTo(pageX: number, pageY: number, time?: number, easing?: EaseItem) {
- const newPage = this.pages.getInternalPage(pageX, pageY)
- const scrollEasing = easing || this.options.easing || ease.bounce
- const { x, y } = newPage
- const deltaX = x - this.scroll.scroller.scrollBehaviorX.currentPos
- const deltaY = y - this.scroll.scroller.scrollBehaviorY.currentPos
- /* istanbul ignore if */
- if (!deltaX && !deltaY) {
- this.scroll.scroller.togglePointerEvents(true)
- return
- }
- time = time === undefined ? this.getEaseTime(deltaX, deltaY) : time
- this.scroll.scroller.scrollTo(x, y, time, scrollEasing)
- }
- private flickHandler() {
- const { scrollBehaviorX, scrollBehaviorY } = this.scroll.scroller
- const {
- currentPos: currentPosX,
- startPos: startPosX,
- direction: directionX,
- } = scrollBehaviorX
- const {
- currentPos: currentPosY,
- startPos: startPosY,
- direction: directionY,
- } = scrollBehaviorY
- const { pageX, pageY } = this.pages.currentPage
- let time = this.getEaseTime(
- currentPosX - startPosX,
- currentPosY - startPosY
- )
- this.goTo(pageX + directionX, pageY + directionY, time)
- }
- private getEaseTime(deltaX: number, deltaY: number): number {
- return (
- this.options.speed ||
- Math.max(
- Math.max(
- Math.min(Math.abs(deltaX), 1000),
- Math.min(Math.abs(deltaY), 1000)
- ),
- 300
- )
- )
- }
- private modifyScrollMetaHandler(scrollMeta: {
- newX: number
- newY: number
- time: number
- [key: string]: any
- }) {
- const { scrollBehaviorX, scrollBehaviorY, animater } = this.scroll.scroller
- const newX = scrollMeta.newX
- const newY = scrollMeta.newY
- const newPage =
- this.satisfyThreshold(newX, newY) || animater.forceStopped
- ? this.pages.getPageByDirection(
- this.nearestPage(newX, newY),
- scrollBehaviorX.direction,
- scrollBehaviorY.direction
- )
- : this.pages.currentPage
- scrollMeta.time = this.getEaseTime(
- scrollMeta.newX - newPage.x,
- scrollMeta.newY - newPage.y
- )
- scrollMeta.newX = newPage.x
- scrollMeta.newY = newPage.y
- scrollMeta.easing = this.options.easing || ease.bounce
- }
- private scrollHandler({ x, y }: Position) {
- if (this.satisfyThreshold(x, y)) {
- const newPage = this.nearestPage(x, y)
- this.pageWillChangeTo(newPage)
- }
- }
- private pageWillChangeTo(newPage: Page) {
- const changeToPage = this.pages.getWillChangedPage(newPage)
- if (!samePage(this.willChangeToPage, changeToPage)) {
- this.willChangeToPage = changeToPage
- this.scroll.trigger(
- this.scroll.eventTypes.slideWillChange,
- this.willChangeToPage
- )
- }
- }
- private registerHooks(hooks: EventEmitter, name: string, handler: Function) {
- hooks.on(name, handler, this)
- this.hooksFn.push([hooks, name, handler])
- }
- destroy() {
- const slideContent = this.scroll.scroller.content
- const { loop, autoplay } = this.options
- if (loop) {
- this.removeClonedSlidePage(slideContent)
- }
- if (autoplay) {
- clearTimeout(this.autoplayTimer)
- }
- this.hooksFn.forEach((item) => {
- const hooks = item[0]
- const hooksName = item[1]
- const handlerFn = item[2]
- if (hooks.eventTypes[hooksName]) {
- hooks.off(hooksName, handlerFn)
- }
- })
- this.hooksFn.length = 0
- }
- }
|