-
-
Save baptistebriel/f046b0b14dd498b3f931b41bd456eed4 to your computer and use it in GitHub Desktop.
smooth-scrolling
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<div class="Scroll"> | |
<div class="Scroll-bounding"> | |
<div class="Scroll-section"></div> | |
<div class="Scroll-section"></div> | |
<div class="Scroll-section"></div> | |
<div class="Scroll-section"></div> | |
</div> | |
</div> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { bindAll, assign, defer, defaults } from 'underscore' | |
import size from 'size' | |
import App from 'lib/app' | |
import Mn from 'backbone.marionette' | |
import prefix from 'vendor-prefix' | |
import VirtualScroll from 'virtual-scroll' | |
import template from './template.html' | |
import { props } from 'lib/decorators' | |
const transform = prefix('transform') | |
const visibility = 'visibility' | |
@props({ | |
template, | |
className: 'inViewport' | |
}) | |
export default class inViewport extends Mn.View { | |
initialize() { | |
bindAll(this, 'scroll', 'event', 'rAF', 'lock', 'unlock') | |
defaults(this.options, { event: 'animatedIn', direction: `y`, bounding: true }) | |
super.initialize() | |
this.resizing = false | |
this.vs = null | |
this.sections = null | |
this.cache = null | |
this.data = { | |
ease: App.useNative ? 1 : App.isDesktop ? .1 : .25, | |
scrolling: false, | |
scrollable: true, | |
locked: false, | |
current: 0, | |
target: 0, | |
fixed: 0, | |
max: 0 | |
} | |
} | |
onRender() { | |
this.once(this.options.event, () => { | |
if (!App.useNative) { | |
this.ui.scroll.addClass(`Scroll-virtual Scroll--${this.options.direction}`) | |
this.vs = new VirtualScroll({ passive: true, touchMultiplier: 3 }) | |
this.vs.on(this.event, this) | |
} else { | |
this.ui.scroll.addClass(`Scroll-native Scroll--${this.options.direction}`) | |
this.ui.scroll.on('scroll', this.scroll) | |
} | |
App.raf.subscribe(`Scroll:${this.cid}`, this.rAF) | |
}) | |
} | |
event(e) { | |
if (this.data.locked) return | |
this.data.target += Math.round(e.deltaY * -0.5) | |
this.clamp() | |
} | |
scroll(e) { | |
if (this.data.locked) return | |
const el = this.ui.scroll[0] | |
const scrollY = this.options.direction === `y` ? el.pageYOffset || el.scrollTop : el.pageXOffset || el.scrollLeft | |
this.data.target = scrollY | |
this.clamp() | |
} | |
lock() { | |
this.data.locked = true | |
this.ui.scroll[0].style.overflow = `hidden` | |
} | |
unlock() { | |
this.data.locked = false | |
this.ui.scroll[0].style.overflow = `` | |
} | |
clamp() { | |
this.data.target = Math.round(Math.min(Math.max(this.data.target, 0), this.data.max)) | |
} | |
rAF() { | |
if (this.resizing) return | |
this.data.scrolling = this.data.target.toFixed() !== this.data.current.toFixed() | |
this.data.current += (this.data.target - this.data.current) * this.data.ease | |
this.data.fixed = this.data.scrolling ? 3 : 0 | |
if (App.useNative) return | |
const el = this.ui.section[0] | |
if (!el.style) return | |
this.rAFSections() | |
this.rAFElements() | |
} | |
getSpeed(section, speed) { | |
const { start, middle } = section | |
const translate = this.data.current.toFixed(this.data.fixed) | |
const centered = translate - (start - middle) | |
return centered * speed | |
} | |
rAFSections() { | |
if (!this.sections) return | |
const { scrolling, fixed } = this.data | |
const translate = this.data.current.toFixed(fixed) | |
const translate3d = this.options.direction === 'y' ? `translate3d(0,-${translate}px,0)` : `translate3d(-${translate}px,0,0)` | |
this.sections.forEach((data, index) => { | |
const { inView } = this.isInView(data) | |
this.ui.section[index].style[transform] = inView ? translate3d : `none` | |
this.ui.section[index].style[visibility] = inView ? `visible` : `hidden` | |
if (!inView || data.fxs.length === 0) return | |
const { start, middle } = data | |
data.fxs.forEach((fx, i) => { | |
const { el, type, speed } = fx | |
const offset = this.getSpeed(data, speed) | |
let value = `none` | |
if (type === 'translateX') value = `translate3d(${(offset).toFixed(fixed)}px, 0, 0)` | |
if (type === 'translateY') value = `translate3d(0, ${(offset).toFixed(fixed)}px, 0)` | |
el.style[transform] = value | |
}) | |
}) | |
} | |
rAFElements() { | |
if (!this.cache || App.perf <= App.PERF_GOOD) return | |
const vertical = this.options.direction === 'y' | |
const threshold = this.options.threshold || 150 | |
const boundary = (vertical ? size.height : size.width) | |
this.cache.forEach((data, index) => { | |
const el = this.ui.els[index] | |
const { inView, start, end, direction } = this.isInView(data) | |
if (!data.state && (start > -threshold) && (end < (boundary + threshold))) return | |
inView ? this.inView(el, data, direction) : this.outView(el, data, direction) | |
}) | |
} | |
isInView(bounds) { | |
const { top, left } = bounds | |
const scrollY = this.data.current | |
const vertical = this.options.direction === 'y' | |
const threshold = this.options.threshold || (App.isDesktop ? 200 : 20) | |
const boundary = (vertical ? size.height : size.width) | |
const bottom = top + bounds.height | |
const right = left + bounds.width | |
const start = (vertical ? bounds.top : bounds.left) - scrollY | |
const end = (vertical ? bounds.bottom : bounds.right) - scrollY | |
const inView = start < (boundary + threshold) && end > -threshold | |
const direction = start < 0 ? 'top' : 'bottom' | |
return { | |
top, right, bottom, left, | |
start, end, | |
inView, | |
direction | |
} | |
} | |
inView(el, data, direction) { | |
if (data.state) return | |
data.state = true | |
const unit = 100 | |
TweenMax.to(el, .2, { | |
opacity: 1, | |
ease: Linear.easeNone | |
}) | |
if (App.perf >= App.PERF_GOOD) { | |
TweenMax.fromTo(el, 1, { | |
y: unit * (direction === 'top' ? -1 : 1) | |
}, { | |
y: 0, | |
ease: Expo.easeOut, | |
clearProps: 'transform' | |
}) | |
} | |
} | |
outView(el, data, direction) { | |
if (!data.state) return | |
data.state = false | |
} | |
onResize(width, height) { | |
this.resizing = true | |
this.getSections() | |
this.getElements() | |
defer(() => { | |
!App.useNative && this.rAFSections() | |
this.getBounding(height, width) | |
this.resizing = false | |
}) | |
} | |
getBounding(height, width) { | |
if (!this.options.bounding) return | |
if (!App.useNative) { | |
if (typeof this.ui.bounding[0] === 'string') return | |
const vertical = this.options.direction === 'y' | |
const scrollY = Math.round(this.data.target) | |
const bounding = this.ui.bounding[0].getBoundingClientRect() | |
const value = vertical ? bounding.height : bounding.width | |
this.data.max = value - (vertical ? height : width) | |
} else { | |
const el = this.ui.scroll[0] | |
this.data.max = Math.max(el.scrollHeight, el.offsetHeight, el.clientHeight) | |
} | |
} | |
getSections() { | |
this.sections = [] | |
this.ui.section.each((index, el) => { | |
el.style[transform] = '' | |
defer(() => { | |
const bounds = el.getBoundingClientRect() | |
const { inView, top, left, right, bottom } = this.isInView(bounds) | |
const start = this.options.direction === `y` ? top + ((bottom - top) / 2) : left + ((right - left) / 2) | |
const middle = (this.options.direction === `y` ? size.height : size.width) / 2 | |
const fxs = this.getEffects(el) | |
this.sections.push({ | |
state: inView, | |
top: top, | |
left: left, | |
right: right, | |
bottom: bottom, | |
start: start, | |
middle: middle, | |
fxs: fxs | |
}) | |
}) | |
}) | |
} | |
getEffects(el) { | |
const fxs = [] | |
if (App.perf >= App.PERF_HIGH) { | |
const effects = Array.from(el.querySelectorAll('.Scroll-effect')) | |
effects.forEach((el, i) => { | |
const type = el.getAttribute('data-type') | |
const speed = el.getAttribute('data-speed') || 0 | |
fxs.push({ el, type, speed }) | |
}) | |
} | |
return fxs | |
} | |
getElements() { | |
if (this.ui.els) { | |
this.cache = [] | |
this.ui.els.each((index, el) => { | |
const bounds = el.getBoundingClientRect() | |
const { inView, top, left, right, bottom } = this.isInView(bounds) | |
this.cache.push({ | |
state: inView, | |
top: top, | |
left: left, | |
right: right, | |
bottom: bottom | |
}) | |
}) | |
} | |
} | |
onBeforeDestroy() { | |
App.raf.unsubscribe(`Scroll:${this.cid}`) | |
if (!App.useNative) { | |
this.vs.off(this.event, this) | |
this.vs.destroy() | |
this.vs = null | |
} else { | |
this.ui.scroll.off('scroll', this.scroll) | |
} | |
this.sections = null | |
this.cache = null | |
this.data = null | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
.Scroll { | |
position: absolute; | |
top: 0; left: 0; | |
width: 100%; | |
height: 100%; | |
&.Scroll-native { | |
overflow-y: scroll; | |
overflow-x: hidden; | |
-webkit-overflow-scrolling: touch; | |
} | |
} | |
.Scroll-bounding { | |
position: absolute; | |
top: 0; left: 0; | |
width: 100%; | |
} | |
.Scroll-section { | |
position: relative; | |
width: 100%; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
By the way, if
App.useNative
is equals to false, it will not transform any of the sections but rather will be a normal scroll—just like nothing would be initialized. I usually setApp.useNative
to false if it's a mobile device or if the performances of the client's device are poor. You could nevertheless apply some parallax transforms in the functionrAF
if it's a native scroll, it's up to you!