Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix for reverse animation with negative speed #2194

Merged
merged 1 commit into from
Jun 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions dev/examples/Animation-reverse.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useAnimate } from "framer-motion"
import * as React from "react"

export const App = () => {
const [scope, animate] = useAnimate()

return (
<div className="App" ref={scope}>
<div
className="four"
style={{ width: 50, height: 50, backgroundColor: "blue" }}
></div>
<p>reverse</p>
<button
onClick={() => {
const animation = animate(
".four",
{ x: 90 },
{ duration: 2 }
)
animation.time = animation.duration
animation.speed = -1
}}
>
play
</button>
</div>
)
}
53 changes: 53 additions & 0 deletions dev/tests/animate-reverse.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Container>
<button id="action" onClick={() => setCount((c) => c + 1)}>
Animate
</button>
<input id="result" readOnly value={result} />
<motion.div className="box" layout />
</Container>
)
}
13 changes: 13 additions & 0 deletions packages/framer-motion/cypress/integration/animate-reverse.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
18 changes: 11 additions & 7 deletions packages/framer-motion/src/animation/animators/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,16 +143,22 @@ export function animateValue<V = number>({
* 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)
Comment on lines +146 to +147
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bit is doing the same thing as the line above, to calibrate the startTime because timestamp can come in lower, but from the reverse direction.


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)

/**
Expand Down Expand Up @@ -240,14 +246,12 @@ export function animateValue<V = number>({
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)
Expand Down