diff --git a/src/interaction-types/frontend/elementScrolling.js b/src/interaction-types/frontend/elementScrolling.js index f2a21a2..bcaa4f0 100644 --- a/src/interaction-types/frontend/elementScrolling.js +++ b/src/interaction-types/frontend/elementScrolling.js @@ -9,7 +9,7 @@ InteractRunner.addInteractionConfig( { const animation = interaction.createTimelineInstance( 0, {} ) const trigger = interaction.getCurrentTrigger() - const scrollHandler = isFirstCall => { + const update = isFirstCall => { const rect = trigger.getBoundingClientRect() const viewportHeight = window.innerHeight @@ -20,19 +20,38 @@ InteractRunner.addInteractionConfig( { // Negative offset means start counting later and finish sooner, contracting the bounds. const elementScroll = viewportHeight - rect.top + offset const totalScroll = viewportHeight + rect.height + ( offset * 2 ) - const scrolled = elementScroll / totalScroll + const scrolled = totalScroll ? elementScroll / totalScroll : 0 // Clamp between 0 and 1 const clampedScrolled = Math.max( 0, Math.min( 1, scrolled ) ) animation.seekPercentage( clampedScrolled, isFirstCall === true ? 0 : smoothness ) } - window.addEventListener( 'scroll', scrollHandler ) - scrollHandler( true ) + // Coalesce scroll events into a single rAF so we only seek once per + // frame. Firing anime.js on every scroll event janks slower Android + // devices. + let rafId = null + const scrollHandler = () => { + if ( rafId !== null ) { + return + } + rafId = window.requestAnimationFrame( () => { + rafId = null + update( false ) + } ) + } + + // Passive so the listener never blocks scrolling on mobile. + window.addEventListener( 'scroll', scrollHandler, { passive: true } ) + update( true ) return () => { + if ( rafId !== null ) { + window.cancelAnimationFrame( rafId ) + rafId = null + } animation.destroy() - window.removeEventListener( 'scroll', scrollHandler ) + window.removeEventListener( 'scroll', scrollHandler, { passive: true } ) } }, }, diff --git a/src/interaction-types/frontend/enterViewport.js b/src/interaction-types/frontend/enterViewport.js index 7113e0c..31c8125 100644 --- a/src/interaction-types/frontend/enterViewport.js +++ b/src/interaction-types/frontend/enterViewport.js @@ -10,10 +10,18 @@ InteractRunner.addInteractionConfig( { // Normalize the threshold to be between 0 and the maximum possible threshold // for the current trigger. This ensures the interaction always works. const rect = trigger.getBoundingClientRect() - const visibleHeight = Math.min( window.innerHeight, rect.height ) - const maxThreshold = ( visibleHeight / rect.height ) - 0.01 const threshold = parseFloat( interaction.getOption( 'threshold', 0.3 ) ) - const normalizedThreshold = Math.min( Math.max( threshold, 0 ), maxThreshold ) + // Guard against a zero (or not-yet-laid-out) height. On slower + // devices the element may not have its final height when we + // initialize, which would make maxThreshold NaN and throw when + // constructing the IntersectionObserver. + let normalizedThreshold = Math.max( threshold, 0 ) + if ( rect.height > 0 ) { + const visibleHeight = Math.min( window.innerHeight, rect.height ) + const maxThreshold = ( visibleHeight / rect.height ) - 0.01 + normalizedThreshold = Math.min( normalizedThreshold, maxThreshold ) + } + normalizedThreshold = Math.max( normalizedThreshold, 0 ) // Use Intersection Observer to detect when the target enters the viewport const callback = entries => { @@ -30,7 +38,17 @@ InteractRunner.addInteractionConfig( { } ) } - const io = new IntersectionObserver( callback, { threshold: normalizedThreshold } ) // eslint-disable-line compat/compat + // Fire slightly before the element scrolls into view on Android only. + // Android Chrome batches/defers IntersectionObserver callbacks during + // momentum (fling) scrolling, so without this lead time reveals + // "pop in" late compared to iOS Safari. + const isAndroid = /Android/i.test( navigator.userAgent ) + const observerOptions = { threshold: normalizedThreshold } + if ( isAndroid ) { + observerOptions.rootMargin = '0px 0px 15% 0px' + } + + const io = new IntersectionObserver( callback, observerOptions ) // eslint-disable-line compat/compat io.observe( trigger ) return () => { diff --git a/src/interaction-types/frontend/pageScrolling.js b/src/interaction-types/frontend/pageScrolling.js index beb44a5..33cd7b3 100644 --- a/src/interaction-types/frontend/pageScrolling.js +++ b/src/interaction-types/frontend/pageScrolling.js @@ -7,19 +7,41 @@ InteractRunner.addInteractionConfig( { const smoothness = interaction.getOption( 'smoothness', 200 ) const animation = interaction.createTimelineInstance( 0, {} ) - const scrollHandler = isFirstCall => { + const update = isFirstCall => { const winScroll = document.body.scrollTop || document.documentElement.scrollTop const height = document.documentElement.scrollHeight - document.documentElement.clientHeight - const scrolled = winScroll / height - animation.seekPercentage( scrolled, isFirstCall === true ? 0 : smoothness ) + const scrolled = height ? winScroll / height : 0 + + // Clamp between 0 and 1 (iOS overscroll can produce negative values). + const clampedScrolled = Math.max( 0, Math.min( 1, scrolled ) ) + animation.seekPercentage( clampedScrolled, isFirstCall === true ? 0 : smoothness ) + } + + // Coalesce scroll events into a single rAF so we only seek once per + // frame. Firing anime.js on every scroll event janks slower Android + // devices. + let rafId = null + const scrollHandler = () => { + if ( rafId !== null ) { + return + } + rafId = window.requestAnimationFrame( () => { + rafId = null + update( false ) + } ) } - window.addEventListener( 'scroll', scrollHandler ) - scrollHandler( true ) + // Passive so the listener never blocks scrolling on mobile. + window.addEventListener( 'scroll', scrollHandler, { passive: true } ) + update( true ) return () => { + if ( rafId !== null ) { + window.cancelAnimationFrame( rafId ) + rafId = null + } animation.destroy() - window.removeEventListener( 'scroll', scrollHandler ) + window.removeEventListener( 'scroll', scrollHandler, { passive: true } ) } }, },