Skip to content

Commit

Permalink
Merge pull request #2259 from framer/feature/scroll-animate
Browse files Browse the repository at this point in the history
Hardware accelerated scroll animations
  • Loading branch information
mergetron[bot] authored Jul 27, 2023
2 parents 3bbef6c + c100f87 commit 8090668
Show file tree
Hide file tree
Showing 12 changed files with 198 additions and 44 deletions.
52 changes: 52 additions & 0 deletions dev/tests/scroll-animate-window.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div style={{ ...spacer, backgroundColor: "red" }} />
<div style={{ ...spacer, backgroundColor: "green" }} />
<div style={{ ...spacer, backgroundColor: "blue" }} />
<div style={{ ...spacer, backgroundColor: "yellow" }} />
<div id="color" style={progressStyle}>
A
</div>
</>
)
}

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",
}
2 changes: 1 addition & 1 deletion dev/tests/scroll-callback-element-x.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
2 changes: 1 addition & 1 deletion dev/tests/scroll-callback-element.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const App = () => {
const ref = useRef(null)

useEffect(() => {
scroll(setProgress, { source: ref.current })
return scroll(setProgress, { source: ref.current })
}, [])

return (
Expand Down
2 changes: 1 addition & 1 deletion dev/tests/scroll-callback-first-frame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
24 changes: 22 additions & 2 deletions dev/tests/scroll-callback-window.tsx
Original file line number Diff line number Diff line change
@@ -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 detected")
}

prevFrameStamp = frameData.timestamp
})
}, [])

return (
Expand All @@ -18,6 +29,9 @@ export const App = () => {
<div id="progress" style={progressStyle}>
{progress}
</div>
<div id="error" style={errorStyle}>
{error}
</div>
</>
)
}
Expand All @@ -31,3 +45,9 @@ const progressStyle: React.CSSProperties = {
top: 0,
left: 0,
}

const errorStyle: React.CSSProperties = {
position: "fixed",
bottom: 0,
left: 0,
}
46 changes: 45 additions & 1 deletion packages/framer-motion/cypress/integration/scroll.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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)"
)
})
})
})
29 changes: 22 additions & 7 deletions packages/framer-motion/src/animation/GroupPlaybackControls.ts
Original file line number Diff line number Diff line change
@@ -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" | "attachTimeline"

export class GroupPlaybackControls implements AnimationPlaybackControls {
animations: AnimationPlaybackControls[]
Expand Down Expand Up @@ -28,12 +30,25 @@ export class GroupPlaybackControls implements AnimationPlaybackControls {
}
}

get timeline() {
return this.getAll("timeline")
}

set timeline(timeline: any) {
this.setAll("timeline", timeline)
attachTimeline(timeline: any) {
const cancelAll = this.animations.map((animation) => {
if (supportsScrollTimeline() && animation.attachTimeline) {
animation.attachTimeline(timeline)
} else {
animation.pause()
return observeTimeline((progress) => {
animation.time = animation.duration * progress
}, timeline)
}
})

return () => {
cancelAll.forEach((cancelTimeline, i) => {
if (cancelTimeline) cancelTimeline()

this.animations[i].stop()
})
}
}

get time() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
secondsToMilliseconds,
} from "../../../utils/time-conversion"
import { memo } from "../../../utils/memo"
import { noop } from "../../../utils/noop"

const supportsWaapi = memo(() =>
Object.hasOwnProperty.call(Element.prototype, "animate")
Expand Down Expand Up @@ -160,16 +161,14 @@ export function createAcceleratedAnimation(
/**
* Animation interrupt callback.
*/
return {
const controls = {
then(resolve: VoidFunction, reject?: VoidFunction) {
return currentFinishedPromise.then(resolve, reject)
},
get timeline() {
return animation.timeline
},
set timeline(timeline) {
attachTimeline(timeline: any) {
animation.timeline = timeline
animation.onfinish = null
return noop<void>
},
get time() {
return millisecondsToSeconds(animation.currentTime || 0)
Expand Down Expand Up @@ -227,4 +226,6 @@ export function createAcceleratedAnimation(
complete: () => animation.finish(),
cancel: safeCancel,
}

return controls
}
3 changes: 2 additions & 1 deletion packages/framer-motion/src/animation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { ProgressTimeline } from "../render/dom/scroll/observe"

export interface AnimationPlaybackLifecycles<V> {
onUpdate?: (latest: V) => void
Expand Down Expand Up @@ -93,7 +94,7 @@ export interface AnimationPlaybackControls {
complete: () => void
cancel: () => void
then: (onResolve: VoidFunction, onReject?: VoidFunction) => Promise<void>
timeline?: AnimationTimeline | null
attachTimeline?: (timeline: ProgressTimeline) => VoidFunction
}

export type DynamicOption<T> = (i: number, total: number) => T
Expand Down
37 changes: 12 additions & 25 deletions packages/framer-motion/src/render/dom/scroll/index.ts
Original file line number Diff line number Diff line change
@@ -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 }
Expand Down Expand Up @@ -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)
}
}
29 changes: 29 additions & 0 deletions packages/framer-motion/src/render/dom/scroll/observe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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 () => cancelFrame(onFrame)
}
5 changes: 5 additions & 0 deletions packages/framer-motion/src/render/dom/scroll/supports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { memo } from "../../../utils/memo"

export const supportsScrollTimeline = memo(
() => window.ScrollTimeline !== undefined
)

0 comments on commit 8090668

Please sign in to comment.