UNPKG

basicscroll

Version:

Standalone parallax scrolling for mobile and desktop with CSS variables

443 lines (306 loc) 11.1 kB
import parseUnit from 'parse-unit' import clonedeep from 'lodash.clonedeep' import eases from 'eases' const instances = [] /** * Debounces a function that will be triggered many times. * @param {Function} fn * @param {Integer} duration * @returns {Function} */ const debounce = function(fn, duration) { let timeout = null return (...args) => { clearTimeout(timeout) timeout = setTimeout(() => fn(...args), duration) } } /** * Returns all active instances from an array. * @param {Array} instances * @returns {Array} instances - Active instances. */ const getActiveInstances = function(instances) { return instances.filter((instance) => instance!=null && instance.isActive()) } /** * Returns all tracked instances from an array. * @param {Array} instances * @returns {Array} instances - Tracked instances. */ const getTrackedInstances = function(instances) { return instances.filter((instance) => instance!=null && instance.getData().track) } /** * Returns the number of scrolled pixels. * @returns {Integer} scrollTop */ const getScrollTop = function() { // Use scrollTop because is's faster than getBoundingClientRect() return (document.scrollingElement || document.documentElement).scrollTop } /** * Returns the height of the viewport. * @returns {Integer} viewportHeight */ const getViewportHeight = function() { return (window.innerHeight || window.outerHeight) } /** * Checks if a value is absolute. * An absolute value must have a value that's not NaN. * @param {String|Integer} value * @returns {Boolean} isAbsolute */ const isAbsoluteValue = function(value) { return isNaN(parseUnit(value)[0])===false } /** * Parses an absolute value. * @param {String|Integer} value * @returns {Object} value - Parsed value. */ const parseAbsoluteValue = function(value) { const parsedValue = parseUnit(value) return { value: parsedValue[0], unit: parsedValue[1] } } /** * Checks if a value is relative. * A relative value must start and end with [a-z] and needs a '-' in the middle. * @param {String|Integer} value * @returns {Boolean} isRelative */ const isRelativeValue = function(value) { return String(value).match(/^[a-z]+-[a-z]+$/)!==null } /** * Converts a relative value to an absolute value. * @param {String} value * @param {Node} elem - Anchor of the relative value. * @param {?Integer} scrollTop - Pixels scrolled in document. * @param {?Integer} viewportHeight - Height of the viewport. * @returns {String} value - Absolute value. */ const relativeToAbsoluteValue = function(value, elem, scrollTop = getScrollTop(), viewportHeight = getViewportHeight()) { const elemSize = elem.getBoundingClientRect() const elemAnchor = value.match(/^[a-z]+/)[0] const viewportAnchor = value.match(/[a-z]+$/)[0] let y = 0 if (viewportAnchor==='top') y -= 0 if (viewportAnchor==='middle') y -= viewportHeight / 2 if (viewportAnchor==='bottom') y -= viewportHeight if (elemAnchor==='top') y += (elemSize.top + scrollTop) if (elemAnchor==='middle') y += (elemSize.top + scrollTop) + elemSize.height / 2 if (elemAnchor==='bottom') y += (elemSize.top + scrollTop) + elemSize.height return `${ y }px` } /** * Validates data and sets defaults for undefined properties. * @param {?Object} data * @returns {Object} data - Validated data. */ const validate = function(data = {}) { // Deep copy object to avoid changes by reference data = clonedeep(data) if (data.from==null) throw new Error('Missing property `from`') if (data.to==null) throw new Error('Missing property `to`') if (data.inside==null) data.inside = () => {} if (data.outside==null) data.outside = () => {} if (data.direct===true && data.elem==null) throw new Error('Property `elem` required when `direct` is true') if (data.direct!==true) data.direct = false if (data.track!==false) data.track = true if (typeof data.inside!=='function') throw new Error('Property `inside` must be a function') if (typeof data.outside!=='function') throw new Error('Property `outside` must be a function') if (data.elem==null) { if (isAbsoluteValue(data.from)===false) throw new Error('Property `from` must be a absolute value when no `elem` has been provided') if (isAbsoluteValue(data.to)===false) throw new Error('Property `to` must be a absolute value when no `elem` has been provided') } else { if (isRelativeValue(data.from)===true) data.from = relativeToAbsoluteValue(data.from, data.elem) if (isRelativeValue(data.to)===true) data.to = relativeToAbsoluteValue(data.to, data.elem) } data.from = parseAbsoluteValue(data.from) data.to = parseAbsoluteValue(data.to) if (data.props==null) data.props = {} Object.keys(data.props).forEach((key) => { const prop = data.props[key] if (isAbsoluteValue(prop.from)===false) throw new Error('Property `from` of prop must be a absolute value') if (isAbsoluteValue(prop.to)===false) throw new Error('Property `from` of prop must be a absolute value') prop.from = parseAbsoluteValue(prop.from) prop.to = parseAbsoluteValue(prop.to) if (typeof prop.timing==='string' && eases[prop.timing]==null) throw new Error('Unknown timing for property `timing` of prop') if (prop.timing==null) prop.timing = eases['linear'] if (typeof prop.timing==='string') prop.timing = eases[prop.timing] }) return data } /** * Calculates the props of an instance. * @param {Object} instance * @param {?Integer} scrollTop - Pixels scrolled in document. * @returns {Object} Calculated props and the element to apply styles to. */ const getProps = function(instance, scrollTop = getScrollTop()) { const data = instance.getData() // 100% in pixel const total = data.to.value - data.from.value // Pixel scrolled const current = scrollTop - data.from.value // Percent scrolled const precisePercentage = current / (total / 100) const normalizedPercentage = Math.min(Math.max(precisePercentage, 0), 100) // Apply styles directly to element when direct is true. // Apply them globally when direct is false. const elem = data.direct===true ? data.elem : document.documentElement // Generate an array with all new props const props = Object.keys(data.props).reduce((acc, key) => { const prop = data.props[key] // Use the unit of from OR to. It's valid to animate from '0' to '100px' and // '0' should be treated as 'px', too. Unit will be an empty string when no unit given. const unit = prop.from.unit || prop.to.unit // The value that should be interpolated const diff = prop.from.value - prop.to.value // All easing functions only remap a time value, and all have the same signature. // Typically a value between 0 and 1, and it returns a new float that has been eased. const time = prop.timing(normalizedPercentage / 100) const value = prop.from.value - diff * time // Round to avoid unprecise values. // The precision of floating point computations is only as precise as the precision it uses. // http://stackoverflow.com/questions/588004/is-floating-point-math-broken const rounded = Math.round(value * 100) / 100 acc[key] = rounded + unit return acc }, {}) // Use precise percentage to check if the viewport is between from and to. // Would always return true when using the normalized percentage. const isInside = (precisePercentage>=0 && precisePercentage<=100) const isOutside = (precisePercentage<0 || precisePercentage>100) // Execute callbacks if (isInside===true) data.inside(instance, precisePercentage, props) if (isOutside===true) data.outside(instance, precisePercentage, props) return { elem, props } } /** * Adds a property with the specified name and value to a given style object. * @param {Node} elem - Styles will be applied to this element. * @param {Object} prop - Object with a key and value. */ const setProp = function(elem, prop) { elem.style.setProperty(prop.key, prop.value) } /** * Adds properties to a given style object. * @param {Node} elem - Styles will be applied to this element. * @param {Object} props - Object of props. */ const setProps = function(elem, props) { Object.keys(props).forEach((key) => setProp(elem, { key: key, value: props[key] })) } /** * Gets and sets new props when the user has scrolled and when there are active instances. * This part get executed with every frame. Make sure it's performant as hell. * @param {Object} style - Style object. * @param {?Integer} previousScrollTop * @returns {?*} */ const loop = function(style, previousScrollTop) { // Continue loop const repeat = () => { // It depends on the browser, but it turns out that closures // are sometimes faster than .bind or .apply. requestAnimationFrame(() => loop(style, previousScrollTop)) } // Get all active instances const activeInstances = getActiveInstances(instances) // Only continue when active instances available if (activeInstances.length===0) return repeat() const scrollTop = getScrollTop() // Only continue when scrollTop has changed if (previousScrollTop===scrollTop) return repeat() else previousScrollTop = scrollTop // Get and set new props of each instance activeInstances .map((instance) => getProps(instance, scrollTop)) .forEach(({ elem, props }) => setProps(elem, props)) repeat() } /** * Creats a new instance. * @param {Object} data * @returns {Object} instance */ export const create = function(data) { // Store the parsed data let _data = null // Store if instance is started or stopped let active = false // Returns if instance is started or stopped const _isActive = () => { return active } // Returns the parsed and calculated data const _getData = function() { return _data } // Parses and calculates data const _calculate = function() { _data = validate(data) } // Update props const _update = () => { // Get new props const { elem, props } = getProps(instance) // Set new props setProps(elem, props) return props } // Starts to animate const _start = () => { active = true } // Stops to animate const _stop = () => { active = false } // Destroys the instance const _destroy = () => { // Replace instance instead of deleting the item to avoid // that the index of other instances changes. instances[index] = undefined } // Assign instance to a variable so the instance can be used // elsewhere in the current function. const instance = { isActive: _isActive, getData: _getData, calculate: _calculate, update: _update, start: _start, stop: _stop, destroy: _destroy } // Store instance in global array and save the index const index = instances.push(instance) - 1 // Calculate data for the first time instance.calculate() return instance } // Start to loop loop() // Recalculate and update instances when the window size changes window.addEventListener('resize', debounce(() => { // Get all tracked instances const trackedInstances = getTrackedInstances(instances) trackedInstances.forEach((instance) => { instance.calculate() instance.update() }) }, 50))