From 8778a16a03b160d728029fedf8182df60b3630c0 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 23 Sep 2024 15:05:00 +0200 Subject: [PATCH] Add scroll info to scroll (#2809) --- packages/framer-motion/package.json | 4 + packages/framer-motion/rollup.size.config.mjs | 19 +- packages/framer-motion/src/dom-entry.ts | 1 + .../src/easing/__tests__/steps.test.ts | 26 ++ packages/framer-motion/src/easing/steps.ts | 28 ++ packages/framer-motion/src/frameloop/frame.ts | 2 +- .../projection/node/create-projection-node.ts | 8 +- .../render/dom/scroll/__tests__/index.test.ts | 334 +++++++++++++++++- .../src/render/dom/scroll/index.ts | 69 +++- .../src/render/dom/scroll/types.ts | 11 +- .../framer-motion/src/value/use-scroll.ts | 6 +- 11 files changed, 482 insertions(+), 26 deletions(-) create mode 100644 packages/framer-motion/src/easing/__tests__/steps.test.ts create mode 100644 packages/framer-motion/src/easing/steps.ts diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index b4d6132e13..bf44d5e961 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -115,6 +115,10 @@ { "path": "./dist/size-rollup-animate.js", "maxSize": "17.9 kB" + }, + { + "path": "./dist/size-rollup-scroll.js", + "maxSize": "5.2 kB" } ], "gitHead": "dcf88c8f6c178d1af7a8f7dec81326db5fd68cea" diff --git a/packages/framer-motion/rollup.size.config.mjs b/packages/framer-motion/rollup.size.config.mjs index a794dcf18f..2ab25b6252 100644 --- a/packages/framer-motion/rollup.size.config.mjs +++ b/packages/framer-motion/rollup.size.config.mjs @@ -62,6 +62,23 @@ const sizeAnimate = Object.assign({}, es, { }, }) +const sizeScroll = Object.assign({}, es, { + input: "lib/render/dom/scroll/index.js", + output: Object.assign({}, es.output, { + file: `dist/size-rollup-scroll.js`, + preserveModules: false, + dir: undefined, + }), + plugins: [...sizePlugins], + external, + onwarn(warning, warn) { + if (warning.code === "MODULE_LEVEL_DIRECTIVE") { + return + } + warn(warning) + }, +}) + const domAnimation = Object.assign({}, es, { input: { "size-rollup-dom-animation-m": "lib/render/components/m/size.js", @@ -105,4 +122,4 @@ const domMax = Object.assign({}, es, { }) // eslint-disable-next-line import/no-default-export -export default [motion, m, domAnimation, domMax, sizeAnimate] +export default [motion, m, domAnimation, domMax, sizeAnimate, sizeScroll] diff --git a/packages/framer-motion/src/dom-entry.ts b/packages/framer-motion/src/dom-entry.ts index 8c56776eb7..9a1d1236e6 100644 --- a/packages/framer-motion/src/dom-entry.ts +++ b/packages/framer-motion/src/dom-entry.ts @@ -13,6 +13,7 @@ export * from "./easing/back" export * from "./easing/circ" export * from "./easing/ease" export * from "./easing/cubic-bezier" +export * from "./easing/steps" export * from "./easing/modifiers/mirror" export * from "./easing/modifiers/reverse" diff --git a/packages/framer-motion/src/easing/__tests__/steps.test.ts b/packages/framer-motion/src/easing/__tests__/steps.test.ts new file mode 100644 index 0000000000..a6638ecdd4 --- /dev/null +++ b/packages/framer-motion/src/easing/__tests__/steps.test.ts @@ -0,0 +1,26 @@ +import { steps } from "../steps" + +test("steps", () => { + const stepEnd = steps(4) + + expect(stepEnd(0)).toBe(0) + expect(stepEnd(0.2)).toBe(0) + expect(stepEnd(0.249)).toBe(0) + expect(stepEnd(0.25)).toBe(0.25) + expect(stepEnd(0.49)).toBe(0.25) + expect(stepEnd(0.5)).toBe(0.5) + expect(stepEnd(0.99)).toBe(0.75) + expect(stepEnd(1)).toBe(0.75) + + const stepStart = steps(4, "start") + expect(stepStart(0)).toBe(0.25) + expect(stepStart(0.2)).toBe(0.25) + expect(stepStart(0.249)).toBe(0.25) + expect(stepStart(0.25)).toBe(0.25) + expect(stepStart(0.49)).toBe(0.5) + expect(stepStart(0.5)).toBe(0.5) + expect(stepStart(0.51)).toBe(0.75) + expect(stepStart(0.99)).toBe(1) + expect(stepStart(1)).toBe(1) + expect(stepStart(2)).toBe(1) +}) diff --git a/packages/framer-motion/src/easing/steps.ts b/packages/framer-motion/src/easing/steps.ts new file mode 100644 index 0000000000..52d146d822 --- /dev/null +++ b/packages/framer-motion/src/easing/steps.ts @@ -0,0 +1,28 @@ +import { clamp } from "../utils/clamp" +import type { EasingFunction } from "./types" + +/* + Create stepped version of 0-1 progress + + @param [int]: Number of steps + @param [number]: Current value + @return [number]: Stepped value +*/ +export type Direction = "start" | "end" + +export function steps( + numSteps: number, + direction: Direction = "end" +): EasingFunction { + return (progress: number) => { + progress = + direction === "end" + ? Math.min(progress, 0.999) + : Math.max(progress, 0.001) + const expanded = progress * numSteps + const rounded = + direction === "end" ? Math.floor(expanded) : Math.ceil(expanded) + + return clamp(0, 1, rounded / numSteps) + } +} diff --git a/packages/framer-motion/src/frameloop/frame.ts b/packages/framer-motion/src/frameloop/frame.ts index ec1194b06c..55887f1ac6 100644 --- a/packages/framer-motion/src/frameloop/frame.ts +++ b/packages/framer-motion/src/frameloop/frame.ts @@ -5,7 +5,7 @@ export const { schedule: frame, cancel: cancelFrame, state: frameData, - steps, + steps: frameSteps, } = createRenderBatcher( typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame : noop, true diff --git a/packages/framer-motion/src/projection/node/create-projection-node.ts b/packages/framer-motion/src/projection/node/create-projection-node.ts index 61539fd506..df408f0b7d 100644 --- a/packages/framer-motion/src/projection/node/create-projection-node.ts +++ b/packages/framer-motion/src/projection/node/create-projection-node.ts @@ -52,7 +52,7 @@ import { frameData } from "../../dom-entry" import { isSVGElement } from "../../render/dom/utils/is-svg-element" import { animateSingleValue } from "../../animation/interfaces/single-value" import { clamp } from "../../utils/clamp" -import { steps } from "../../frameloop/frame" +import { frameSteps } from "../../frameloop/frame" import { noop } from "../../utils/noop" import { time } from "../../frameloop/sync-time" import { microtask } from "../../frameloop/microtask" @@ -730,9 +730,9 @@ export function createProjectionNode({ frameData.delta = clamp(0, 1000 / 60, now - frameData.timestamp) frameData.timestamp = now frameData.isProcessing = true - steps.update.process(frameData) - steps.preRender.process(frameData) - steps.render.process(frameData) + frameSteps.update.process(frameData) + frameSteps.preRender.process(frameData) + frameSteps.render.process(frameData) frameData.isProcessing = false } diff --git a/packages/framer-motion/src/render/dom/scroll/__tests__/index.test.ts b/packages/framer-motion/src/render/dom/scroll/__tests__/index.test.ts index 552f4e627f..d4cd81359a 100644 --- a/packages/framer-motion/src/render/dom/scroll/__tests__/index.test.ts +++ b/packages/framer-motion/src/render/dom/scroll/__tests__/index.test.ts @@ -1,5 +1,6 @@ import { frame } from "../../../../frameloop" import { scrollInfo } from "../track" +import { scroll } from "../" import { ScrollOffset } from "../offsets/presets" import { ScrollInfo } from "../types" @@ -51,7 +52,7 @@ async function fireScroll(distance: number = 0) { return nextFrame() } -describe("scroll", () => { +describe("scrollInfo", () => { test("Fires onScroll on creation.", async () => { const onScroll = jest.fn() @@ -380,3 +381,334 @@ describe("scroll", () => { }) }) }) + +describe("scroll", () => { + test("Fires onScroll on creation.", async () => { + const onScroll = jest.fn() + + const stopScroll = scroll((_progress, info) => onScroll(info)) + + return new Promise((resolve) => { + window.requestAnimationFrame(() => { + expect(onScroll).toBeCalled() + + stopScroll() + + resolve() + }) + }) + }) + + test("Fires onScroll on scroll.", async () => { + let latest: ScrollInfo + + const stopScroll = scroll((_progress, info) => { + latest = info + }) + + setWindowHeight(1000) + setDocumentHeight(3000) + + return new Promise(async (resolve) => { + await fireScroll(10) + + expect(latest.time).not.toEqual(0) + expect(latest.y.current).toEqual(10) + expect(latest.y.offset).toEqual([0, 2000]) + expect(latest.y.scrollLength).toEqual(2000) + expect(latest.y.targetOffset).toEqual(0) + expect(latest.y.targetLength).toEqual(3000) + expect(latest.y.containerLength).toEqual(1000) + expect(latest.y.progress).toEqual(0.005) + + await fireScroll(2000) + + expect(latest.y.current).toEqual(2000) + expect(latest.y.offset).toEqual([0, 2000]) + expect(latest.y.scrollLength).toEqual(2000) + expect(latest.y.targetOffset).toEqual(0) + expect(latest.y.targetLength).toEqual(3000) + expect(latest.y.containerLength).toEqual(1000) + expect(latest.y.progress).toEqual(1) + + stopScroll() + + resolve() + }) + }) + + test("Fires onScroll on scroll with different container.", async () => { + let latest: ScrollInfo + + const container = document.createElement("div") + + const setContainerHeight = createMockMeasurement( + container, + "clientHeight" + ) + const setContainerLength = createMockMeasurement( + container, + "scrollHeight" + ) + const setContainerScrollTop = createMockMeasurement( + container, + "scrollTop" + ) + + setContainerHeight(100) + setContainerLength(1000) + + const fireElementScroll = async (distance: number = 0) => { + setContainerScrollTop(distance) + container.dispatchEvent(new window.Event("scroll")) + await nextFrame() + } + + const stopScroll = scroll( + (_progress, info) => { + latest = info + expect(_progress).toEqual(info.y.progress) + }, + { container } + ) + + return new Promise(async (resolve) => { + await fireElementScroll(100) + + expect(latest.time).not.toEqual(0) + expect(latest.y.current).toEqual(100) + expect(latest.y.offset).toEqual([0, 900]) + expect(latest.y.scrollLength).toEqual(900) + expect(latest.y.targetOffset).toEqual(0) + expect(latest.y.targetLength).toEqual(1000) + expect(latest.y.containerLength).toEqual(100) + expect(latest.y.progress).toBeCloseTo(0.1, 1) + + await fireElementScroll(450) + + expect(latest.y.current).toEqual(450) + expect(latest.y.offset).toEqual([0, 900]) + expect(latest.y.scrollLength).toEqual(900) + expect(latest.y.targetOffset).toEqual(0) + expect(latest.y.targetLength).toEqual(1000) + expect(latest.y.containerLength).toEqual(100) + expect(latest.y.progress).toEqual(0.5) + + stopScroll() + + resolve() + }) + }) + + test("Fires onScroll on scroll with different container with child target.", async () => { + let latest: ScrollInfo + + const container = document.createElement("div") + const target = document.createElement("div") + container.appendChild(target) + + const setContainerHeight = createMockMeasurement( + container, + "clientHeight" + ) + const setContainerLength = createMockMeasurement( + container, + "scrollHeight" + ) + const setContainerScrollTop = createMockMeasurement( + container, + "scrollTop" + ) + const setTargetHeight = createMockMeasurement(target, "clientHeight") + const setTargetOffsetTop = createMockMeasurement(target, "offsetTop") + + setContainerHeight(100) + setContainerLength(1000) + setTargetHeight(200) + setTargetOffsetTop(100) + + async function fireElementScroll(distance: number = 0) { + setContainerScrollTop(distance) + container.dispatchEvent(new window.Event("scroll")) + await nextFrame() + } + + const stopScroll = scroll( + (_progress, info) => { + latest = info + }, + { container, target, offset: ScrollOffset.Enter } + ) + + return new Promise(async (resolve) => { + await nextFrame() + expect(latest.y.current).toEqual(0) + expect(latest.y.offset).toEqual([0, 200]) + expect(latest.y.scrollLength).toEqual(900) + expect(latest.y.targetOffset).toEqual(100) + expect(latest.y.targetLength).toEqual(200) + expect(latest.y.containerLength).toEqual(100) + expect(latest.y.progress).toBeCloseTo(0, 1) + + await fireElementScroll(100) + expect(latest.y.current).toEqual(100) + expect(latest.y.offset).toEqual([0, 200]) + expect(latest.y.scrollLength).toEqual(900) + expect(latest.y.targetOffset).toEqual(100) + expect(latest.y.targetLength).toEqual(200) + expect(latest.y.containerLength).toEqual(100) + expect(latest.y.progress).toBeCloseTo(0.5) + + stopScroll() + + resolve() + }) + }) + + test("Fires onScroll on window scroll with child target.", async () => { + await fireScroll(0) + let latest: ScrollInfo + + const target = document.createElement("div") + document.documentElement.appendChild(target) + + const setTargetHeight = createMockMeasurement(target, "clientHeight") + const setTargetOffsetTop = createMockMeasurement(target, "offsetTop") + + setWindowHeight(100) + setDocumentHeight(1000) + setTargetHeight(200) + setTargetOffsetTop(100) + + const stopScroll = scroll( + (_progress, info) => { + latest = info + }, + { target, offset: ScrollOffset.Enter } + ) + + return new Promise(async (resolve) => { + await nextFrame() + + expect(latest.y.current).toEqual(0) + expect(latest.y.offset).toEqual([0, 200]) + expect(latest.y.scrollLength).toEqual(900) + expect(latest.y.targetOffset).toEqual(100) + expect(latest.y.targetLength).toEqual(200) + expect(latest.y.containerLength).toEqual(100) + expect(latest.y.progress).toBeCloseTo(0, 1) + + await fireScroll(100) + expect(latest.y.current).toEqual(100) + expect(latest.y.offset).toEqual([0, 200]) + expect(latest.y.scrollLength).toEqual(900) + expect(latest.y.targetOffset).toEqual(100) + expect(latest.y.targetLength).toEqual(200) + expect(latest.y.containerLength).toEqual(100) + expect(latest.y.progress).toBeCloseTo(0.5) + + stopScroll() + + resolve() + }) + }) + + test("Fires onScroll on resize.", async () => { + let latest: ScrollInfo + + const stopScroll = scroll((_progress, info) => { + latest = info + }) + + setWindowHeight(1000) + setDocumentHeight(3000) + + return new Promise(async (resolve) => { + await fireScroll(500) + + expect(latest.time).not.toEqual(0) + expect(latest.y.current).toEqual(500) + expect(latest.y.offset).toEqual([0, 2000]) + expect(latest.y.scrollLength).toEqual(2000) + expect(latest.y.targetOffset).toEqual(0) + expect(latest.y.targetLength).toEqual(3000) + expect(latest.y.containerLength).toEqual(1000) + expect(latest.y.progress).toEqual(0.25) + await nextFrame() + + setWindowHeight(500) + setDocumentHeight(6000) + + window.dispatchEvent(new window.Event("resize")) + await nextFrame() + expect(latest.y.current).toEqual(500) + expect(latest.y.targetLength).toEqual(6000) + expect(latest.y.containerLength).toEqual(500) + + stopScroll() + + resolve() + }) + }) + + test("Fires onScroll on element resize.", async () => { + let latest: ScrollInfo + + const container = document.createElement("div") + + const setContainerHeight = createMockMeasurement( + container, + "clientHeight" + ) + const setContainerLength = createMockMeasurement( + container, + "scrollHeight" + ) + const setContainerScrollTop = createMockMeasurement( + container, + "scrollTop" + ) + + setContainerHeight(100) + setContainerLength(1000) + + const fireElementScroll = async (distance: number = 0) => { + setContainerScrollTop(distance) + container.dispatchEvent(new window.Event("scroll")) + await nextFrame() + } + + const stopScroll = scroll( + (_progress, info) => { + latest = info + }, + { container } + ) + + return new Promise(async (resolve) => { + await fireElementScroll(100) + + expect(latest.time).not.toEqual(0) + expect(latest.y.current).toEqual(100) + expect(latest.y.offset).toEqual([0, 900]) + expect(latest.y.scrollLength).toEqual(900) + expect(latest.y.targetOffset).toEqual(0) + expect(latest.y.targetLength).toEqual(1000) + expect(latest.y.containerLength).toEqual(100) + expect(latest.y.progress).toBeCloseTo(0.1, 1) + await nextFrame() + setContainerHeight(500) + setContainerLength(2000) + + window.dispatchEvent(new window.Event("resize")) + await nextFrame() + expect(latest.y.current).toEqual(100) + expect(latest.y.targetLength).toEqual(2000) + expect(latest.y.containerLength).toEqual(500) + + stopScroll() + + resolve() + }) + }) +}) diff --git a/packages/framer-motion/src/render/dom/scroll/index.ts b/packages/framer-motion/src/render/dom/scroll/index.ts index 311e8af7b7..54b4fded49 100644 --- a/packages/framer-motion/src/render/dom/scroll/index.ts +++ b/packages/framer-motion/src/render/dom/scroll/index.ts @@ -1,4 +1,4 @@ -import { ScrollOptions, OnScroll } from "./types" +import { ScrollOptions, OnScroll, OnScrollWithInfo } from "./types" import { scrollInfo } from "./track" import { GroupPlaybackControls } from "../../../animation/GroupPlaybackControls" import { ProgressTimeline, observeTimeline } from "./observe" @@ -18,7 +18,14 @@ declare global { } } -function scrollTimelineFallback({ source, axis = "y" }: ScrollOptions) { +function scrollTimelineFallback({ + source, + container, + axis = "y", +}: ScrollOptions) { + // Support legacy source argument. Deprecate later. + if (source) container = source + // ScrollTimeline records progress as a percentage CSSUnitValue const currentTime = { value: 0 } @@ -26,7 +33,7 @@ function scrollTimelineFallback({ source, axis = "y" }: ScrollOptions) { (info) => { currentTime.value = info[axis].progress * 100 }, - { container: source as HTMLElement, axis } + { container, axis } ) return { currentTime, cancel } @@ -38,33 +45,69 @@ const timelineCache = new Map< >() function getTimeline({ - source = document.documentElement, + source, + container = document.documentElement, axis = "y", }: ScrollOptions = {}): ScrollTimeline { - if (!timelineCache.has(source)) { - timelineCache.set(source, {}) + // Support legacy source argument. Deprecate later. + if (source) container = source + + if (!timelineCache.has(container)) { + timelineCache.set(container, {}) } - const elementCache = timelineCache.get(source)! + const elementCache = timelineCache.get(container)! if (!elementCache[axis]) { elementCache[axis] = supportsScrollTimeline() - ? new ScrollTimeline({ source, axis }) - : scrollTimelineFallback({ source, axis }) + ? new ScrollTimeline({ source: container, axis }) + : scrollTimelineFallback({ source: container, axis }) } return elementCache[axis]! } +function isOnScrollWithInfo(onScroll: OnScroll): onScroll is OnScrollWithInfo { + return onScroll.length === 2 +} + +function needsMainThreadScrollTracking(options?: ScrollOptions) { + return options && (options.target || options.offset) +} + export function scroll( onScroll: OnScroll | GroupPlaybackControls, options?: ScrollOptions ): VoidFunction { - const timeline = getTimeline(options) - + const axis = options?.axis || "y" if (typeof onScroll === "function") { - return observeTimeline(onScroll, timeline) + /** + * If the onScroll function has two arguments, it's expecting + * more specific information about the scroll from scrollInfo. + */ + if ( + isOnScrollWithInfo(onScroll) || + needsMainThreadScrollTracking(options) + ) { + return scrollInfo((info) => { + onScroll(info[axis].progress, info) + }, options) + } else { + return observeTimeline(onScroll, getTimeline(options)) + } } else { - return onScroll.attachTimeline(timeline) + /** + * If we need main thread scroll tracking because we're tracking + * a target or defined offsets, we need to create a scrollInfo timeline. + * Over time the number of sitauations where this is true + */ + if (needsMainThreadScrollTracking(options)) { + onScroll.pause() + return scrollInfo((info) => { + onScroll.time = onScroll.duration * info[axis].progress + }, options) + } else { + return onScroll.attachTimeline(getTimeline(options)) + } } } diff --git a/packages/framer-motion/src/render/dom/scroll/types.ts b/packages/framer-motion/src/render/dom/scroll/types.ts index 3f3801c765..8250d51055 100644 --- a/packages/framer-motion/src/render/dom/scroll/types.ts +++ b/packages/framer-motion/src/render/dom/scroll/types.ts @@ -1,11 +1,17 @@ import { EasingFunction } from "../../../easing/types" export interface ScrollOptions { - source?: Element + source?: HTMLElement + container?: HTMLElement + target?: Element axis?: "x" | "y" + offset?: ScrollOffset } -export type OnScroll = (progress: number) => void +export type OnScrollProgress = (progress: number) => void +export type OnScrollWithInfo = (progress: number, info: ScrollInfo) => void + +export type OnScroll = OnScrollProgress | OnScrollWithInfo export interface AxisScrollInfo { current: number @@ -58,5 +64,4 @@ export interface ScrollInfoOptions { target?: Element axis?: "x" | "y" offset?: ScrollOffset - smooth?: number } diff --git a/packages/framer-motion/src/value/use-scroll.ts b/packages/framer-motion/src/value/use-scroll.ts index 96f74942d5..6fcbcb1a39 100644 --- a/packages/framer-motion/src/value/use-scroll.ts +++ b/packages/framer-motion/src/value/use-scroll.ts @@ -4,7 +4,7 @@ import { useConstant } from "../utils/use-constant" import { useEffect } from "react" import { useIsomorphicLayoutEffect } from "../three-entry" import { warning } from "../utils/errors" -import { scrollInfo } from "../render/dom/scroll/track" +import { scroll } from "../render/dom/scroll" import { ScrollInfoOptions } from "../render/dom/scroll/types" export interface UseScrollOptions @@ -44,8 +44,8 @@ export function useScroll({ refWarning("target", target) refWarning("container", container) - return scrollInfo( - ({ x, y }) => { + return scroll( + (_progress, { x, y }) => { values.scrollX.set(x.current) values.scrollXProgress.set(x.progress) values.scrollY.set(y.current)