diff --git a/dev/examples/Animation-reverse.tsx b/dev/examples/Animation-reverse.tsx new file mode 100644 index 0000000000..8c2974ae59 --- /dev/null +++ b/dev/examples/Animation-reverse.tsx @@ -0,0 +1,29 @@ +import { useAnimate } from "framer-motion" +import * as React from "react" + +export const App = () => { + const [scope, animate] = useAnimate() + + return ( +
+
+

reverse

+ +
+ ) +} diff --git a/dev/tests/animate-reverse.tsx b/dev/tests/animate-reverse.tsx new file mode 100644 index 0000000000..a29f1594da --- /dev/null +++ b/dev/tests/animate-reverse.tsx @@ -0,0 +1,53 @@ +import { motion, animate } from "framer-motion" +import * as React from "react" +import { useEffect, useState } from "react" +import styled from "styled-components" + +const Container = styled.section` + position: relative; + display: flex; + flex-direction: column; + padding: 100px; + + div { + width: 100px; + height: 100px; + background-color: red; + } +` + +export const App = () => { + const [count, setCount] = useState(0) + const [result, setResult] = useState("") + + useEffect(() => { + if (count % 2 === 0) return + + const output: number[] = [] + const controls = animate(0, 100, { + duration: 0.5, + ease: "linear", + onUpdate: (v: number) => output.push(v), + onComplete: () => + setResult( + output[1] === 100 && output.length !== 2 + ? "Success" + : "Fail" + ), + }) + controls.time = controls.duration + controls.speed = -1 + + return controls.stop + }, [count]) + + return ( + + + + + + ) +} diff --git a/packages/framer-motion/cypress/integration/animate-reverse.ts b/packages/framer-motion/cypress/integration/animate-reverse.ts new file mode 100644 index 0000000000..ff61c97b70 --- /dev/null +++ b/packages/framer-motion/cypress/integration/animate-reverse.ts @@ -0,0 +1,13 @@ +describe("animate() x layout prop in reverse speed", () => { + it("animate() plays as expected when layout prop is present", () => { + cy.visit("?test=animate-reverse") + .wait(1000) + .get("#action") + .trigger("click", 1, 1, { force: true }) + .wait(600) + .get("#result") + .should(([$element]: any) => { + expect($element.value).to.equal("Success") + }) + }) +}) diff --git a/packages/framer-motion/src/animation/animators/js/__tests__/animate.test.ts b/packages/framer-motion/src/animation/animators/js/__tests__/animate.test.ts index b5737e6a09..7aa74bc67e 100644 --- a/packages/framer-motion/src/animation/animators/js/__tests__/animate.test.ts +++ b/packages/framer-motion/src/animation/animators/js/__tests__/animate.test.ts @@ -1232,6 +1232,45 @@ describe("animate", () => { expect(output).toEqual([0, 20, 40, 20, 0]) }) + test("Reverse animation from the end", async () => { + const output: number[] = [] + + const animation = animateValue({ + keyframes: [0, 100], + driver: syncDriver(20), + duration: 100, + ease: linear, + onUpdate: (v) => { + output.push(v) + }, + }) + animation.time = 100 + animation.speed = -1 + + await animation + + expect(output).toEqual([100, 80, 60, 40, 20, 0]) + }) + + test("Reverse animation from the end with half speed", async () => { + const output: number[] = [] + + const animation = animateValue({ + keyframes: [0, 100], + driver: syncDriver(20), + duration: 100, + ease: linear, + onUpdate: (v) => { + output.push(v) + }, + }) + animation.time = 100 + animation.speed = -0.5 + + await animation + expect(output).toEqual([100, 90, 80, 70, 60, 50, 40, 30, 20, 10, 0]) + }) + test("Correctly ends animations with duration: 0", async () => { const animation = animateValue({ keyframes: [0, 100], diff --git a/packages/framer-motion/src/animation/animators/js/index.ts b/packages/framer-motion/src/animation/animators/js/index.ts index 1ca5e6878a..c30b3e2c96 100644 --- a/packages/framer-motion/src/animation/animators/js/index.ts +++ b/packages/framer-motion/src/animation/animators/js/index.ts @@ -143,16 +143,22 @@ export function animateValue({ * a pending operation that gets resolved here. */ if (speed > 0) startTime = Math.min(startTime, timestamp) + if (speed < 0) + startTime = Math.min(timestamp - totalDuration / speed, startTime) if (holdTime !== null) { currentTime = holdTime } else { - currentTime = (timestamp - startTime) * speed + // Rounding the time because floating point arithmetic is not always accurate, e.g. 3000.367 - 1000.367 = + // 2000.0000000000002. This is a problem when we are comparing the currentTime with the duration, for + // example. + currentTime = Math.round(timestamp - startTime) * speed } // Rebase on delay - const timeWithoutDelay = currentTime - delay - const isInDelayPhase = timeWithoutDelay < 0 + const timeWithoutDelay = currentTime - delay * (speed >= 0 ? 1 : -1) + const isInDelayPhase = + speed >= 0 ? timeWithoutDelay < 0 : timeWithoutDelay > totalDuration currentTime = Math.max(timeWithoutDelay, 0) /** @@ -240,14 +246,12 @@ export function animateValue({ let { done } = state if (!isInDelayPhase && calculatedDuration !== null) { - done = currentTime >= totalDuration + done = speed >= 0 ? currentTime >= totalDuration : currentTime <= 0 } const isAnimationFinished = holdTime === null && - (playState === "finished" || - (playState === "running" && done) || - (speed < 0 && currentTime <= 0)) + (playState === "finished" || (playState === "running" && done)) if (onUpdate) { onUpdate(state.value)