From d3581b59359285ef180917d239289befddb392f7 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 27 Jul 2023 12:24:32 +0200 Subject: [PATCH 1/6] Adding support for animations --- dev/tests/scroll-animate-window.tsx | 52 +++++++++++++++++++ dev/tests/scroll-callback-element-x.tsx | 2 +- dev/tests/scroll-callback-element.tsx | 2 +- dev/tests/scroll-callback-first-frame.tsx | 2 +- dev/tests/scroll-callback-window.tsx | 24 ++++++++- .../cypress/integration/scroll.ts | 46 +++++++++++++++- .../src/animation/GroupPlaybackControls.ts | 31 ++++++++--- .../waapi/create-accelerated-animation.ts | 7 ++- packages/framer-motion/src/animation/types.ts | 5 +- .../src/render/dom/scroll/index.ts | 37 +++++-------- .../src/render/dom/scroll/observe.ts | 32 ++++++++++++ .../src/render/dom/scroll/supports.ts | 5 ++ 12 files changed, 204 insertions(+), 41 deletions(-) create mode 100644 dev/tests/scroll-animate-window.tsx create mode 100644 packages/framer-motion/src/render/dom/scroll/observe.ts create mode 100644 packages/framer-motion/src/render/dom/scroll/supports.ts diff --git a/dev/tests/scroll-animate-window.tsx b/dev/tests/scroll-animate-window.tsx new file mode 100644 index 0000000000..318cefe11b --- /dev/null +++ b/dev/tests/scroll-animate-window.tsx @@ -0,0 +1,52 @@ +import { scroll, animate } from "framer-motion" +import * as React from "react" +import { useEffect } from "react" + +export const App = () => { + useEffect(() => { + /** + * Animate both background-color (WAAPI-driven) and color (sync) + */ + return scroll( + animate( + "#color", + { + backgroundColor: ["#fff", "#000"], + color: ["#000", "#fff"], + transform: ["none", "translateX(100px)"], + }, + { ease: "linear" } + ) + ) + }, []) + + return ( + <> +
+
+
+
+
+ A +
+ + ) +} + +const spacer = { + height: "100vh", +} + +const progressStyle: React.CSSProperties = { + position: "fixed", + top: 0, + left: 0, + width: 100, + height: 100, + display: "flex", + justifyContent: "center", + alignItems: "center", + fontSize: 80, + lineHeight: 80, + fontWeight: "bold", +} diff --git a/dev/tests/scroll-callback-element-x.tsx b/dev/tests/scroll-callback-element-x.tsx index c6ea335ca8..c5bf15f680 100644 --- a/dev/tests/scroll-callback-element-x.tsx +++ b/dev/tests/scroll-callback-element-x.tsx @@ -10,7 +10,7 @@ export const App = () => { useEffect(() => { if (!ref.current) return - scroll(setProgress, { source: ref.current, axis: "x" }) + return scroll(setProgress, { source: ref.current, axis: "x" }) }, []) return ( diff --git a/dev/tests/scroll-callback-element.tsx b/dev/tests/scroll-callback-element.tsx index 27af15a32a..b53513d38e 100644 --- a/dev/tests/scroll-callback-element.tsx +++ b/dev/tests/scroll-callback-element.tsx @@ -9,7 +9,7 @@ export const App = () => { const ref = useRef(null) useEffect(() => { - scroll(setProgress, { source: ref.current }) + return scroll(setProgress, { source: ref.current }) }, []) return ( diff --git a/dev/tests/scroll-callback-first-frame.tsx b/dev/tests/scroll-callback-first-frame.tsx index ea2ea2f15f..8867a0e386 100644 --- a/dev/tests/scroll-callback-first-frame.tsx +++ b/dev/tests/scroll-callback-first-frame.tsx @@ -6,7 +6,7 @@ export const App = () => { const [progress, setProgress] = useState(0) useEffect(() => { - scroll((p) => setProgress(2 - p)) + return scroll((p) => setProgress(2 - p)) }, []) return ( diff --git a/dev/tests/scroll-callback-window.tsx b/dev/tests/scroll-callback-window.tsx index 20703c066f..b5b56302dd 100644 --- a/dev/tests/scroll-callback-window.tsx +++ b/dev/tests/scroll-callback-window.tsx @@ -1,12 +1,23 @@ -import { scroll } from "framer-motion" +import { scroll, frameData } from "framer-motion" import * as React from "react" import { useEffect, useState } from "react" export const App = () => { const [progress, setProgress] = useState(0) + const [error, setError] = useState("") useEffect(() => { - scroll(setProgress) + let prevFrameStamp = 0 + + return scroll((p) => { + setProgress(p) + + if (prevFrameStamp === frameData.timestamp) { + setError("Concurrent event handlers detect") + } + + prevFrameStamp = frameData.timestamp + }) }, []) return ( @@ -18,6 +29,9 @@ export const App = () => {
{progress}
+
+ {error} +
) } @@ -31,3 +45,9 @@ const progressStyle: React.CSSProperties = { top: 0, left: 0, } + +const errorStyle: React.CSSProperties = { + position: "fixed", + bottom: 0, + left: 0, +} diff --git a/packages/framer-motion/cypress/integration/scroll.ts b/packages/framer-motion/cypress/integration/scroll.ts index 419ae8a4e2..9c806ea913 100644 --- a/packages/framer-motion/cypress/integration/scroll.ts +++ b/packages/framer-motion/cypress/integration/scroll.ts @@ -1,4 +1,4 @@ -describe("scroll()", () => { +describe("scroll() callbacks", () => { it("Fires callback on first frame, before scroll event", () => { cy.visit("?test=scroll-callback-first-frame") .wait(100) @@ -23,6 +23,10 @@ describe("scroll()", () => { .should(([$element]: any) => { expect($element.innerText).to.equal("0.25") }) + + cy.get("#error").should(([$element]: any) => { + expect($element.innerText).to.equal("") + }) }) it("Correctly updates window scroll progress callback, x axis", () => { @@ -70,3 +74,43 @@ describe("scroll()", () => { }) }) }) + +describe("scroll() animation", () => { + it("Updates aniamtion on first frame, before scroll event", () => { + cy.visit("?test=scroll-animate-window") + .wait(100) + .get("#color") + .should(([$element]: any) => { + expect(getComputedStyle($element).backgroundColor).to.equal( + "rgb(255, 255, 255)" + ) + }) + }) + + it("Correctly updates window scroll progress callback", () => { + cy.visit("?test=scroll-animate-window").wait(100).viewport(100, 400) + + cy.scrollTo(0, 600) + .wait(200) + .get("#color") + .should(([$element]: any) => { + expect(getComputedStyle($element).backgroundColor).to.equal( + "rgb(180, 180, 180)" + ) + expect(getComputedStyle($element).color).to.equal( + "rgb(180, 180, 180)" + ) + }) + cy.viewport(100, 800) + .wait(200) + .get("#color") + .should(([$element]: any) => { + expect(getComputedStyle($element).backgroundColor).to.equal( + "rgb(64, 64, 64)" + ) + expect(getComputedStyle($element).color).to.equal( + "rgb(64, 64, 64)" + ) + }) + }) +}) diff --git a/packages/framer-motion/src/animation/GroupPlaybackControls.ts b/packages/framer-motion/src/animation/GroupPlaybackControls.ts index a19ad209be..e1c94b982b 100644 --- a/packages/framer-motion/src/animation/GroupPlaybackControls.ts +++ b/packages/framer-motion/src/animation/GroupPlaybackControls.ts @@ -1,6 +1,8 @@ +import { observeTimeline } from "../render/dom/scroll/observe" +import { supportsScrollTimeline } from "../render/dom/scroll/supports" import { AnimationPlaybackControls } from "./types" -type PropNames = "time" | "speed" | "duration" | "timeline" +type PropNames = "time" | "speed" | "duration" export class GroupPlaybackControls implements AnimationPlaybackControls { animations: AnimationPlaybackControls[] @@ -28,12 +30,27 @@ export class GroupPlaybackControls implements AnimationPlaybackControls { } } - get timeline() { - return this.getAll("timeline") - } - - set timeline(timeline: any) { - this.setAll("timeline", timeline) + attachTimeline(timeline: any) { + const cancel = this.animations.map((animation) => { + if (supportsScrollTimeline() && animation.isAccelerated) { + animation.timeline = timeline + } else { + animation.pause() + return observeTimeline((progress) => { + animation.time = animation.duration * progress + }, timeline) + } + }) + + return () => { + cancel.forEach((destroy, i) => { + if (destroy) { + destroy() + } + + this.animations[i].stop() + }) + } } get time() { diff --git a/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts b/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts index 2a941a4cac..2aa886e8b9 100644 --- a/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts +++ b/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts @@ -160,12 +160,13 @@ export function createAcceleratedAnimation( /** * Animation interrupt callback. */ - return { + const controls = { + isAccelerated: true, then(resolve: VoidFunction, reject?: VoidFunction) { return currentFinishedPromise.then(resolve, reject) }, get timeline() { - return animation.timeline + return animation.timeline as any }, set timeline(timeline) { animation.timeline = timeline @@ -227,4 +228,6 @@ export function createAcceleratedAnimation( complete: () => animation.finish(), cancel: safeCancel, } + + return controls } diff --git a/packages/framer-motion/src/animation/types.ts b/packages/framer-motion/src/animation/types.ts index 29f1dd4d10..866839c37f 100644 --- a/packages/framer-motion/src/animation/types.ts +++ b/packages/framer-motion/src/animation/types.ts @@ -4,6 +4,7 @@ import { Easing } from "../easing/types" import { Driver } from "./animators/js/types" import { SVGPathProperties, VariantLabels } from "../motion/types" import { SVGAttributes } from "../render/svg/types-attributes" +import { Timeline } from "./animators/timeline" export interface AnimationPlaybackLifecycles { onUpdate?: (latest: V) => void @@ -87,13 +88,15 @@ export interface AnimationPlaybackControls { */ duration: number + isAccelerated?: boolean + stop: () => void play: () => void pause: () => void complete: () => void cancel: () => void then: (onResolve: VoidFunction, onReject?: VoidFunction) => Promise - timeline?: AnimationTimeline | null + timeline?: Timeline | null } export type DynamicOption = (i: number, total: number) => T diff --git a/packages/framer-motion/src/render/dom/scroll/index.ts b/packages/framer-motion/src/render/dom/scroll/index.ts index f84a227a50..311e8af7b7 100644 --- a/packages/framer-motion/src/render/dom/scroll/index.ts +++ b/packages/framer-motion/src/render/dom/scroll/index.ts @@ -1,11 +1,10 @@ import { ScrollOptions, OnScroll } from "./types" -import { cancelFrame, frame } from "../../../frameloop" -import { memo } from "../../../utils/memo" import { scrollInfo } from "./track" +import { GroupPlaybackControls } from "../../../animation/GroupPlaybackControls" +import { ProgressTimeline, observeTimeline } from "./observe" +import { supportsScrollTimeline } from "./supports" -const supportsScrollTimeline = memo(() => window.ScrollTimeline !== undefined) - -declare class ScrollTimeline { +declare class ScrollTimeline implements ProgressTimeline { constructor(options: ScrollOptions) currentTime: null | { value: number } @@ -57,27 +56,15 @@ function getTimeline({ return elementCache[axis]! } -export function scroll(onScroll: OnScroll, options?: ScrollOptions) { +export function scroll( + onScroll: OnScroll | GroupPlaybackControls, + options?: ScrollOptions +): VoidFunction { const timeline = getTimeline(options) - let prevProgress: number - - const onFrame = () => { - const { currentTime } = timeline - const percentage = currentTime === null ? 0 : currentTime.value - const progress = percentage / 100 - - if (prevProgress !== progress) { - onScroll(progress) - } - - prevProgress = progress - } - - frame.update(onFrame, true) - - return () => { - cancelFrame(onFrame) - if (timeline.cancel) timeline.cancel() + if (typeof onScroll === "function") { + return observeTimeline(onScroll, timeline) + } else { + return onScroll.attachTimeline(timeline) } } diff --git a/packages/framer-motion/src/render/dom/scroll/observe.ts b/packages/framer-motion/src/render/dom/scroll/observe.ts new file mode 100644 index 0000000000..fb0ca30d55 --- /dev/null +++ b/packages/framer-motion/src/render/dom/scroll/observe.ts @@ -0,0 +1,32 @@ +import { cancelFrame, frame } from "../../../frameloop" + +type Update = (progress: number) => void + +export interface ProgressTimeline { + currentTime: null | { value: number } + + cancel?: VoidFunction +} + +export function observeTimeline(update: Update, timeline: ProgressTimeline) { + let prevProgress: number + + const onFrame = () => { + const { currentTime } = timeline + const percentage = currentTime === null ? 0 : currentTime.value + const progress = percentage / 100 + + if (prevProgress !== progress) { + update(progress) + } + + prevProgress = progress + } + + frame.update(onFrame, true) + + return () => { + console.log("cancel timeline observation") + cancelFrame(onFrame) + } +} diff --git a/packages/framer-motion/src/render/dom/scroll/supports.ts b/packages/framer-motion/src/render/dom/scroll/supports.ts new file mode 100644 index 0000000000..8b6a65a02c --- /dev/null +++ b/packages/framer-motion/src/render/dom/scroll/supports.ts @@ -0,0 +1,5 @@ +import { memo } from "../../../utils/memo" + +export const supportsScrollTimeline = memo( + () => window.ScrollTimeline !== undefined +) From b2d7ca22b82ab36270f879ba1946425452bff9d5 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 27 Jul 2023 14:10:07 +0200 Subject: [PATCH 2/6] Updating --- dev/tests/scroll-callback-window.tsx | 2 +- .../src/animation/GroupPlaybackControls.ts | 12 +++++------- .../animators/waapi/create-accelerated-animation.ts | 6 ++---- packages/framer-motion/src/animation/types.ts | 4 ++-- 4 files changed, 10 insertions(+), 14 deletions(-) diff --git a/dev/tests/scroll-callback-window.tsx b/dev/tests/scroll-callback-window.tsx index b5b56302dd..c65109b869 100644 --- a/dev/tests/scroll-callback-window.tsx +++ b/dev/tests/scroll-callback-window.tsx @@ -13,7 +13,7 @@ export const App = () => { setProgress(p) if (prevFrameStamp === frameData.timestamp) { - setError("Concurrent event handlers detect") + setError("Concurrent event handlers detected") } prevFrameStamp = frameData.timestamp diff --git a/packages/framer-motion/src/animation/GroupPlaybackControls.ts b/packages/framer-motion/src/animation/GroupPlaybackControls.ts index e1c94b982b..1ddf1b1efb 100644 --- a/packages/framer-motion/src/animation/GroupPlaybackControls.ts +++ b/packages/framer-motion/src/animation/GroupPlaybackControls.ts @@ -31,9 +31,9 @@ export class GroupPlaybackControls implements AnimationPlaybackControls { } attachTimeline(timeline: any) { - const cancel = this.animations.map((animation) => { - if (supportsScrollTimeline() && animation.isAccelerated) { - animation.timeline = timeline + const cancelAll = this.animations.map((animation) => { + if (supportsScrollTimeline() && animation.attachTimeline) { + animation.attachTimeline(timeline) } else { animation.pause() return observeTimeline((progress) => { @@ -43,10 +43,8 @@ export class GroupPlaybackControls implements AnimationPlaybackControls { }) return () => { - cancel.forEach((destroy, i) => { - if (destroy) { - destroy() - } + cancelAll.forEach((cancelTimeline, i) => { + if (cancelTimeline) cancelTimeline() this.animations[i].stop() }) diff --git a/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts b/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts index 2aa886e8b9..a3c3729ea0 100644 --- a/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts +++ b/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts @@ -12,6 +12,7 @@ import { secondsToMilliseconds, } from "../../../utils/time-conversion" import { memo } from "../../../utils/memo" +import { ProgressTimeline } from "../../../render/dom/scroll/observe" const supportsWaapi = memo(() => Object.hasOwnProperty.call(Element.prototype, "animate") @@ -165,10 +166,7 @@ export function createAcceleratedAnimation( then(resolve: VoidFunction, reject?: VoidFunction) { return currentFinishedPromise.then(resolve, reject) }, - get timeline() { - return animation.timeline as any - }, - set timeline(timeline) { + attachTimeline(timeline: any) { animation.timeline = timeline animation.onfinish = null }, diff --git a/packages/framer-motion/src/animation/types.ts b/packages/framer-motion/src/animation/types.ts index 866839c37f..b934516140 100644 --- a/packages/framer-motion/src/animation/types.ts +++ b/packages/framer-motion/src/animation/types.ts @@ -4,7 +4,7 @@ import { Easing } from "../easing/types" import { Driver } from "./animators/js/types" import { SVGPathProperties, VariantLabels } from "../motion/types" import { SVGAttributes } from "../render/svg/types-attributes" -import { Timeline } from "./animators/timeline" +import { ProgressTimeline } from "../render/dom/scroll/observe" export interface AnimationPlaybackLifecycles { onUpdate?: (latest: V) => void @@ -96,7 +96,7 @@ export interface AnimationPlaybackControls { complete: () => void cancel: () => void then: (onResolve: VoidFunction, onReject?: VoidFunction) => Promise - timeline?: Timeline | null + attachTimeline?: (timeline: ProgressTimeline) => VoidFunction } export type DynamicOption = (i: number, total: number) => T From d9a7b3eadbc1c81b104e709118a7416c3b7f2684 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 27 Jul 2023 14:13:37 +0200 Subject: [PATCH 3/6] Updating --- .../animation/animators/waapi/create-accelerated-animation.ts | 3 ++- packages/framer-motion/src/animation/types.ts | 2 -- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts b/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts index a3c3729ea0..5c7a1d2e8d 100644 --- a/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts +++ b/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts @@ -13,6 +13,7 @@ import { } from "../../../utils/time-conversion" import { memo } from "../../../utils/memo" import { ProgressTimeline } from "../../../render/dom/scroll/observe" +import { noop } from "../../../utils/noop" const supportsWaapi = memo(() => Object.hasOwnProperty.call(Element.prototype, "animate") @@ -162,13 +163,13 @@ export function createAcceleratedAnimation( * Animation interrupt callback. */ const controls = { - isAccelerated: true, then(resolve: VoidFunction, reject?: VoidFunction) { return currentFinishedPromise.then(resolve, reject) }, attachTimeline(timeline: any) { animation.timeline = timeline animation.onfinish = null + return noop }, get time() { return millisecondsToSeconds(animation.currentTime || 0) diff --git a/packages/framer-motion/src/animation/types.ts b/packages/framer-motion/src/animation/types.ts index b934516140..8ad1026ac9 100644 --- a/packages/framer-motion/src/animation/types.ts +++ b/packages/framer-motion/src/animation/types.ts @@ -88,8 +88,6 @@ export interface AnimationPlaybackControls { */ duration: number - isAccelerated?: boolean - stop: () => void play: () => void pause: () => void From 60ea9f9947c6c26ad5e719501a2744146561a000 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 27 Jul 2023 14:22:33 +0200 Subject: [PATCH 4/6] Removing unused import --- .../animation/animators/waapi/create-accelerated-animation.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts b/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts index 5c7a1d2e8d..7f39304b51 100644 --- a/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts +++ b/packages/framer-motion/src/animation/animators/waapi/create-accelerated-animation.ts @@ -12,7 +12,6 @@ import { secondsToMilliseconds, } from "../../../utils/time-conversion" import { memo } from "../../../utils/memo" -import { ProgressTimeline } from "../../../render/dom/scroll/observe" import { noop } from "../../../utils/noop" const supportsWaapi = memo(() => From 41338c295d460c936a68b626057c017564c3c774 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 27 Jul 2023 15:00:41 +0200 Subject: [PATCH 5/6] Fixing --- packages/framer-motion/src/animation/GroupPlaybackControls.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/framer-motion/src/animation/GroupPlaybackControls.ts b/packages/framer-motion/src/animation/GroupPlaybackControls.ts index 1ddf1b1efb..44083012e1 100644 --- a/packages/framer-motion/src/animation/GroupPlaybackControls.ts +++ b/packages/framer-motion/src/animation/GroupPlaybackControls.ts @@ -2,7 +2,7 @@ import { observeTimeline } from "../render/dom/scroll/observe" import { supportsScrollTimeline } from "../render/dom/scroll/supports" import { AnimationPlaybackControls } from "./types" -type PropNames = "time" | "speed" | "duration" +type PropNames = "time" | "speed" | "duration" | "attachTimeline" export class GroupPlaybackControls implements AnimationPlaybackControls { animations: AnimationPlaybackControls[] From c100f875f71fbdbf08fb331a07af4f112c46947b Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 27 Jul 2023 15:41:45 +0200 Subject: [PATCH 6/6] Latest --- packages/framer-motion/src/render/dom/scroll/observe.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/framer-motion/src/render/dom/scroll/observe.ts b/packages/framer-motion/src/render/dom/scroll/observe.ts index fb0ca30d55..941bff54b6 100644 --- a/packages/framer-motion/src/render/dom/scroll/observe.ts +++ b/packages/framer-motion/src/render/dom/scroll/observe.ts @@ -25,8 +25,5 @@ export function observeTimeline(update: Update, timeline: ProgressTimeline) { frame.update(onFrame, true) - return () => { - console.log("cancel timeline observation") - cancelFrame(onFrame) - } + return () => cancelFrame(onFrame) }