index.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628
  1. import BScroll from '@better-scroll/core'
  2. import {
  3. between,
  4. prepend,
  5. removeChild,
  6. ease,
  7. extend,
  8. EaseItem,
  9. Direction,
  10. warn,
  11. EventEmitter,
  12. } from '@better-scroll/shared-utils'
  13. import SlidePages, { Page, Position } from './SlidePages'
  14. import propertiesConfig from './propertiesConfig'
  15. import { BASE_PAGE } from './constants'
  16. export interface SlideConfig {
  17. loop: boolean
  18. threshold: number
  19. speed: number
  20. easing: {
  21. style: string
  22. fn: (t: number) => number
  23. }
  24. listenFlick: boolean
  25. autoplay: boolean
  26. interval: number
  27. startPageXIndex: number
  28. startPageYIndex: number
  29. }
  30. export type SlideOptions = Partial<SlideConfig> | true
  31. declare module '@better-scroll/core' {
  32. interface CustomOptions {
  33. slide?: SlideOptions
  34. }
  35. interface CustomAPI {
  36. slide: PluginAPI
  37. }
  38. }
  39. interface PluginAPI {
  40. next(time?: number, easing?: EaseItem): void
  41. prev(time?: number, easing?: EaseItem): void
  42. goToPage(x: number, y: number, time?: number, easing?: EaseItem): void
  43. getCurrentPage(): Page
  44. startPlay(): void
  45. pausePlay(): void
  46. }
  47. const samePage = (p1: Page, p2: Page) => {
  48. return p1.pageX === p2.pageX && p1.pageY === p2.pageY
  49. }
  50. type styleConfiguration = {
  51. direction: 'scrollX' | 'scrollY'
  52. sizeType: 'offsetWidth' | 'offsetHeight'
  53. styleType: 'width' | 'height'
  54. }
  55. export default class Slide implements PluginAPI {
  56. static pluginName = 'slide'
  57. pages: SlidePages
  58. options: SlideConfig
  59. initialised: boolean
  60. contentChanged: boolean
  61. prevContent: HTMLElement
  62. exposedPage: Page
  63. private cachedClonedPageDOM: HTMLElement[] = []
  64. private oneToMorePagesInLoop: boolean
  65. private moreToOnePageInLoop: boolean
  66. private thresholdX: number
  67. private thresholdY: number
  68. private hooksFn: Array<[EventEmitter, string, Function]>
  69. private resetLooping = false
  70. private willChangeToPage: Page
  71. private autoplayTimer: number = 0
  72. constructor(public scroll: BScroll) {
  73. if (!this.satisfyInitialization()) {
  74. return
  75. }
  76. this.init()
  77. }
  78. private satisfyInitialization(): boolean {
  79. if (this.scroll.scroller.content.children.length <= 0) {
  80. warn(
  81. `slide need at least one slide page to be initialised.` +
  82. `please check your DOM layout.`
  83. )
  84. return false
  85. }
  86. return true
  87. }
  88. init() {
  89. this.willChangeToPage = extend({}, BASE_PAGE)
  90. this.handleBScroll()
  91. this.handleOptions()
  92. this.handleHooks()
  93. this.createPages()
  94. }
  95. private createPages() {
  96. this.pages = new SlidePages(this.scroll, this.options)
  97. }
  98. private handleBScroll() {
  99. this.scroll.registerType(['slideWillChange', 'slidePageChanged'])
  100. this.scroll.proxy(propertiesConfig)
  101. }
  102. private handleOptions() {
  103. const userOptions = (this.scroll.options.slide === true
  104. ? {}
  105. : this.scroll.options.slide) as Partial<SlideConfig>
  106. const defaultOptions: SlideConfig = {
  107. loop: true,
  108. threshold: 0.1,
  109. speed: 400,
  110. easing: ease.bounce,
  111. listenFlick: true,
  112. autoplay: true,
  113. interval: 3000,
  114. startPageXIndex: 0,
  115. startPageYIndex: 0,
  116. }
  117. this.options = extend(defaultOptions, userOptions)
  118. }
  119. private handleLoop(prevSlideContent: HTMLElement) {
  120. const { loop } = this.options
  121. const slideContent = this.scroll.scroller.content
  122. const currentSlidePagesLength = slideContent.children.length
  123. // only should respect loop scene
  124. if (loop) {
  125. if (slideContent !== prevSlideContent) {
  126. this.resetLoopChangedStatus()
  127. this.removeClonedSlidePage(prevSlideContent)
  128. currentSlidePagesLength > 1 &&
  129. this.cloneFirstAndLastSlidePage(slideContent)
  130. } else {
  131. // many pages reduce to one page
  132. if (currentSlidePagesLength === 3 && this.initialised) {
  133. this.removeClonedSlidePage(slideContent)
  134. this.moreToOnePageInLoop = true
  135. this.oneToMorePagesInLoop = false
  136. } else if (currentSlidePagesLength > 1) {
  137. // one page increases to many page
  138. if (this.initialised && this.cachedClonedPageDOM.length === 0) {
  139. this.oneToMorePagesInLoop = true
  140. this.moreToOnePageInLoop = false
  141. } else {
  142. this.removeClonedSlidePage(slideContent)
  143. this.resetLoopChangedStatus()
  144. }
  145. this.cloneFirstAndLastSlidePage(slideContent)
  146. } else {
  147. this.resetLoopChangedStatus()
  148. }
  149. }
  150. }
  151. }
  152. private resetLoopChangedStatus() {
  153. this.moreToOnePageInLoop = false
  154. this.oneToMorePagesInLoop = false
  155. }
  156. private handleHooks() {
  157. const scrollHooks = this.scroll.hooks
  158. const scrollerHooks = this.scroll.scroller.hooks
  159. const { listenFlick } = this.options
  160. this.prevContent = this.scroll.scroller.content
  161. this.hooksFn = []
  162. // scroll
  163. this.registerHooks(
  164. this.scroll,
  165. this.scroll.eventTypes.beforeScrollStart,
  166. this.pausePlay
  167. )
  168. this.registerHooks(
  169. this.scroll,
  170. this.scroll.eventTypes.scrollEnd,
  171. this.modifyCurrentPage
  172. )
  173. this.registerHooks(
  174. this.scroll,
  175. this.scroll.eventTypes.scrollEnd,
  176. this.startPlay
  177. )
  178. // for mousewheel event
  179. if (this.scroll.eventTypes.mousewheelMove) {
  180. this.registerHooks(
  181. this.scroll,
  182. this.scroll.eventTypes.mousewheelMove,
  183. () => {
  184. // prevent default action of mousewheelMove
  185. return true
  186. }
  187. )
  188. this.registerHooks(
  189. this.scroll,
  190. this.scroll.eventTypes.mousewheelEnd,
  191. (delta: { directionX: number; directionY: number }) => {
  192. if (
  193. delta.directionX === Direction.Positive ||
  194. delta.directionY === Direction.Positive
  195. ) {
  196. this.next()
  197. }
  198. if (
  199. delta.directionX === Direction.Negative ||
  200. delta.directionY === Direction.Negative
  201. ) {
  202. this.prev()
  203. }
  204. }
  205. )
  206. }
  207. // scrollHooks
  208. this.registerHooks(
  209. scrollHooks,
  210. scrollHooks.eventTypes.refresh,
  211. this.refreshHandler
  212. )
  213. this.registerHooks(
  214. scrollHooks,
  215. scrollHooks.eventTypes.destroy,
  216. this.destroy
  217. )
  218. // scroller
  219. this.registerHooks(
  220. scrollerHooks,
  221. scrollerHooks.eventTypes.beforeRefresh,
  222. () => {
  223. this.handleLoop(this.prevContent)
  224. this.setSlideInlineStyle()
  225. }
  226. )
  227. this.registerHooks(
  228. scrollerHooks,
  229. scrollerHooks.eventTypes.momentum,
  230. this.modifyScrollMetaHandler
  231. )
  232. this.registerHooks(
  233. scrollerHooks,
  234. scrollerHooks.eventTypes.scroll,
  235. this.scrollHandler
  236. )
  237. // a click operation will clearTimer, so restart a new one
  238. this.registerHooks(
  239. scrollerHooks,
  240. scrollerHooks.eventTypes.checkClick,
  241. this.startPlay
  242. )
  243. if (listenFlick) {
  244. this.registerHooks(
  245. scrollerHooks,
  246. scrollerHooks.eventTypes.flick,
  247. this.flickHandler
  248. )
  249. }
  250. }
  251. startPlay() {
  252. const { interval, autoplay } = this.options
  253. if (autoplay) {
  254. clearTimeout(this.autoplayTimer)
  255. this.autoplayTimer = window.setTimeout(() => {
  256. this.next()
  257. }, interval)
  258. }
  259. }
  260. pausePlay() {
  261. if (this.options.autoplay) {
  262. clearTimeout(this.autoplayTimer)
  263. }
  264. }
  265. private setSlideInlineStyle() {
  266. const styleConfigurations: styleConfiguration[] = [
  267. {
  268. direction: 'scrollX',
  269. sizeType: 'offsetWidth',
  270. styleType: 'width',
  271. },
  272. {
  273. direction: 'scrollY',
  274. sizeType: 'offsetHeight',
  275. styleType: 'height',
  276. },
  277. ]
  278. const {
  279. content: slideContent,
  280. wrapper: slideWrapper,
  281. } = this.scroll.scroller
  282. const scrollOptions = this.scroll.options
  283. styleConfigurations.forEach(({ direction, sizeType, styleType }) => {
  284. // wanna scroll in this direction
  285. if (scrollOptions[direction]) {
  286. const size = slideWrapper[sizeType]
  287. const children = slideContent.children
  288. const length = children.length
  289. for (let i = 0; i < length; i++) {
  290. const slidePageDOM = children[i] as HTMLElement
  291. slidePageDOM.style[styleType] = size + 'px'
  292. }
  293. slideContent.style[styleType] = size * length + 'px'
  294. }
  295. })
  296. }
  297. next(time?: number, easing?: EaseItem) {
  298. const { pageX, pageY } = this.pages.nextPageIndex()
  299. this.goTo(pageX, pageY, time, easing)
  300. }
  301. prev(time?: number, easing?: EaseItem) {
  302. const { pageX, pageY } = this.pages.prevPageIndex()
  303. this.goTo(pageX, pageY, time, easing)
  304. }
  305. goToPage(pageX: number, pageY: number, time?: number, easing?: EaseItem) {
  306. const pageIndex = this.pages.getValidPageIndex(pageX, pageY)
  307. this.goTo(pageIndex.pageX, pageIndex.pageY, time, easing)
  308. }
  309. getCurrentPage(): Page {
  310. return this.exposedPage || this.pages.getInitialPage(false, true)
  311. }
  312. setCurrentPage(page: Page) {
  313. this.pages.setCurrentPage(page)
  314. this.exposedPage = this.pages.getExposedPage(page)
  315. }
  316. nearestPage(x: number, y: number): Page {
  317. const { scrollBehaviorX, scrollBehaviorY } = this.scroll.scroller
  318. const {
  319. maxScrollPos: maxScrollPosX,
  320. minScrollPos: minScrollPosX,
  321. } = scrollBehaviorX
  322. const {
  323. maxScrollPos: maxScrollPosY,
  324. minScrollPos: minScrollPosY,
  325. } = scrollBehaviorY
  326. return this.pages.getNearestPage(
  327. between(x, maxScrollPosX, minScrollPosX),
  328. between(y, maxScrollPosY, minScrollPosY)
  329. )
  330. }
  331. private satisfyThreshold(x: number, y: number): boolean {
  332. const { scrollBehaviorX, scrollBehaviorY } = this.scroll.scroller
  333. let satisfied = true
  334. if (
  335. Math.abs(x - scrollBehaviorX.absStartPos) <= this.thresholdX &&
  336. Math.abs(y - scrollBehaviorY.absStartPos) <= this.thresholdY
  337. ) {
  338. satisfied = false
  339. }
  340. return satisfied
  341. }
  342. private refreshHandler(content: HTMLElement) {
  343. if (!this.satisfyInitialization()) {
  344. return
  345. }
  346. this.pages.refresh()
  347. this.computeThreshold()
  348. const contentChanged = (this.contentChanged = this.prevContent !== content)
  349. if (contentChanged) {
  350. this.prevContent = content
  351. }
  352. const initPage = this.pages.getInitialPage(
  353. this.oneToMorePagesInLoop || this.moreToOnePageInLoop,
  354. contentChanged || !this.initialised
  355. )
  356. if (this.initialised) {
  357. this.goTo(initPage.pageX, initPage.pageY, 0)
  358. } else {
  359. this.registerHooks(
  360. this.scroll.hooks,
  361. this.scroll.hooks.eventTypes.beforeInitialScrollTo,
  362. (position: { x: number; y: number }) => {
  363. this.initialised = true
  364. position.x = initPage.x
  365. position.y = initPage.y
  366. }
  367. )
  368. }
  369. this.startPlay()
  370. }
  371. private computeThreshold() {
  372. const threshold = this.options.threshold
  373. // Integer
  374. if (threshold % 1 === 0) {
  375. this.thresholdX = threshold
  376. this.thresholdY = threshold
  377. } else {
  378. // decimal
  379. const { width, height } = this.pages.getPageStats()
  380. this.thresholdX = Math.round(width * threshold)
  381. this.thresholdY = Math.round(height * threshold)
  382. }
  383. }
  384. private cloneFirstAndLastSlidePage(slideContent: HTMLElement) {
  385. const children = slideContent.children
  386. const preprendDOM = children[children.length - 1].cloneNode(
  387. true
  388. ) as HTMLElement
  389. const appendDOM = children[0].cloneNode(true) as HTMLElement
  390. prepend(preprendDOM, slideContent)
  391. slideContent.appendChild(appendDOM)
  392. this.cachedClonedPageDOM = [preprendDOM, appendDOM]
  393. }
  394. private removeClonedSlidePage(slideContent: HTMLElement) {
  395. // maybe slideContent has removed from DOM Tree
  396. const slidePages = (slideContent && slideContent.children) || []
  397. if (slidePages.length) {
  398. this.cachedClonedPageDOM.forEach((el) => {
  399. removeChild(slideContent, el)
  400. })
  401. }
  402. this.cachedClonedPageDOM = []
  403. }
  404. private modifyCurrentPage(point: Position) {
  405. const {
  406. pageX: prevExposedPageX,
  407. pageY: prevExposedPageY,
  408. } = this.getCurrentPage()
  409. const newPage = this.nearestPage(point.x, point.y)
  410. this.setCurrentPage(newPage)
  411. /* istanbul ignore if */
  412. if (this.contentChanged) {
  413. this.contentChanged = false
  414. return true
  415. }
  416. const {
  417. pageX: currentExposedPageX,
  418. pageY: currentExposedPageY,
  419. } = this.getCurrentPage()
  420. this.pageWillChangeTo(newPage)
  421. // loop is true, and one page becomes many pages when call bs.refresh
  422. if (this.oneToMorePagesInLoop) {
  423. this.oneToMorePagesInLoop = false
  424. return true
  425. }
  426. // loop is true, and many page becomes one page when call bs.refresh
  427. // if prevPage > 0, dispatch slidePageChanged and scrollEnd events
  428. /* istanbul ignore if */
  429. if (
  430. this.moreToOnePageInLoop &&
  431. prevExposedPageX === 0 &&
  432. prevExposedPageY === 0
  433. ) {
  434. this.moreToOnePageInLoop = false
  435. return true
  436. }
  437. if (
  438. prevExposedPageX !== currentExposedPageX ||
  439. prevExposedPageY !== currentExposedPageY
  440. ) {
  441. // only trust pageX & pageY when loop is true
  442. const page = this.pages.getExposedPageByPageIndex(
  443. currentExposedPageX,
  444. currentExposedPageY
  445. )
  446. this.scroll.trigger(this.scroll.eventTypes.slidePageChanged, page)
  447. }
  448. // triggered by resetLoop
  449. if (this.resetLooping) {
  450. this.resetLooping = false
  451. return
  452. }
  453. const changePage = this.pages.resetLoopPage()
  454. if (changePage) {
  455. this.resetLooping = true
  456. this.goTo(changePage.pageX, changePage.pageY, 0)
  457. // stop user's scrollEnd
  458. // since it is a seamless scroll
  459. return true
  460. }
  461. }
  462. private goTo(pageX: number, pageY: number, time?: number, easing?: EaseItem) {
  463. const newPage = this.pages.getInternalPage(pageX, pageY)
  464. const scrollEasing = easing || this.options.easing || ease.bounce
  465. const { x, y } = newPage
  466. const deltaX = x - this.scroll.scroller.scrollBehaviorX.currentPos
  467. const deltaY = y - this.scroll.scroller.scrollBehaviorY.currentPos
  468. /* istanbul ignore if */
  469. if (!deltaX && !deltaY) {
  470. this.scroll.scroller.togglePointerEvents(true)
  471. return
  472. }
  473. time = time === undefined ? this.getEaseTime(deltaX, deltaY) : time
  474. this.scroll.scroller.scrollTo(x, y, time, scrollEasing)
  475. }
  476. private flickHandler() {
  477. const { scrollBehaviorX, scrollBehaviorY } = this.scroll.scroller
  478. const {
  479. currentPos: currentPosX,
  480. startPos: startPosX,
  481. direction: directionX,
  482. } = scrollBehaviorX
  483. const {
  484. currentPos: currentPosY,
  485. startPos: startPosY,
  486. direction: directionY,
  487. } = scrollBehaviorY
  488. const { pageX, pageY } = this.pages.currentPage
  489. let time = this.getEaseTime(
  490. currentPosX - startPosX,
  491. currentPosY - startPosY
  492. )
  493. this.goTo(pageX + directionX, pageY + directionY, time)
  494. }
  495. private getEaseTime(deltaX: number, deltaY: number): number {
  496. return (
  497. this.options.speed ||
  498. Math.max(
  499. Math.max(
  500. Math.min(Math.abs(deltaX), 1000),
  501. Math.min(Math.abs(deltaY), 1000)
  502. ),
  503. 300
  504. )
  505. )
  506. }
  507. private modifyScrollMetaHandler(scrollMeta: {
  508. newX: number
  509. newY: number
  510. time: number
  511. [key: string]: any
  512. }) {
  513. const { scrollBehaviorX, scrollBehaviorY, animater } = this.scroll.scroller
  514. const newX = scrollMeta.newX
  515. const newY = scrollMeta.newY
  516. const newPage =
  517. this.satisfyThreshold(newX, newY) || animater.forceStopped
  518. ? this.pages.getPageByDirection(
  519. this.nearestPage(newX, newY),
  520. scrollBehaviorX.direction,
  521. scrollBehaviorY.direction
  522. )
  523. : this.pages.currentPage
  524. scrollMeta.time = this.getEaseTime(
  525. scrollMeta.newX - newPage.x,
  526. scrollMeta.newY - newPage.y
  527. )
  528. scrollMeta.newX = newPage.x
  529. scrollMeta.newY = newPage.y
  530. scrollMeta.easing = this.options.easing || ease.bounce
  531. }
  532. private scrollHandler({ x, y }: Position) {
  533. if (this.satisfyThreshold(x, y)) {
  534. const newPage = this.nearestPage(x, y)
  535. this.pageWillChangeTo(newPage)
  536. }
  537. }
  538. private pageWillChangeTo(newPage: Page) {
  539. const changeToPage = this.pages.getWillChangedPage(newPage)
  540. if (!samePage(this.willChangeToPage, changeToPage)) {
  541. this.willChangeToPage = changeToPage
  542. this.scroll.trigger(
  543. this.scroll.eventTypes.slideWillChange,
  544. this.willChangeToPage
  545. )
  546. }
  547. }
  548. private registerHooks(hooks: EventEmitter, name: string, handler: Function) {
  549. hooks.on(name, handler, this)
  550. this.hooksFn.push([hooks, name, handler])
  551. }
  552. destroy() {
  553. const slideContent = this.scroll.scroller.content
  554. const { loop, autoplay } = this.options
  555. if (loop) {
  556. this.removeClonedSlidePage(slideContent)
  557. }
  558. if (autoplay) {
  559. clearTimeout(this.autoplayTimer)
  560. }
  561. this.hooksFn.forEach((item) => {
  562. const hooks = item[0]
  563. const hooksName = item[1]
  564. const handlerFn = item[2]
  565. if (hooks.eventTypes[hooksName]) {
  566. hooks.off(hooksName, handlerFn)
  567. }
  568. })
  569. this.hooksFn.length = 0
  570. }
  571. }