Skip to content

Commit

Permalink
Adding unit support to useSpring (#3046)
Browse files Browse the repository at this point in the history
* Adding unit support to useSpring

* Fixing tests

* Fixes

* Removing string coercison
  • Loading branch information
mattgperry authored Feb 5, 2025
1 parent d100739 commit 85bd99f
Show file tree
Hide file tree
Showing 4 changed files with 247 additions and 143 deletions.
2 changes: 1 addition & 1 deletion dev/react/src/examples/Drag-SharedLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ function Target({ onProjectionUpdate }: TargetProps) {
drag
dragConstraints={{ top: 0, left: 0, right: 0, bottom: 0 }}
dragElastic={1}
onProjectionUpdate={onProjectionUpdate}
onLayoutMeasure={onProjectionUpdate}
layoutId="a"
style={{
background: "white",
Expand Down
18 changes: 13 additions & 5 deletions dev/react/src/examples/useSpring.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { motion, useMotionValue, useSpring } from "framer-motion"
import {
frame,
motion,
useMotionValue,
useSpring,
useTransform,
} from "framer-motion"
import { useRef, useState } from "react"

const spring = {
Expand All @@ -11,8 +17,10 @@ const spring = {
function DragExample() {
const dragX = useMotionValue(0)
const dragY = useMotionValue(0)
const x = useSpring(dragX, spring)
const y = useSpring(dragY, spring)
const dragXPX = useTransform(dragX, (v) => `${v}%`)
const dragYPX = useTransform(dragY, (v) => `${v}%`)
const x = useSpring(dragXPX, spring)
const y = useSpring(dragYPX, spring)

return (
<motion.div
Expand All @@ -28,10 +36,10 @@ function DragExample() {
}

function RerenderExample() {
const [{ x, y }, setMousePosition] = useState({ x: null, y: null })
const [{ x, y }, setMousePosition] = useState({ x: 0, y: 0 })

const updateMousePosition = useRef((e) => {
setMousePosition({ x: e.clientX, y: e.clientY })
frame.postRender(() => setMousePosition({ x: e.clientX, y: e.clientY }))
})

const size = 40
Expand Down
309 changes: 190 additions & 119 deletions packages/framer-motion/src/value/__tests__/use-spring.test.tsx
Original file line number Diff line number Diff line change
@@ -1,156 +1,227 @@
import { render } from "../../../jest.setup"
import { useEffect } from "react";
import { useSpring } from "../use-spring"
import { useMotionValue } from "../use-motion-value"
import { useEffect } from "react"
import { motionValue, MotionValue } from ".."
import { motion } from "../../"
import { render } from "../../../jest.setup"
import { syncDriver } from "../../animation/animators/__tests__/utils"
import { useMotionValue } from "../use-motion-value"
import { useSpring } from "../use-spring"

describe("useSpring", () => {
describe("useSpring types", () => {
test("can create a motion value from a number", async () => {
const promise = new Promise((resolve) => {
const Component = () => {
const x = useSpring(0)
const Component = () => {
const x = useSpring(0)
expect(x.get()).toBe(0)
return null
}
render(<Component />)
})

useEffect(() => {
x.on("change", (v) => resolve(v))
x.set(100)
})
test("can create a motion value from a string with a unit", async () => {
const Component = () => {
const x = useSpring("0%")
expect(x.get()).toBe("0%")
return null
}
render(<Component />)
})

return null
}
test("can create a motion value from a number motion value", async () => {
const Component = () => {
const source = motionValue(0)
const x = useSpring(source)
expect(x.get()).toBe(0)
return null
}
render(<Component />)
})

const { rerender } = render(<Component />)
rerender(<Component />)
})
test("can create a motion value from a string motion value with a unit", async () => {
const Component = () => {
const source = motionValue("0%")
const x = useSpring(source)
expect(x.get()).toBe("0%")
return null
}
render(<Component />)
})
})

const resolved = await promise
const runSpringTests = (unit?: string | undefined) => {
const createValue = (num: number) => {
if (unit) {
return `${num}${unit}` as unknown as number
}
return num as number
}

expect(resolved).not.toBe(0)
expect(resolved).not.toBe(100)
})
const parseTestValue = (val: string | number): number =>
typeof val === "string" ? parseFloat(val) : val

test("can create a MotionValue that responds to changes from another MotionValue", async () => {
const promise = new Promise((resolve) => {
const Component = () => {
const x = useMotionValue(0)
const y = useSpring(x)
const formatOutput = (num: number) => {
if (unit) {
return `${Math.round(num)}${unit}`
}
return Math.round(num)
}

describe(`useSpring ${unit ? `with ${unit}` : "with numbers"}`, () => {
test("can create a motion value from a number", async () => {
const promise = new Promise((resolve) => {
const Component = () => {
const x = useMotionValue(createValue(0))
const spring = useSpring(x)

useEffect(() => {
spring.on("change", (v) => resolve(v))
x.set(createValue(100))
})

useEffect(() => {
y.on("change", (v) => resolve(v))
x.set(100)
})
return null
}

return null
}
const { rerender } = render(<Component />)
rerender(<Component />)
})

const { rerender } = render(<Component />)
rerender(<Component />)
})
const resolved = await promise

const resolved = await promise
expect(resolved).not.toBe(createValue(0))
expect(resolved).not.toBe(createValue(100))
})

expect(resolved).not.toBe(0)
expect(resolved).not.toBe(100)
})
test("can create a MotionValue that responds to changes from another MotionValue", async () => {
const promise = new Promise((resolve) => {
const Component = () => {
const x = useMotionValue(createValue(0))
const y = useSpring(x)

test("creates a spring that animates to the subscribed motion value", async () => {
const promise = new Promise<number[]>((resolve) => {
const output: number[] = []
const Component = () => {
const x = useMotionValue(0)
const y = useSpring(x, {
driver: syncDriver(10),
} as any)

useEffect(() => {
return y.on("change", (v) => {
if (output.length >= 10) {
resolve(output)
} else {
output.push(Math.round(v))
}
useEffect(() => {
y.on("change", (v) => resolve(v))
x.set(createValue(100))
})
})

useEffect(() => {
x.set(100)
}, [])
return null
}

return null
}
const { rerender } = render(<Component />)
rerender(<Component />)
})

const resolved = await promise

const { rerender } = render(<Component />)
rerender(<Component />)
expect(resolved).not.toBe(createValue(0))
expect(resolved).not.toBe(createValue(100))
})

const resolved = await promise
test("creates a spring that animates to the subscribed motion value", async () => {
const promise = new Promise<Array<string | number>>((resolve) => {
const output: Array<string | number> = []
const Component = () => {
const x = useMotionValue(createValue(0))
const y = useSpring(x, {
driver: syncDriver(10),
} as any)

useEffect(() => {
return y.on("change", (v) => {
if (output.length >= 10) {
resolve(output)
} else {
output.push(formatOutput(parseTestValue(v)))
}
})
})

const testNear = (value: number, expected: number, deviation = 2) => {
expect(
value >= expected - deviation && value <= expected + deviation
).toBe(true)
}
useEffect(() => {
x.set(createValue(100))
}, [])

return null
}

const { rerender } = render(<Component />)
rerender(<Component />)
})

const resolved = await promise

const testNear = (
value: string | number,
expected: number,
deviation = 2
) => {
const numValue = parseTestValue(value)
expect(
numValue >= expected - deviation &&
numValue <= expected + deviation
).toBe(true)
}

testNear(resolved[0], 0)
testNear(resolved[4], 10)
testNear(resolved[8], 30)
})
testNear(resolved[0], 0)
testNear(resolved[4], 10)
testNear(resolved[8], 30)
})

test("will not animate if immediate=true", async () => {
const promise = new Promise((resolve) => {
const output: number[] = []
const Component = () => {
const y = useSpring(0, {
driver: syncDriver(10),
} as any)

useEffect(() => {
return y.on("change", (v) => {
if (output.length >= 10) {
} else {
output.push(Math.round(v))
}
test("will not animate if immediate=true", async () => {
const promise = new Promise((resolve) => {
const output: Array<string | number> = []
const Component = () => {
const x = useMotionValue(createValue(0))
const y = useSpring(x, {
driver: syncDriver(10),
} as any)

useEffect(() => {
return y.on("change", (v) => {
if (output.length >= 10) {
} else {
output.push(formatOutput(parseTestValue(v)))
}
})
})
})

useEffect(() => {
y.jump(100)
useEffect(() => {
y.jump(createValue(100))

setTimeout(() => {
resolve(output)
}, 100)
}, [])
setTimeout(() => {
resolve(output)
}, 100)
}, [])

return null
}
return null
}

const { rerender } = render(<Component />)
rerender(<Component />)
})
const { rerender } = render(<Component />)
rerender(<Component />)
})

const resolved = await promise
const resolved = await promise

expect(resolved).toEqual([100])
})
expect(resolved).toEqual([createValue(100)])
})

test("unsubscribes when attached to a new value", () => {
const a = motionValue(0)
const b = motionValue(0)
let y: MotionValue<number>
const Component = ({ target }: { target: MotionValue<number> }) => {
y = useSpring(target)
return <motion.div style={{ y }} />
}
test("unsubscribes when attached to a new value", () => {
const a = motionValue(createValue(0))
const b = motionValue(createValue(0))
let y: MotionValue<number>
const Component = ({ target }: { target: MotionValue<number> }) => {
y = useSpring(target)
return <motion.div style={{ y }} />
}

const { rerender } = render(<Component target={a} />)
rerender(<Component target={b} />)
rerender(<Component target={a} />)
rerender(<Component target={b} />)
rerender(<Component target={a} />)
rerender(<Component target={a} />)
const { rerender } = render(<Component target={a} />)
rerender(<Component target={b} />)
rerender(<Component target={a} />)
rerender(<Component target={b} />)
rerender(<Component target={a} />)
rerender(<Component target={a} />)

// Cast to any here as `.events` is private API
expect((a as any).events.change.getSize()).toBe(1)
// Cast to any here as `.events` is private API
expect((a as any).events.change.getSize()).toBe(1)
})
})
})
}

// Run tests for both number values and percentage values
runSpringTests()
runSpringTests("%")
Loading

0 comments on commit 85bd99f

Please sign in to comment.