Skip to content

Instantly share code, notes, and snippets.

@trusktr
Last active September 7, 2024 04:59
Show Gist options
  • Save trusktr/7fea2a77e7e1959cb14790b6e0087c4e to your computer and use it in GitHub Desktop.
Save trusktr/7fea2a77e7e1959cb14790b6e0087c4e to your computer and use it in GitHub Desktop.
signals and effects for animation
// this example is app-specific, in an app I'm making, so it expects certain types of elements
function fadeCardsOut(cards: FlexItem[]) {
const staggerTime = 500
const stagger = staggerTime / cards.length
const dones: (() => boolean)[] = []
for (const [i, card] of cards.entries()) {
const el = card.children[0] as TiltCard
if (el.tagName !== 'LUME-TILT-CARD') continue
const rect = el.shadowRoot?.querySelector('lume-rounded-rectangle') as RoundedRectangle
const [staggerDelayDone, setStaggerDelayDone] = createSignal(false)
createEffect(() => {
const timeout = setTimeout(() => setStaggerDelayDone(true), i * stagger)
onCleanup(() => clearTimeout(timeout))
})
let [fadeDone, setFadeDone] = createSignal(false)
createEffect(() => {
if (!staggerDelayDone()) return
rect.castShadow = false
const _fadeDone = fadeCard(el, 0, -20)
createEffect(() => setFadeDone(_fadeDone()))
})
// Add a small delay after cards are faded out (f.e. before fading in project content).
const [postDelayDone, setPostDelayDone] = createSignal(false)
createEffect(() => {
if (!fadeDone()) return
const timeout = setTimeout(() => setPostDelayDone(true), 150)
onCleanup(() => clearTimeout(timeout))
})
dones.push(postDelayDone)
}
const allDone = createMemo(() => dones.every(done => !!done()))
return allDone
}
function fadeCardsIn(cards: FlexItem[]) {
const staggerTime = 500
const stagger = staggerTime / cards.length
const dones: (() => boolean)[] = []
for (const [i, card] of cards.entries()) {
const el = card.children[0] as TiltCard
if (el.tagName === 'LUME-GLTF-MODEL') continue
const rect = el.shadowRoot?.querySelector('lume-rounded-rectangle') as RoundedRectangle
const fadeDone = fadeCard(el, 1, 0, i * stagger)
createEffect(() => fadeDone() && (rect.castShadow = true))
dones.push(fadeDone)
}
const allDone = createMemo(() => dones.every(done => !!done()))
return allDone
}
// this is also app-specific, the individual logic for fading a card
function fadeCard(element: TiltCard | (() => TiltCard), targetOpacity: number, targetZ: number, delay = 0) {
const elMemo = createMemo(() => (typeof element === 'function' ? element() : element))
const [allDone, setAllDone] = createSignal(false)
const duration = 500
createEffect(() => {
const el = elMemo()
if (!el) return setAllDone(false)
const rect = el.shadowRoot?.querySelector('lume-rounded-rectangle') as RoundedRectangle
const name = el.querySelector('.name') as Element3D
const parts = [rect, name] as const
const dones: Array<() => boolean> = []
for (const el of parts) {
const opacity = () => el.opacity
const setOpacity = (v: number) => (el.opacity = v)
const _fadeDone = animateValue(opacity, setOpacity, targetOpacity, {delay, duration, curve: Easing.Cubic.In})
dones.push(_fadeDone)
const position = () => el.position.z
const setPosition = (v: number) => (el.position.z = v)
const _translateDone = animateValue(position, setPosition, targetZ, {delay, duration})
dones.push(_translateDone)
}
createEffect(() => setAllDone(dones.every(done => done())))
})
return createMemo(() => allDone())
}
////// this is more of a library, used above //////////////////////////////
import {Easing} from '@tweenjs/tween.js'
import {createEffect, createSignal, onCleanup, untrack} from 'solid-js'
export function animateValue<T extends number>(
getValue: () => T,
setValue: (v: T) => {},
targetValue: T,
{
delay = 0,
duration = 1000,
curve = Easing.Cubic.InOut,
}: {
/** Amount to delay before animating. */
delay?: number
/** Duration of the animation in milliseconds. */
duration?: number
/**
* The easing curve to use. The function
* accepts a value between 0 and 1 indicating start to finish time,
* and returns a value between 0 and 1 indicating start to finish
* position. You can pass any Tween.js Easing curve here, for
* example. Defaults to Tween.js Easing.Cubic.InOut
*/
curve?: (amount: number) => number
} = {},
) {
const [done, setDone] = createSignal(false)
const startValue = untrack(getValue)
createEffect(() => {
if (untrack(getValue) === targetValue) return setDone(true)
let frame = 0
const timeout = setTimeout(() => {
const start = performance.now()
frame = requestAnimationFrame(function loop(time) {
let val = getValue()
const elapsed = time - start
const elapsedPortion = elapsed / duration
const amount = curve(elapsedPortion > 1 ? 1 : elapsedPortion)
const valuePortion = amount * (targetValue - startValue)
val = (startValue + valuePortion) as T
setValue(val)
if (val === targetValue) return setDone(true)
frame = requestAnimationFrame(loop)
})
}, delay)
onCleanup(() => {
clearTimeout(timeout)
cancelAnimationFrame(frame)
setDone(false)
})
})
return done
}
///////// Usage /////////////////////////////
// Based on some other logic, f.e. the current app route:
createEffect(() => {
if (transitionCardsOut()) {
const done = fadeCardsOut(cards())
createEffect(() => done() && (setShowCards(false), scroller()!.scroll(0, 0)))
} else {
fadeCardsIn(cards())
setShowCards(true)
scroller()!.scroll(0, 0)
}
})
@trusktr
Copy link
Author

trusktr commented Sep 7, 2024

Promises:

function delay(duration = 0) {
	return new Promise(resolve => setTimeout(resolve, duration))
}

Signals and effects:

function delay(duration = 0) {
	const [done, setDone] = createSignal(false)

	createEffect(() => {
		const timeout = setTimeout(() => setDone(true), duration)
		onCleanup(() => clearTimeout(timeout))
	})

	return done
}

Abstracting with signals and effects for async code can seem a little more verbose at first, but the benefits are much bigger as more and more features are added:

automatic robustness

Notice how the signal version cleans up simply.

Now how would you make the promise-based one clean up (i.e. be cancellable)? And then once you have multiple primitives besides delay, how do you compose them and keep them robust with promises? It's gonna get messier faster with promises (just imagine wiring up the .catches or try-catch blocks for multiple primitives...)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment