Home Reference Source Repository

src/BackgroundParallax.js

import { noop, getElements } from './modules/utility'

/**
 * Automatically calculate the moving distance from the height of the parent element
 */
export default class BackgroundParallax {
  /**
   * @param {string|NodeList|Element|Element[]} target - Target elements (selector or element object)
   * @param {Object} [options={}]
   * @param {onResize} [options.onResize=noop] - Resize event handler
   * @param {onScroll} [options.onScroll=noop] - Scroll event handler
   * @param {boolean} [options.isRound=false] - Whether transform style value is rounded or not
   * @param {boolean} [options.autoRun=true] - Whether to run automatically
   */
  constructor (target, options = {}) {
    this._els = getElements(target)
    if (this._els.length === 0) {
      this._disabled = true
      return
    }

    const {
      onResize = noop,
      onScroll = noop,
      isRound = false,
      autoRun = true
    } = options

    this._optionOnResize = onResize
    this._optionOnScroll = onScroll
    this._fTansform = `_getTransform${isRound ? 'Round' : ''}`
    this._scrollTarget = document.scrollingElement || document.documentElement

    this._onResize = () => {
      this.update()
      this._optionOnResize(this._windowHeight)
    }
    window.addEventListener('resize', this._onResize)

    this._onLoad = () => {
      this.update()
    }
    window.addEventListener('load', this._onLoad)

    autoRun && this.run()
  }

  /**
   * Cache various values
   */
  _cache () {
    this._windowHeight = window.innerHeight
    const scrollY = window.scrollY || window.pageYOffset

    this._items = this._els
      .map(el => {
        el.style.transform = 'none'
        const willChange = window.getComputedStyle(el).willChange
        el.style.willChange = 'transform'
        if (!(willChange === 'auto' || willChange === 'transform')) el.style.willChange += ', ' + willChange

        return this._cacheElementPos(el, scrollY)
      })
      .filter(item => item)
  }

  /**
   * Each frame of animation
   */
  _tick () {
    const scrollTop = this._scrollTarget.scrollTop
    if (scrollTop !== this._scrollTop) {
      // When the scroll position changes
      this._scrollTop = scrollTop
      this._update()
    }

    this._animationFrameId = requestAnimationFrame(() => { this._tick() })
  }

  /**
   * Update the position of each element
   */
  _update () {
    this._items.forEach(item => this._updateElement(item))

    this._optionOnScroll(this._scrollTop)
  }

  /**
   * Return the value of transform
   */
  _getTransform (position) {
    return `translate3d(0, ${position}px, 0)`
  }

  /**
   * Return the value of transform
   * In order to avoid problems such as bleeding, convert the number to an integers
   */
  _getTransformRound (position) {
    return `translate3d(0, ${Math.round(position)}px, 0)`
  }

  /**
   * Cache various values of one element
   */
  _cacheElementPos (el, scrollY) {
    const bounding = el.getBoundingClientRect()
    const boundingParent = el.parentNode.getBoundingClientRect()
    const top = boundingParent.top + scrollY
    const inPos = top - this._windowHeight
    const outPos = boundingParent.bottom + scrollY

    return {
      el,
      max: boundingParent.height - bounding.height,
      inPos,
      outPos,
      distance: outPos - inPos,
      offset: boundingParent.top - bounding.top
    }
  }

  /**
   * Update the position of one element
   */
  _updateElement (item) {
    if (this._scrollTop > item.outPos) {
      // After the element disappears in the upper direction
    } else {
      const diff = this._scrollTop - item.inPos
      if (diff > 0) {
        // After the element can be seen from below
        const rate = diff / item.distance
        const position = item.offset + item.max - item.max * rate // max - (max - min) * rate

        item.el.style.transform = this[this._fTansform](position)
      }
    }
  }

  /**
   * Run animation
   */
  run () {
    if (this._disabled) return

    this._cache()
    this._animationFrameId = requestAnimationFrame(() => { this._tick() })
  }

  /**
   * Update cache and position
   */
  update () {
    if (this._disabled) return

    this._cache()
    this._update()
  }

  /**
   * Destroy instance
   */
  destroy () {
    if (!this._els) return

    cancelAnimationFrame(this._animationFrameId)

    this._els.forEach(el => {
      el.style.transform = null
      el.style.willChange = null
    })

    window.removeEventListener('resize', this._onResize)
    window.removeEventListener('load', this._onLoad)
  }
}

/**
 * @typedef {function} onResize
 * @param {number} windowHeight - `window.innerHeight`
 */

/**
 * @typedef {function} onScroll
 * @param {number} scrollTop - `document.scrollingElement.scrollTop`
 */