framer-motion
Version:
An open source and production-ready motion library for React on the web
717 lines (604 loc) • 24.2 kB
text/typescript
import { RefObject } from "react"
import { invariant } from "hey-listen"
import { progress } from "popmotion"
import { PanSession, AnyPointerEvent, PanInfo } from "../../gestures/PanSession"
import { DraggableProps, DragHandler } from "./types"
import { Lock, getGlobalLock } from "./utils/lock"
import { isRefObject } from "../../utils/is-ref-object"
import { addPointerEvent } from "../../events/use-pointer-event"
import { addDomEvent } from "../../events/use-dom-event"
import { getViewportPointFromEvent } from "../../events/event-info"
import { TransformPoint2D, AxisBox2D, Point2D } from "../../types/geometry"
import {
convertBoundingBoxToAxisBox,
convertAxisBoxToBoundingBox,
} from "../../utils/geometry"
import { eachAxis } from "../../utils/each-axis"
import {
calcRelativeConstraints,
calcConstrainedMinPoint,
ResolvedConstraints,
calcViewportConstraints,
calcPositionFromProgress,
applyConstraints,
rebaseAxisConstraints,
} from "./utils/constraints"
import { getBoundingBox } from "../../render/dom/projection/measure"
import { calcOrigin } from "../../utils/geometry/delta-calc"
import { startAnimation } from "../../animation/utils/transitions"
import { Transition } from "../../types"
import { MotionProps } from "../../motion"
import { AnimationType } from "../../render/utils/animation-state"
import { VisualElement } from "../../render/types"
export const elementDragControls = new WeakMap<
VisualElement,
VisualElementDragControls
>()
interface DragControlConfig {
visualElement: VisualElement
}
export interface DragControlOptions {
snapToCursor?: boolean
cursorProgress?: Point2D
}
interface DragControlsProps extends DraggableProps {
transformPagePoint?: TransformPoint2D
}
type DragDirection = "x" | "y"
/**
*
*/
let lastPointerEvent: AnyPointerEvent
export class VisualElementDragControls {
/**
* Track whether we're currently dragging.
*
* @internal
*/
isDragging = false
/**
* The current direction of drag, or `null` if both.
*
* @internal
*/
private currentDirection: DragDirection | null = null
/**
* The permitted boundaries of travel, in pixels.
*
* @internal
*/
private constraints: ResolvedConstraints | false = false
/**
* A reference to the host component's latest props.
*
* @internal
*/
private props: DragControlsProps & MotionProps = {}
/**
* @internal
*/
private visualElement: VisualElement
/**
* @internal
*/
private hasMutatedConstraints: boolean = false
/**
* Track the initial position of the cursor relative to the dragging element
* when dragging starts as a value of 0-1 on each axis. We then use this to calculate
* an ideal bounding box for the VisualElement renderer to project into every frame.
*
* @internal
*/
cursorProgress: Point2D = {
x: 0.5,
y: 0.5,
}
// When updating _dragX, or _dragY instead of the VisualElement,
// persist their values between drag gestures.
private originPoint: {
x?: number
y?: number
} = {}
// This is a reference to the global drag gesture lock, ensuring only one component
// can "capture" the drag of one or both axes.
// TODO: Look into moving this into pansession?
private openGlobalLock: Lock | null = null
/**
* @internal
*/
private panSession: PanSession | null = null
/**
* A reference to the measured constraints bounding box
*/
private constraintsBox?: AxisBox2D
constructor({ visualElement }: DragControlConfig) {
this.visualElement = visualElement
this.visualElement.enableLayoutProjection()
elementDragControls.set(visualElement, this)
}
/**
* Instantiate a PanSession for the drag gesture
*
* @public
*/
start(
originEvent: AnyPointerEvent,
{ snapToCursor = false, cursorProgress }: DragControlOptions = {}
) {
/**
* If this drag session has been manually triggered by the user, it might be from an event
* outside the draggable element. If snapToCursor is set to true, we need to measure the position
* of the element and snap it to the cursor.
*/
snapToCursor && this.snapToCursor(originEvent)
const onSessionStart = () => {
// Stop any animations on both axis values immediately. This allows the user to throw and catch
// the component.
this.stopMotion()
}
const onStart = (event: AnyPointerEvent, info: PanInfo) => {
// Attempt to grab the global drag gesture lock - maybe make this part of PanSession
const { drag, dragPropagation } = this.props
if (drag && !dragPropagation) {
if (this.openGlobalLock) this.openGlobalLock()
this.openGlobalLock = getGlobalLock(drag)
// If we don 't have the lock, don't start dragging
if (!this.openGlobalLock) return
}
/**
* Record the progress of the mouse within the draggable element on each axis.
* onPan, we're going to use this to calculate a new bounding box for the element to
* project into. This will ensure that even if the DOM element moves via a relayout, it'll
* stick to the correct place under the pointer.
*/
this.prepareBoundingBox()
this.visualElement.lockProjectionTarget()
/**
* Resolve the drag constraints. These are either set as top/right/bottom/left constraints
* relative to the element's layout, or a ref to another element. Both need converting to
* viewport coordinates.
*/
this.resolveDragConstraints()
/**
* When dragging starts, we want to find where the cursor is relative to the bounding box
* of the element. Every frame, we calculate a new bounding box using this relative position
* and let the visualElement renderer figure out how to reproject the element into this bounding
* box.
*
* By doing it this way, rather than applying an x/y transform directly to the element,
* we can ensure the component always visually sticks to the cursor as we'd expect, even
* if the DOM element itself changes layout as a result of React updates the user might
* make based on the drag position.
*/
const { point } = getViewportPointFromEvent(event)
eachAxis((axis) => {
const { min, max } = this.visualElement.projection.target[axis]
this.cursorProgress[axis] = cursorProgress
? cursorProgress[axis]
: progress(min, max, point[axis])
/**
* If we have external drag MotionValues, record their origin point. On pointermove
* we'll apply the pan gesture offset directly to this value.
*/
const axisValue = this.getAxisMotionValue(axis)
if (axisValue) {
this.originPoint[axis] = axisValue.get()
}
})
// Set current drag status
this.isDragging = true
this.currentDirection = null
// Fire onDragStart event
this.props.onDragStart?.(event, info)
this.visualElement.animationState?.setActive(
AnimationType.Drag,
true
)
}
const onMove = (event: AnyPointerEvent, info: PanInfo) => {
const { dragPropagation, dragDirectionLock } = this.props
// If we didn't successfully receive the gesture lock, early return.
if (!dragPropagation && !this.openGlobalLock) return
const { offset } = info
// Attempt to detect drag direction if directionLock is true
if (dragDirectionLock && this.currentDirection === null) {
this.currentDirection = getCurrentDirection(offset)
// If we've successfully set a direction, notify listener
if (this.currentDirection !== null) {
this.props.onDirectionLock?.(this.currentDirection)
}
return
}
// Update each point with the latest position
this.updateAxis("x", event, offset)
this.updateAxis("y", event, offset)
// Fire onDrag event
this.props.onDrag?.(event, info)
// Update the last pointer event
lastPointerEvent = event
}
const onEnd: DragHandler = (event, info) => this.stop(event, info)
const { transformPagePoint } = this.props
this.panSession = new PanSession(
originEvent,
{
onSessionStart,
onStart,
onMove,
onEnd,
},
{ transformPagePoint }
)
}
/**
* Ensure the component's layout and target bounding boxes are up-to-date.
*/
prepareBoundingBox() {
const { visualElement } = this
visualElement.withoutTransform(() => {
visualElement.updateLayoutMeasurement()
})
visualElement.rebaseProjectionTarget(
true,
visualElement.measureViewportBox(false)
)
}
resolveDragConstraints() {
const { dragConstraints } = this.props
if (dragConstraints) {
this.constraints = isRefObject(dragConstraints)
? this.resolveRefConstraints(
this.visualElement.getLayoutState().layout,
dragConstraints
)
: calcRelativeConstraints(
this.visualElement.getLayoutState().layout,
dragConstraints
)
} else {
this.constraints = false
}
/**
* If we're outputting to external MotionValues, we want to rebase the measured constraints
* from viewport-relative to component-relative.
*/
if (this.constraints && !this.hasMutatedConstraints) {
eachAxis((axis) => {
if (this.getAxisMotionValue(axis)) {
this.constraints[axis] = rebaseAxisConstraints(
this.visualElement.getLayoutState().layout[axis],
this.constraints[axis]
)
}
})
}
}
resolveRefConstraints(
layoutBox: AxisBox2D,
constraints: RefObject<Element>
) {
const { onMeasureDragConstraints, transformPagePoint } = this.props
const constraintsElement = constraints.current as Element
invariant(
constraintsElement !== null,
"If `dragConstraints` is set as a React ref, that ref must be passed to another component's `ref` prop."
)
this.constraintsBox = getBoundingBox(
constraintsElement,
transformPagePoint
)
let measuredConstraints = calcViewportConstraints(
layoutBox,
this.constraintsBox
)
/**
* If there's an onMeasureDragConstraints listener we call it and
* if different constraints are returned, set constraints to that
*/
if (onMeasureDragConstraints) {
const userConstraints = onMeasureDragConstraints(
convertAxisBoxToBoundingBox(measuredConstraints)
)
this.hasMutatedConstraints = !!userConstraints
if (userConstraints) {
measuredConstraints = convertBoundingBoxToAxisBox(
userConstraints
)
}
}
return measuredConstraints
}
cancelDrag() {
this.isDragging = false
this.panSession && this.panSession.end()
this.panSession = null
if (!this.props.dragPropagation && this.openGlobalLock) {
this.openGlobalLock()
this.openGlobalLock = null
}
this.visualElement.animationState?.setActive(AnimationType.Drag, false)
}
stop(event: AnyPointerEvent, info: PanInfo) {
this.visualElement.unlockProjectionTarget()
this.panSession?.end()
this.panSession = null
const isDragging = this.isDragging
this.cancelDrag()
if (!isDragging) return
const { dragMomentum, dragElastic, onDragEnd } = this.props
if (dragMomentum || dragElastic) {
const { velocity } = info
this.animateDragEnd(velocity)
}
onDragEnd?.(event, info)
}
snapToCursor(event: AnyPointerEvent) {
this.prepareBoundingBox()
eachAxis((axis) => {
const { drag } = this.props
// If we're not dragging this axis, do an early return.
if (!shouldDrag(axis, drag, this.currentDirection)) return
const axisValue = this.getAxisMotionValue(axis)
if (axisValue) {
const { point } = getViewportPointFromEvent(event)
const box = this.visualElement.getLayoutState().layout
const length = box[axis].max - box[axis].min
const center = box[axis].min + length / 2
const offset = point[axis] - center
this.originPoint[axis] = point[axis]
axisValue.set(offset)
} else {
this.cursorProgress[axis] = 0.5
this.updateVisualElementAxis(axis, event)
}
})
}
/**
* Update the specified axis with the latest pointer information.
*/
updateAxis(axis: DragDirection, event: AnyPointerEvent, offset?: Point2D) {
const { drag } = this.props
// If we're not dragging this axis, do an early return.
if (!shouldDrag(axis, drag, this.currentDirection)) return
return this.getAxisMotionValue(axis)
? this.updateAxisMotionValue(axis, offset)
: this.updateVisualElementAxis(axis, event)
}
updateAxisMotionValue(axis: DragDirection, offset?: Point2D) {
const axisValue = this.getAxisMotionValue(axis)
if (!offset || !axisValue) return
const { dragElastic } = this.props
const nextValue = this.originPoint[axis]! + offset[axis]
const update = this.constraints
? applyConstraints(
nextValue,
this.constraints[axis],
dragElastic as number
)
: nextValue
axisValue.set(update)
}
updateVisualElementAxis(axis: DragDirection, event: AnyPointerEvent) {
const { dragElastic } = this.props
// Get the actual layout bounding box of the element
const axisLayout = this.visualElement.getLayoutState().layout[axis]
// Calculate its current length. In the future we might want to lerp this to animate
// between lengths if the layout changes as we change the DOM
const axisLength = axisLayout.max - axisLayout.min
// Get the initial progress that the pointer sat on this axis on gesture start.
const axisProgress = this.cursorProgress[axis]
const { point } = getViewportPointFromEvent(event)
// Calculate a new min point based on the latest pointer position, constraints and elastic
const min = calcConstrainedMinPoint(
point[axis],
axisLength,
axisProgress,
this.constraints?.[axis],
dragElastic as number
)
// Update the axis viewport target with this new min and the length
this.visualElement.setProjectionTargetAxis(axis, min, min + axisLength)
}
setProps({
drag = false,
dragDirectionLock = false,
dragPropagation = false,
dragConstraints = false,
dragElastic = 0.35,
dragMomentum = true,
...remainingProps
}: DragControlsProps & MotionProps) {
this.props = {
drag,
dragDirectionLock,
dragPropagation,
dragConstraints,
dragElastic,
dragMomentum,
...remainingProps,
}
}
/**
* Drag works differently depending on which props are provided.
*
* - If _dragX and _dragY are provided, we output the gesture delta directly to those motion values.
* - If the component will perform layout animations, we output the gesture to the component's
* visual bounding box
* - Otherwise, we apply the delta to the x/y motion values.
*/
private getAxisMotionValue(axis: DragDirection) {
const { layout, layoutId } = this.props
const dragKey = "_drag" + axis.toUpperCase()
if (this.props[dragKey]) {
return this.props[dragKey]
} else if (!layout && layoutId === undefined) {
return this.visualElement.getValue(axis, 0)
}
}
private animateDragEnd(velocity: Point2D) {
const { drag, dragMomentum, dragElastic, dragTransition } = this.props
const momentumAnimations = eachAxis((axis) => {
if (!shouldDrag(axis, drag, this.currentDirection)) {
return
}
const transition = this.constraints ? this.constraints[axis] : {}
/**
* Overdamp the boundary spring if `dragElastic` is disabled. There's still a frame
* of spring animations so we should look into adding a disable spring option to `inertia`.
* We could do something here where we affect the `bounceStiffness` and `bounceDamping`
* using the value of `dragElastic`.
*/
const bounceStiffness = dragElastic ? 200 : 1000000
const bounceDamping = dragElastic ? 40 : 10000000
const inertia = {
type: "inertia",
velocity: dragMomentum ? velocity[axis] : 0,
bounceStiffness,
bounceDamping,
timeConstant: 750,
restDelta: 1,
restSpeed: 10,
...dragTransition,
...transition,
}
// If we're not animating on an externally-provided `MotionValue` we can use the
// component's animation controls which will handle interactions with whileHover (etc),
// otherwise we just have to animate the `MotionValue` itself.
return this.getAxisMotionValue(axis)
? this.startAxisValueAnimation(axis, inertia)
: this.visualElement.startLayoutAnimation(axis, inertia)
})
// Run all animations and then resolve the new drag constraints.
return Promise.all(momentumAnimations).then(() => {
this.props.onDragTransitionEnd?.()
})
}
stopMotion() {
eachAxis((axis) => {
const axisValue = this.getAxisMotionValue(axis)
axisValue
? axisValue.stop()
: this.visualElement.stopLayoutAnimation()
})
}
private startAxisValueAnimation(axis: "x" | "y", transition: Transition) {
const axisValue = this.getAxisMotionValue(axis)
if (!axisValue) return
const currentValue = axisValue.get()
axisValue.set(currentValue)
axisValue.set(currentValue) // Set twice to hard-reset velocity
return startAnimation(axis, axisValue, 0, transition)
}
scalePoint() {
const { drag, dragConstraints } = this.props
if (!isRefObject(dragConstraints) || !this.constraintsBox) return
// Stop any current animations as there can be some visual glitching if we resize mid animation
this.stopMotion()
// Record the relative progress of the targetBox relative to the constraintsBox
const boxProgress = { x: 0, y: 0 }
eachAxis((axis) => {
boxProgress[axis] = calcOrigin(
this.visualElement.projection.target[axis],
this.constraintsBox![axis]
)
})
/**
* For each axis, calculate the current progress of the layout axis within the constraints.
* Then, using the latest layout and constraints measurements, reposition the new layout axis
* proportionally within the constraints.
*/
this.prepareBoundingBox()
this.resolveDragConstraints()
eachAxis((axis) => {
if (!shouldDrag(axis, drag, null)) return
// Calculate the position of the targetBox relative to the constraintsBox using the
// previously calculated progress
const { min, max } = calcPositionFromProgress(
this.visualElement.projection.target[axis],
this.constraintsBox![axis],
boxProgress[axis]
)
this.visualElement.setProjectionTargetAxis(axis, min, max)
})
}
mount(visualElement: VisualElement) {
const element = visualElement.getInstance()
/**
* Attach a pointerdown event listener on this DOM element to initiate drag tracking.
*/
const stopPointerListener = addPointerEvent(
element,
"pointerdown",
(event) => {
const { drag, dragListener = true } = this.props
drag && dragListener && this.start(event)
}
)
/**
* Attach a window resize listener to scale the draggable target within its defined
* constraints as the window resizes.
*/
const stopResizeListener = addDomEvent(window, "resize", () => {
this.scalePoint()
})
/**
* Ensure drag constraints are resolved correctly relative to the dragging element
* whenever its layout changes.
*/
const stopLayoutUpdateListener = visualElement.onLayoutUpdate(() => {
if (this.isDragging) this.resolveDragConstraints()
})
/**
* If the previous component with this same layoutId was dragging at the time
* it was unmounted, we want to continue the same gesture on this component.
*/
const { prevDragCursor } = visualElement
if (prevDragCursor) {
this.start(lastPointerEvent, { cursorProgress: prevDragCursor })
}
/**
* Return a function that will teardown the drag gesture
*/
return () => {
stopPointerListener?.()
stopResizeListener?.()
stopLayoutUpdateListener?.()
this.cancelDrag()
}
}
}
function shouldDrag(
direction: DragDirection,
drag: boolean | DragDirection | undefined,
currentDirection: null | DragDirection
) {
return (
(drag === true || drag === direction) &&
(currentDirection === null || currentDirection === direction)
)
}
/**
* Based on an x/y offset determine the current drag direction. If both axis' offsets are lower
* than the provided threshold, return `null`.
*
* @param offset - The x/y offset from origin.
* @param lockThreshold - (Optional) - the minimum absolute offset before we can determine a drag direction.
*/
function getCurrentDirection(
offset: Point2D,
lockThreshold = 10
): DragDirection | null {
let direction: DragDirection | null = null
if (Math.abs(offset.y) > lockThreshold) {
direction = "y"
} else if (Math.abs(offset.x) > lockThreshold) {
direction = "x"
}
return direction
}
export function expectsResolvedDragConstraints({
dragConstraints,
onMeasureDragConstraints,
}: MotionProps) {
return isRefObject(dragConstraints) && !!onMeasureDragConstraints
}