Skip to content

Fixes #3320 (UPDATED) — when a draggable element is inside a rotated parent, its drag direction is incorrect #3331

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

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
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
145 changes: 134 additions & 11 deletions packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export class VisualElementDragControls {
private currentDirection: DragDirection | null = null

private originPoint: Point = { x: 0, y: 0 }
private inverseMatrix: number[][]

/**
* The permitted boundaries of travel, in pixels.
Expand Down Expand Up @@ -309,25 +310,138 @@ export class VisualElementDragControls {
animationState && animationState.setActive("whileDrag", false)
}

private calculateInvertedPoint(
inverseMatrix: number[][],
xCoordinate: number,
yCoordinate: number
): Point {
const invertedPoint: Point = { x: 0, y: 0 }

if (!inverseMatrix) {
return invertedPoint
}

invertedPoint["x"] =
inverseMatrix[0][0] * xCoordinate +
inverseMatrix[0][1] * yCoordinate
invertedPoint["y"] =
inverseMatrix[1][0] * xCoordinate +
inverseMatrix[1][1] * yCoordinate

return invertedPoint
}

private calculateInverseMatrix(element: HTMLElement): number[][] {
const inverseMatrix: number[][] = [
[0, 0],
[0, 0],
]

if (!element) return inverseMatrix
const computed = getComputedStyle(element)

const matrix = computed.transform.match(
/matrix\(\s*([-\d.]+),\s*([-\d.]+),\s*([-\d.]+),\s*([-\d.]+)/
)
if (!matrix) return inverseMatrix

//Inverting a matrix
const a: number = parseFloat(matrix[1])
const b: number = parseFloat(matrix[2])
const c: number = parseFloat(matrix[3])
const d: number = parseFloat(matrix[4])

const determinant: number = 1 / (a * d - b * c)

inverseMatrix[0][0] = determinant * d
inverseMatrix[0][1] = determinant * -c
inverseMatrix[1][0] = determinant * -b
inverseMatrix[1][1] = determinant * a

return inverseMatrix
}

private checkForRotatedParent(element: HTMLElement): HTMLElement | null {
while (element.parentElement) {
element = element.parentElement
const computed = getComputedStyle(element)

if (computed.transform && computed.transform !== "none") {
const epsilon = 0.001

const matrix = computed.transform.match(
/matrix\(\s*([-\d.]+),\s*([-\d.]+),\s*([-\d.]+),\s*([-\d.]+)/
)

if (!matrix) break

const a: number = parseFloat(matrix[1])
const b: number = parseFloat(matrix[2])

const hasRotation =
Math.abs(a - 1) > epsilon || Math.abs(b) > epsilon

if (hasRotation) {
return element
}
}
}

return null
}

private updateAxis(axis: DragDirection, _point: Point, offset?: Point) {
const { drag } = this.getProps()

// If we're not dragging this axis, do an early return.
if (!offset || !shouldDrag(axis, drag, this.currentDirection)) return
if (!this.visualElement.current) return

const axisValue = this.getAxisMotionValue(axis)
let next = this.originPoint[axis] + offset[axis]

// Apply constraints
if (this.constraints && this.constraints[axis]) {
next = applyConstraints(
next,
this.constraints[axis],
this.elastic[axis]

const rotatedDiv = this.checkForRotatedParent(
this.visualElement.current
)

if (rotatedDiv) {
if (this.inverseMatrix === undefined) {
this.inverseMatrix = this.calculateInverseMatrix(rotatedDiv)
}

const invertedOffset = this.calculateInvertedPoint(
this.inverseMatrix,
offset["x"],
offset["y"]
)
}
const newCoordinates: Point = {
x: invertedOffset["x"] + this.originPoint["x"],
y: invertedOffset["y"] + this.originPoint["y"],
}

//apply constraints
if (this.constraints && this.constraints[axis]) {
newCoordinates[axis] = applyConstraints(
newCoordinates[axis],
this.constraints[axis],
this.elastic[axis]
)
}

axisValue.set(next)
axisValue.set(newCoordinates[axis])
} else {
let next = this.originPoint[axis] + offset[axis]

// Apply constraints
if (this.constraints && this.constraints[axis]) {
next = applyConstraints(
next,
this.constraints[axis],
this.elastic[axis]
)
}

axisValue.set(next)
}
}

private resolveConstraints() {
Expand Down Expand Up @@ -460,9 +574,18 @@ export class VisualElementDragControls {
const bounceStiffness = dragElastic ? 200 : 1000000
const bounceDamping = dragElastic ? 40 : 10000000

let velocityProper = velocity[axis]
if (this.inverseMatrix) {
velocityProper = this.calculateInvertedPoint(
this.inverseMatrix,
velocity["x"],
velocity["y"]
)[axis]
}

const inertia: Transition = {
type: "inertia",
velocity: dragMomentum ? velocity[axis] : 0,
velocity: dragMomentum ? velocityProper : 0,
bounceStiffness,
bounceDamping,
timeConstant: 750,
Expand Down
90 changes: 90 additions & 0 deletions packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,96 @@ describe("dragging", () => {
pointer.end()
})

test("draggable element moves correctly in the X direction when inside a rotated parent", async () => {
const Component = (): JSX.Element => (
<MockDrag>
<motion.div style={{ transform: "rotate(180deg)" }}>
<motion.div
data-testid="draggable"
drag="x"
dragTransition={{
bounceStiffness: 100000,
bounceDamping: 100000,
}}
style={{
width: 100,
height: 100,
background: "red",
}}
/>
</motion.div>
</MockDrag>
)

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

const draggable = getByTestId("draggable")
const dragTarget = container.firstChild?.firstChild as HTMLElement

const pointer = await drag(dragTarget).to(100, 0)
await nextFrame()

const transform = draggable.style.transform
const translateXMatch = transform.match(/translateX\(\s*([\d.-]+)px\)/)

expect(translateXMatch).not.toBeNull()
expect(translateXMatch).toBeTruthy()

const translateXValue = parseFloat(translateXMatch![1])
expect(translateXValue).not.toBeNaN()
expect(translateXValue).toBeCloseTo(100, 1)

expect(transform).not.toMatch(/translateY/)

pointer.end()
})

test("draggable element moves correctly in the Y direction when inside a rotated parent", async () => {
const Component = (): JSX.Element => (
<MockDrag>
<motion.div style={{ transform: "rotate(180deg)" }}>
<motion.div
data-testid="draggable"
drag="y"
dragTransition={{
bounceStiffness: 100000,
bounceDamping: 100000,
}}
style={{
width: 100,
height: 100,
background: "red",
}}
/>
</motion.div>
</MockDrag>
)

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

const draggable = getByTestId("draggable")
const dragTarget = container.firstChild?.firstChild as HTMLElement

const pointer = await drag(dragTarget).to(0, 100)
await nextFrame()

const transform = draggable.style.transform
const translateYMatch = transform.match(/translateY\(\s*([\d.-]+)px\)/)

expect(translateYMatch).not.toBeNull()
expect(translateYMatch).toBeTruthy()

const translateYValue = parseFloat(translateYMatch![1])
expect(translateYValue).not.toBeNaN()
expect(translateYValue).toBeCloseTo(100, 1)

expect(transform).not.toMatch(/translateX/)

pointer.end()
})

test("willChange is applied correctly when other values are animating", async () => {
const Component = () => {
const willChange = useWillChange()
Expand Down