Skip to content

Commit

Permalink
feat: implement animation controls and enhance animation features
Browse files Browse the repository at this point in the history
  • Loading branch information
rick-hup committed Jan 21, 2025
1 parent 020f506 commit 781a2b8
Show file tree
Hide file tree
Showing 20 changed files with 545 additions and 215 deletions.
4 changes: 0 additions & 4 deletions docs/components/content/LoadingPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,9 @@ const floatingItems = [
<div class="relative pt-32 pb-20 sm:pt-40 sm:pb-24">
<!-- Main Content -->
<motion.div
v-bind="slideUp"
initial="hidden"
in-view="visible"
:transition="{
type: 'spring',
stiffness: 260,
damping: 50,
staggerChildren: 0.2,
}"
class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"
Expand Down
105 changes: 105 additions & 0 deletions packages/motion/src/animation/hooks/animation-controls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type { AnimationControls } from '@/animation/types'
import { type MotionState, mountedStates } from '@/state'
import type { Options } from '@/types'
import { invariant } from 'hey-listen'
import { setTarget } from 'framer-motion/dist/es/render/utils/setters.mjs'
import type { VisualElement } from 'framer-motion'

function stopAnimation(visualElement: VisualElement) {
visualElement.values.forEach(value => value.stop())
}

/**
* @public
*/
export function animationControls(): AnimationControls {
/**
* Track whether the host component has mounted.
*/
let hasMounted = false

/**
* A collection of linked component animation controls.
*/
const subscribers = new Set<MotionState>()

const controls: AnimationControls = {
subscribe(state) {
subscribers.add(state)
return () => void subscribers.delete(state)
},

start(definition, transitionOverride) {
invariant(
hasMounted,
'controls.start() should only be called after a component has mounted. Consider calling within a useEffect hook.',
)

const animations: Array<Promise<any>> = []
subscribers.forEach((state) => {
animations.push(
state.animateUpdates({
directAnimate: definition,
directTransition: transitionOverride,
}) as Promise<any>,
)
})

return Promise.all(animations)
},

set(definition) {
invariant(
hasMounted,
'controls.set() should only be called after a component has mounted. Consider calling within a useEffect hook.',
)
return subscribers.forEach((state) => {
setValues(state, definition)
})
},

stop() {
subscribers.forEach((state) => {
stopAnimation(state.visualElement)
})
},

mount() {
hasMounted = true

return () => {
hasMounted = false
controls.stop()
}
},
}

return controls
}

export function setValues(
state: MotionState,
definition: Options['animate'],
) {
if (typeof definition === 'string') {
return setVariants(state, [definition])
}
else {
setTarget(state.visualElement, definition as any)
}
}

function setVariants(state: MotionState, variantLabels: string[]) {
const reversedLabels = [...variantLabels].reverse()
const visualElement = state.visualElement
reversedLabels.forEach((key) => {
const variant = visualElement.getVariant(key)
variant && setTarget(visualElement, variant)

if (visualElement.variantChildren) {
visualElement.variantChildren.forEach((child) => {
setVariants(mountedStates.get(child.current as HTMLElement), variantLabels)
})
}
})
}
46 changes: 46 additions & 0 deletions packages/motion/src/animation/hooks/use-animation-controls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { animationControls } from '@/animation/hooks/animation-controls'
import { onMounted, onUnmounted } from 'vue'

/**
* Creates `AnimationControls`, which can be used to manually start, stop
* and sequence animations on one or more components.
*
* The returned `AnimationControls` should be passed to the `animate` property
* of the components you want to animate.
*
* These components can then be animated with the `start` method.
*
* ```jsx
* import { motion, useAnimationControls } from 'motion-v'
*
* export default defineComponent({
* setup() {
* const controls = useAnimationControls()
*
* controls.start({
* x: 100,
* transition: { duration: 0.5 },
* })
*
* return () => (
* <motion.div animate={controls} />
* )
* }
* })
* ```
*
* @returns Animation controller with `start`, `stop`, `set` and `mount` methods
*
* @public
*/
export function useAnimationControls() {
const controls = animationControls()
let unmount: () => void
onMounted(() => {
unmount = controls.mount()
})
onUnmounted(() => {
unmount()
})
return controls
}
3 changes: 2 additions & 1 deletion packages/motion/src/animation/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './use-animate'
export * from './hooks/use-animate'
export * from './hooks/use-animation-controls'
85 changes: 73 additions & 12 deletions packages/motion/src/animation/types.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,74 @@
// import type { AnimationControls as FramerAnimationControls, VisualElement } from 'framer-motion'
import type { MotionState } from '@/state'
import type { $Transition, Options } from '@/types'
/**
* @public
*/
export interface AnimationControls {
/**
* Subscribes a component's animation controls to this.
*
* @param controls - The controls to subscribe
* @returns An unsubscribe function.
*
* @internal
*/
subscribe: (state: MotionState) => () => void

// export interface AnimationControls extends FramerAnimationControls {
// /**
// * Subscribes a component's animation controls to this.
// *
// * @param controls - The controls to subscribe
// * @returns An unsubscribe function.
// *
// * @internal
// */
// subscribe: (visualElement: VisualElement) => () => void
// }
/**
* Starts an animation on all linked components.
*
* @remarks
*
* ```jsx
* controls.start("variantLabel")
* controls.start({
* x: 0,
* transition: { duration: 1 }
* })
* ```
*
* @param definition - Properties or variant label to animate to
* @param transition - Optional `transtion` to apply to a variant
* @returns - A `Promise` that resolves when all animations have completed.
*
* @public
*/
start: (
definition: Options['animate'],
transitionOverride?: $Transition
) => Promise<any>

/**
* Instantly set to a set of properties or a variant.
*
* ```jsx
* // With properties
* controls.set({ opacity: 0 })
*
* // With variants
* controls.set("hidden")
* ```
*
* @privateRemarks
* We could perform a similar trick to `.start` where this can be called before mount
* and we maintain a list of of pending actions that get applied on mount. But the
* expectation of `set` is that it happens synchronously and this would be difficult
* to do before any children have even attached themselves. It's also poor practise
* and we should discourage render-synchronous `.start` calls rather than lean into this.
*
* @public
*/
set: (definition: Options['animate']) => void

/**
* Stops animations on all linked components.
*
* ```jsx
* controls.stop()
* ```
*
* @public
*/
stop: () => void
mount: () => () => void
}
9 changes: 9 additions & 0 deletions packages/motion/src/animation/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { AnimationControls } from './types'

export function isAnimationControls(v?: unknown): v is AnimationControls {
return (
v !== null
&& typeof v === 'object'
&& typeof (v as AnimationControls).start === 'function'
)
}
36 changes: 36 additions & 0 deletions packages/motion/src/features/animation/animation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { isAnimationControls } from '@/animation/utils'
import { Feature } from '@/features/feature'
import type { MotionState } from '@/state'

export class AnimationFeature extends Feature {
unmountControls?: () => void
constructor(state: MotionState) {
super(state)
}

updateAnimationControlsSubscription() {
const { animate } = this.state.options
if (isAnimationControls(animate)) {
this.unmountControls = animate.subscribe(this.state)
}
}

/**
* Subscribe any provided AnimationControls to the component's VisualElement
*/
mount() {
this.updateAnimationControlsSubscription()
}

update() {
const { animate } = this.state.options
const { animate: prevAnimate } = this.state.visualElement.prevProps || {}
if (animate !== prevAnimate) {
this.updateAnimationControlsSubscription()
}
}

unmount() {
this.unmountControls?.()
}
}
3 changes: 2 additions & 1 deletion packages/motion/src/features/feature-manager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DragGesture, type Feature, HoverGesture, InViewGesture, LayoutFeature, PanGesture, PressGesture, SVGFeature } from '@/features'
import { AnimationFeature, DragGesture, type Feature, HoverGesture, InViewGesture, LayoutFeature, PanGesture, PressGesture, SVGFeature } from '@/features'
import type { MotionState } from '@/state'
import { ProjectionFeature } from './layout/projection'
import { FocusGesture } from '@/features/gestures/focus'
Expand All @@ -17,6 +17,7 @@ export class FeatureManager {
new PanGesture(state),
new DragGesture(state),
new FocusGesture(state),
new AnimationFeature(state),
]
}

Expand Down
1 change: 1 addition & 0 deletions packages/motion/src/features/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './svg'
export * from './layout/layout'
export * from './gestures/pan'
export * from './feature-manager'
export * from './animation/animation'
6 changes: 6 additions & 0 deletions packages/motion/src/framer-motion.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,9 @@ declare module 'framer-motion/dist/es/animation/interfaces/motion-value.mjs' {
isHandoff?: boolean
) => StartAnimation
}

declare module 'framer-motion/dist/es/render/utils/setters.mjs' {
import type { VisualElement } from 'framer-motion'

export const setTarget: (visualElement: VisualElement, definition: any) => void
}
Loading

0 comments on commit 781a2b8

Please sign in to comment.