Last active
September 7, 2024 04:59
-
-
Save trusktr/7fea2a77e7e1959cb14790b6e0087c4e to your computer and use it in GitHub Desktop.
signals and effects for animation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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) | |
} | |
}) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Promises:
Signals and effects:
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.catch
es ortry-catch
blocks for multiple primitives...)