From d3cfc8d334899dd69ff382b2e7bc4413a309f809 Mon Sep 17 00:00:00 2001 From: Caleb Eby Date: Tue, 27 Aug 2019 22:34:42 -0700 Subject: [PATCH 01/12] Springy 1 --- src/components/menu.tsx | 32 ++++- src/routes.ts | 4 + src/routes/event-team.tsx | 14 +++ src/routes/springy.tsx | 47 ++++++++ src/spring/use.tsx | 242 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 src/routes/springy.tsx create mode 100644 src/spring/use.tsx diff --git a/src/components/menu.tsx b/src/components/menu.tsx index ff6ecd3d9..0163adfae 100644 --- a/src/components/menu.tsx +++ b/src/components/menu.tsx @@ -14,6 +14,8 @@ import { getScrollbarWidth } from '@/utils/get-scrollbar-width' import { home } from '@/icons/home' import clsx from 'clsx' import { resolveUrl } from '@/utils/resolve-url' +import { initSpring, Animated } from '@/spring/use' +import { useState } from 'preact/hooks' const spacing = '0.3rem' @@ -29,7 +31,7 @@ const menuItemStyle = css` border-radius: 0.3rem; margin: ${spacing}; color: ${lighten(0.26, 'black')}; - transition: all 0.3s ease; + transition: box-shadow 0.3s ease; display: flex; align-items: center; font-weight: 500; @@ -70,7 +72,7 @@ const menuStyle = css` background: white; box-shadow: ${createShadow(16)}; z-index: 16; - transition: inherit; + /* transition: inherit; */ transition-timing-function: cubic-bezier(1, 0, 0.71, 0.88); display: flex; flex-direction: column; @@ -84,7 +86,6 @@ const menuStyle = css` } .${scrimHiddenClass} & { - transform: translateX(100%); box-shadow: none; } ` @@ -102,10 +103,16 @@ interface Props { export const Menu = ({ onHide, visible }: Props) => { const { jwt } = useJWT() const isAdmin = jwt && jwt.peregrineRoles.isAdmin + const spring = initSpring() return ( - + ) } + +const TextAnimated = () => { + const spring = initSpring() + const [counter, setCounter] = useState(0) + const increment = () => setCounter(c => c + 1) + const decrement = () => setCounter(c => c - 1) + + return ( +
+ + {spring(counter)} + +
+ ) +} diff --git a/src/routes.ts b/src/routes.ts index c7486bc08..7881de8b1 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -39,6 +39,10 @@ const routes = [ path: '/leaderboard', component: () => import('./routes/leaderboard'), }, + { + path: '/springy', + component: () => import('./routes/springy'), + }, ] export default routes diff --git a/src/routes/event-team.tsx b/src/routes/event-team.tsx index 9a3101840..ea916979a 100644 --- a/src/routes/event-team.tsx +++ b/src/routes/event-team.tsx @@ -15,6 +15,8 @@ import { useEventInfo } from '@/cache/event-info/use' import { usePromise } from '@/utils/use-promise' import { nextIncompleteMatch } from '@/utils/next-incomplete-match' import { useEventMatches } from '@/cache/event-matches/use' +import { useState, useEffect, useRef } from 'preact/hooks' +import { initSpring } from '@/spring/use' const sectionStyle = css` font-weight: normal; @@ -83,6 +85,7 @@ const EventTeam = ({ eventKey, teamNum }: Props) => { /> )} + {/* */} { } export default EventTeam + +const TestComponent = () => { + const [toggle, setToggle] = useState(false) + const position = initSpring(toggle ? -200 : 200) + return ( +
+

Hi

+ +
+ ) +} diff --git a/src/routes/springy.tsx b/src/routes/springy.tsx new file mode 100644 index 000000000..1eb0a3d62 --- /dev/null +++ b/src/routes/springy.tsx @@ -0,0 +1,47 @@ +import { h, FunctionComponent } from 'preact' +import { initSpring, Animated } from '@/spring/use' +import { useState } from 'preact/hooks' +import { css } from 'linaria' + +const wrapperStyle = css` + width: 100%; + height: 100vh; +` + +const width = 50 + +const circleStyle = css` + width: ${width}px; + height: ${width}px; + background: purple; + will-change: transform; + border-radius: 50%; +` + +const Springy: FunctionComponent = () => { + const spring = initSpring({ + friction: 0.007, + mass: 0.0019, + springStrength: 0.08, + }) + const [x, setX] = useState(0) + const [y, setY] = useState(0) + return ( +
{ + setX(e.x) + setY(e.y) + }} + > + +
+ ) +} + +export default Springy diff --git a/src/spring/use.tsx b/src/spring/use.tsx new file mode 100644 index 000000000..6f3f158d7 --- /dev/null +++ b/src/spring/use.tsx @@ -0,0 +1,242 @@ +import { JSX, FunctionComponent, h } from 'preact' +import { useRef, useEffect } from 'preact/hooks' + +const springed = Symbol('springed') + +interface SpringState { + lastTime: number + velocity: number + position: number +} + +interface DirectSpring { + [springed]: true + opts: Required + target: number + state?: SpringState +} + +interface ComposedSpring { + [springed]: true + subSprings: SubSprings + composeTweenedValues: (tweenedValues: { [key: string]: number }) => ResultType +} + +type Springed = DirectSpring | ComposedSpring + +interface SpringOpts { + mass?: number + springStrength?: number + friction?: number +} + +interface CreateSpring { + (strings: TemplateStringsArray, ...expressions: number[]): ComposedSpring< + string + > + (value: number): DirectSpring + (value: T): ComposedSpring +} + +interface SubSprings { + [key: string]: Springed +} + +const isTemplateStringsArray = (input: any): input is TemplateStringsArray => + Array.isArray(input) && Array.isArray((input as any).raw) + +export const initSpring = ({ + friction = 0.007, + mass = 0.0003, + springStrength = 0.08, +}: SpringOpts = {}) => { + const createSpring: CreateSpring = ( + firstArg: number | TemplateStringsArray | T, + ...expressions: number[] + ): any => { + if (typeof firstArg === 'number') { + const resultSpringed: DirectSpring = { + [springed]: true, + opts: { + friction, + mass, + springStrength, + }, + target: firstArg, + } + return resultSpringed + } + if (isTemplateStringsArray(firstArg)) { + const resultSpringed: ComposedSpring = { + [springed]: true, + subSprings: expressions.reduce((subSprings, expr, i) => { + subSprings[i] = createSpring(expr) + return subSprings + }, {}), + composeTweenedValues: tweenedValues => + firstArg.reduce( + (builtString, stringChunk, i) => + builtString + stringChunk + (tweenedValues[i] || ''), + '', + ), + } + return resultSpringed + } + const resultSpringed: ComposedSpring = { + [springed]: true, + subSprings: Object.entries(firstArg).reduce( + (subSprings, [key, value]) => { + if (isSpringed(value)) subSprings[key] = value + return subSprings + }, + {}, + ), + composeTweenedValues: tweenedValues => + Object.entries(firstArg).reduce( + (acc, [key, val]) => { + // @ts-ignore + acc[key] = isSpringed(val) ? tweenedValues[key] : firstArg[key] + return acc + }, + {} as T, + ), + } + return resultSpringed + } + return createSpring +} + +const getSpringFramePosition = (spring: DirectSpring) => { + const currentTime = new Date().getTime() + const state: SpringState = spring.state || { + position: spring.target, + lastTime: currentTime, + velocity: 0, + } + spring.state = state + const displacement = spring.target - state.position + const springForce = spring.opts.springStrength * displacement + // Friction isn't really proportional to velocity + // But this makes it "feel" right + // And it makes everything way easier to tune + const frictionalForce = spring.opts.friction * state.velocity + console.log(state.position) + const force = springForce - frictionalForce + // f = ma + const acceleration = force / spring.opts.mass + const dTime = (currentTime - state.lastTime) / 1000 + state.lastTime = currentTime + // v' = v + at + state.velocity += acceleration * dTime + // x' = x + vt + state.position += state.velocity * dTime + return state.position +} + +const isSpringed = (prop: any): prop is Springed => prop[springed] + +type AnimatifyProps = { + [Prop in keyof Props]: Props[Prop] | Springed> +} + +type AnimatedObject = { + [El in keyof JSX.IntrinsicElements]: FunctionComponent< + AnimatifyProps + > +} + +const isComposedSpring = ( + spring: Springed, +): spring is ComposedSpring => + (spring as ComposedSpring).subSprings !== undefined + +const isDirectSpring = (spring: Springed): spring is DirectSpring => + (spring as DirectSpring).target !== undefined + +const computeSpring = (spring: Springed): T => { + if (isComposedSpring(spring)) { + return spring.composeTweenedValues( + Object.entries(spring.subSprings).reduce( + (acc, [key, value]) => { + acc[key] = isSpringed(value) ? computeSpring(value) : value + return acc + }, + {} as { [key: string]: any }, + ), + ) + } + return (getSpringFramePosition(spring) as unknown) as T +} + +const copySpringState = ( + oldSpring: Springed, + newSpring: Springed, +) => { + if (isDirectSpring(oldSpring)) { + if (isDirectSpring(newSpring)) { + newSpring.state = oldSpring.state + } + } else if (isComposedSpring(newSpring)) { + const subSpringKeys = Object.keys(newSpring.subSprings) + for (const key of subSpringKeys) { + const oldSubSpring = oldSpring.subSprings[key] + const newSubSpring = newSpring.subSprings[key] + if (oldSubSpring) copySpringState(oldSubSpring, newSubSpring) + } + } +} + +// We need to make sure to return the same component between multiple renders +// so that state/refs are preserved between renders +// Otherwise a different component is returned and everything is reset +const animatedComponentCache: Partial = {} + +export const Animated = new Proxy({} as any, { + get: ( + _target, + El: keyof AnimatedObject, + ): AnimatedObject[keyof AnimatedObject] => { + const cachedEl = animatedComponentCache[El] + if (cachedEl) return cachedEl + const Component = ( + props: AnimatifyProps, + ) => { + const lastSpring = useRef | null>(null) + const staticProps = Object.fromEntries( + Object.entries(props).filter(([, val]) => !isSpringed(val)), + ) + const propsSpring = initSpring()( + Object.fromEntries( + Object.entries(props).filter(([, val]) => isSpringed(val)), + ), + ) + if (lastSpring.current) copySpringState(lastSpring.current, propsSpring) + lastSpring.current = propsSpring + const elementRef = useRef() + useEffect(() => { + const updateComponentSprings = () => { + const element = elementRef.current + const tweenedProps = computeSpring(propsSpring) + if (element) { + for (const key in tweenedProps.style) { + ;(element as HTMLElement).style[key] = tweenedProps.style[key] + } + } + timeoutId = setTimeout( + () => (rafId = requestAnimationFrame(updateComponentSprings)), + 1, + ) as any + } + let rafId = requestAnimationFrame(updateComponentSprings) + let timeoutId: number + return () => { + clearTimeout(timeoutId) + cancelAnimationFrame(rafId) + } + }, [propsSpring]) + return + } + animatedComponentCache[El] = Component + return Component + }, +}) From 13539a3628544d1051884bb91cec3b5c3590a2ff Mon Sep 17 00:00:00 2001 From: Caleb Eby Date: Tue, 27 Aug 2019 22:40:39 -0700 Subject: [PATCH 02/12] Use template literals and remove console.log --- src/routes/springy.tsx | 6 +++--- src/spring/use.tsx | 9 ++++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/routes/springy.tsx b/src/routes/springy.tsx index 1eb0a3d62..42091a981 100644 --- a/src/routes/springy.tsx +++ b/src/routes/springy.tsx @@ -26,6 +26,8 @@ const Springy: FunctionComponent = () => { }) const [x, setX] = useState(0) const [y, setY] = useState(0) + const offsetX = x - width / 2 + const offsetY = y - width / 2 return (
{ >
) diff --git a/src/spring/use.tsx b/src/spring/use.tsx index 6f3f158d7..809339c84 100644 --- a/src/spring/use.tsx +++ b/src/spring/use.tsx @@ -120,7 +120,6 @@ const getSpringFramePosition = (spring: DirectSpring) => { // But this makes it "feel" right // And it makes everything way easier to tune const frictionalForce = spring.opts.friction * state.velocity - console.log(state.position) const force = springForce - frictionalForce // f = ma const acceleration = force / spring.opts.mass @@ -218,8 +217,12 @@ export const Animated = new Proxy({} as any, { const element = elementRef.current const tweenedProps = computeSpring(propsSpring) if (element) { - for (const key in tweenedProps.style) { - ;(element as HTMLElement).style[key] = tweenedProps.style[key] + if (typeof tweenedProps.style === 'string') { + ;(element as HTMLElement).style.cssText = tweenedProps.style + } else { + for (const key in tweenedProps.style) { + ;(element as HTMLElement).style[key] = tweenedProps.style[key] + } } } timeoutId = setTimeout( From 24739802e8ac5447565998dc0b77025d2509b46f Mon Sep 17 00:00:00 2001 From: Caleb Eby Date: Tue, 27 Aug 2019 22:42:47 -0700 Subject: [PATCH 03/12] Set overflow hidden --- src/routes/springy.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/routes/springy.tsx b/src/routes/springy.tsx index 42091a981..fac528e74 100644 --- a/src/routes/springy.tsx +++ b/src/routes/springy.tsx @@ -6,6 +6,7 @@ import { css } from 'linaria' const wrapperStyle = css` width: 100%; height: 100vh; + overflow: hidden; ` const width = 50 @@ -39,7 +40,7 @@ const Springy: FunctionComponent = () => { + /> ) } From 2bcc2d6b242d055fc581c4515a85bf284ff5a059 Mon Sep 17 00:00:00 2001 From: Caleb Eby Date: Tue, 27 Aug 2019 23:02:37 -0700 Subject: [PATCH 04/12] Add transitioney --- src/routes.ts | 4 ++++ src/routes/springy.tsx | 4 ++-- src/routes/transitioney.tsx | 43 +++++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 src/routes/transitioney.tsx diff --git a/src/routes.ts b/src/routes.ts index 7881de8b1..76ed764fb 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -43,6 +43,10 @@ const routes = [ path: '/springy', component: () => import('./routes/springy'), }, + { + path: '/transitioney', + component: () => import('./routes/transitioney'), + }, ] export default routes diff --git a/src/routes/springy.tsx b/src/routes/springy.tsx index fac528e74..e1d63a7b4 100644 --- a/src/routes/springy.tsx +++ b/src/routes/springy.tsx @@ -22,8 +22,8 @@ const circleStyle = css` const Springy: FunctionComponent = () => { const spring = initSpring({ friction: 0.007, - mass: 0.0019, - springStrength: 0.08, + mass: 0.0023, + springStrength: 0.02, }) const [x, setX] = useState(0) const [y, setY] = useState(0) diff --git a/src/routes/transitioney.tsx b/src/routes/transitioney.tsx new file mode 100644 index 000000000..7bca99e22 --- /dev/null +++ b/src/routes/transitioney.tsx @@ -0,0 +1,43 @@ +import { h, FunctionComponent } from 'preact' +import { useState } from 'preact/hooks' +import { css } from 'linaria' + +const wrapperStyle = css` + width: 100%; + height: 100vh; + overflow: hidden; +` + +const width = 50 + +const circleStyle = css` + width: ${width}px; + height: ${width}px; + background: purple; + will-change: transform; + border-radius: 50%; + transition: all 0.8s cubic-bezier(0.28, 0.04, 0.6, 1.21); +` + +const Springy: FunctionComponent = () => { + const [x, setX] = useState(0) + const [y, setY] = useState(0) + const offsetX = x - width / 2 + const offsetY = y - width / 2 + return ( +
{ + setX(e.x) + setY(e.y) + }} + > +
+
+ ) +} + +export default Springy From 5274522e918837d97f5e16a18dbe6bc6bbfb8197 Mon Sep 17 00:00:00 2001 From: Caleb Eby Date: Wed, 28 Aug 2019 00:15:43 -0700 Subject: [PATCH 05/12] Use date.now --- src/spring/use.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spring/use.tsx b/src/spring/use.tsx index 809339c84..f466aa306 100644 --- a/src/spring/use.tsx +++ b/src/spring/use.tsx @@ -107,7 +107,7 @@ export const initSpring = ({ } const getSpringFramePosition = (spring: DirectSpring) => { - const currentTime = new Date().getTime() + const currentTime = Date.now() const state: SpringState = spring.state || { position: spring.target, lastTime: currentTime, From 6254ec8b1b166fb5e1eb67eac3d61a8ab20468c7 Mon Sep 17 00:00:00 2001 From: Caleb Eby Date: Sat, 2 Nov 2019 15:44:15 -0700 Subject: [PATCH 06/12] eslint disable --- src/routes/springy.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/springy.tsx b/src/routes/springy.tsx index e1d63a7b4..889f5b934 100644 --- a/src/routes/springy.tsx +++ b/src/routes/springy.tsx @@ -30,6 +30,7 @@ const Springy: FunctionComponent = () => { const offsetX = x - width / 2 const offsetY = y - width / 2 return ( + // eslint-disable-next-line caleb/jsx-a11y/no-static-element-interactions, caleb/jsx-a11y/click-events-have-key-events
{ From 5d77f09c00bb68ffa4f3d14378f2406517302339 Mon Sep 17 00:00:00 2001 From: Caleb Eby Date: Sat, 2 Nov 2019 18:14:58 -0700 Subject: [PATCH 07/12] WIP --- src/components/menu.tsx | 12 ++++++------ src/routes/event-team.tsx | 15 +++++++++++---- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/components/menu.tsx b/src/components/menu.tsx index d02a75a60..5330eff77 100644 --- a/src/components/menu.tsx +++ b/src/components/menu.tsx @@ -127,17 +127,17 @@ const logoutHandler = () => { export const Menu = ({ onHide, visible }: Props) => { const { jwt } = useJWT() const isAdmin = jwt && jwt.peregrineRoles.isAdmin - const spring = initSpring() + // const spring = initSpring() const isLoggedIn = jwt const savedReports = useSavedReports() return ( - { )} - + ) } diff --git a/src/routes/event-team.tsx b/src/routes/event-team.tsx index 20d65292e..9d2f0c3ef 100644 --- a/src/routes/event-team.tsx +++ b/src/routes/event-team.tsx @@ -17,7 +17,7 @@ import { nextIncompleteMatch } from '@/utils/next-incomplete-match' import { ChartCard } from '@/components/chart' import { useEventMatches } from '@/cache/event-matches/use' import { useState } from 'preact/hooks' -import { initSpring } from '@/spring/use' +import { initSpring, Animated } from '@/spring/use' import { useSchema } from '@/cache/schema/use' const sectionStyle = css` @@ -88,7 +88,7 @@ const EventTeam = ({ eventKey, teamNum }: Props) => { /> )} - {/* */} + { const [toggle, setToggle] = useState(false) - const position = initSpring(toggle ? -200 : 200) + const spring = initSpring({ mass: 0.0007 }) + return (
-

Hi

+ + Hi +
) From ef4d5b0f3f87b41c576647e5b335c81b39c70af6 Mon Sep 17 00:00:00 2001 From: Caleb Eby Date: Sun, 3 Nov 2019 12:04:01 -0800 Subject: [PATCH 08/12] Add computed springs and stuff --- src/routes/event-team.tsx | 46 ++++++-- src/routes/springy.tsx | 35 +++++- src/spring/use.tsx | 243 ++++++++++++++++++++++++++------------ 3 files changed, 234 insertions(+), 90 deletions(-) diff --git a/src/routes/event-team.tsx b/src/routes/event-team.tsx index 9d2f0c3ef..4878227e8 100644 --- a/src/routes/event-team.tsx +++ b/src/routes/event-team.tsx @@ -17,7 +17,13 @@ import { nextIncompleteMatch } from '@/utils/next-incomplete-match' import { ChartCard } from '@/components/chart' import { useEventMatches } from '@/cache/event-matches/use' import { useState } from 'preact/hooks' -import { initSpring, Animated } from '@/spring/use' +import { + initSpring, + Animated, + Springed, + tweenColor, + tweenLength, +} from '@/spring/use' import { useSchema } from '@/cache/schema/use' const sectionStyle = css` @@ -142,15 +148,37 @@ const TestComponent = () => { const [toggle, setToggle] = useState(false) const spring = initSpring({ mass: 0.0007 }) + const styles = spring({ + padding: '0.5rem', + 'border-radius': '0.2rem', + transform: spring`translateX(${toggle ? 200 : -200}px)`, + 'font-family': '"Dank Mono", "Fira Code", "Source Code Pro"', + background: tweenColor(spring, toggle ? '#282828' : 'black'), + color: tweenColor(spring, toggle ? '#b16286' : '#994cc3'), + width: tweenLength(spring, toggle ? '20vw' : '100%', el => el.offsetWidth), + // ...(toggle + // ? { + // background: tweenColor(spring, '#282828'), + // color: tweenColor(spring, '#b16286'), + // } + // : { + // background: tweenColor(spring, 'black'), + // color: tweenColor(spring, '#994cc3'), + // }), + }) + return ( -
- - Hi - +
+ + {toggle ? 'hi' : 'hiya long\n\nhi again'} +
) diff --git a/src/routes/springy.tsx b/src/routes/springy.tsx index 889f5b934..2c536d770 100644 --- a/src/routes/springy.tsx +++ b/src/routes/springy.tsx @@ -11,12 +11,23 @@ const wrapperStyle = css` const width = 50 -const circleStyle = css` +const boxStyle = css` width: ${width}px; height: ${width}px; background: purple; will-change: transform; - border-radius: 50%; + border-radius: 10%; + + &::before { + content: ''; + display: block; + width: ${width / 4}px; + height: ${width / 4}px; + border-radius: 50%; + background: red; + margin: 0 auto; + transform: translateY(${width / 15}px); + } ` const Springy: FunctionComponent = () => { @@ -27,8 +38,18 @@ const Springy: FunctionComponent = () => { }) const [x, setX] = useState(0) const [y, setY] = useState(0) - const offsetX = x - width / 2 - const offsetY = y - width / 2 + const targetX = x - width / 2 + const targetY = y - width / 2 + const offsetX = spring(targetX) + const offsetY = spring(targetY) + const angle = spring(getValue => { + const currentX = getValue(offsetX) + const currentY = getValue(offsetY) + console.log(Math.atan((targetY - currentY) / (targetX - currentX))) + // Rise over run + return Math.atan((targetY - currentY) / (targetX - currentX)) - Math.PI / 2 + }) + return ( // eslint-disable-next-line caleb/jsx-a11y/no-static-element-interactions, caleb/jsx-a11y/click-events-have-key-events
{ }} >
) diff --git a/src/spring/use.tsx b/src/spring/use.tsx index f466aa306..0f1c78db4 100644 --- a/src/spring/use.tsx +++ b/src/spring/use.tsx @@ -9,37 +9,62 @@ interface SpringState { position: number } -interface DirectSpring { - [springed]: true - opts: Required - target: number - state?: SpringState +interface SubSprings { + [key: string]: Springed } -interface ComposedSpring { - [springed]: true - subSprings: SubSprings - composeTweenedValues: (tweenedValues: { [key: string]: number }) => ResultType -} +type _Springed = T extends number + ? { + [springed]: true + opts: SpringOpts + target: number + state?: SpringState + } + : { + [springed]: true + measure?: (endEl: HTMLElement) => void + subSprings: T extends string ? SubSprings : SubSprings + composeTweenedValues: (tweenedValues: { [key: string]: number }) => T + } -type Springed = DirectSpring | ComposedSpring +// Mapped types cause TS to not resolve the types inline when you hover over them. +// So this is a useless mapped type that is only being used so that it doesn't get inlined when you hover over it +export type Springed = { + [K in keyof _Springed]: _Springed[K] +} interface SpringOpts { - mass?: number - springStrength?: number - friction?: number + mass: number + springStrength: number + friction: number } -interface CreateSpring { - (strings: TemplateStringsArray, ...expressions: number[]): ComposedSpring< - string - > - (value: number): DirectSpring - (value: T): ComposedSpring -} +type UnSpring = T extends Springed + ? U extends object + ? UnSpringObject + : U + : T + +// This is a conditional type so that TS will inline the result of the type when you hover over it. +// If it isn't conditional, it doesn't inline it. IDK why. +type UnSpringObject = T extends object + ? { + [K in keyof T]: UnSpring + } + : never + +type ComputeFromSubSprings = ( + getValue: >(input: U) => UnSpring, +) => T -interface SubSprings { - [key: string]: Springed +interface CreateSpring { + (value: number): Springed + (computeFromDependentSprings: ComputeFromSubSprings): Springed + (value: T): Springed> + ( + strings: TemplateStringsArray, + ...expressions: (number | Springed)[] + ): Springed } const isTemplateStringsArray = (input: any): input is TemplateStringsArray => @@ -49,32 +74,44 @@ export const initSpring = ({ friction = 0.007, mass = 0.0003, springStrength = 0.08, -}: SpringOpts = {}) => { - const createSpring: CreateSpring = ( - firstArg: number | TemplateStringsArray | T, - ...expressions: number[] +}: Partial = {}) => { + const createSpring: CreateSpring = ( + input: + | TemplateStringsArray + | number + | object + | ComputeFromSubSprings, + ...expressions: (number | Springed)[] // Only exists when being used as a template string ): any => { - if (typeof firstArg === 'number') { - const resultSpringed: DirectSpring = { + if (typeof input === 'number') { + // Use case: spring(asdf ? 1 : 2) + // Returns a direct spring holding the config values + const resultSpringed: Springed = { [springed]: true, opts: { friction, mass, springStrength, }, - target: firstArg, + target: input, } return resultSpringed } - if (isTemplateStringsArray(firstArg)) { - const resultSpringed: ComposedSpring = { + if (isTemplateStringsArray(input)) { + // Use case: spring`transform: translate${asdf ? 1 : 2}` + // Returns a composed spring with all of the interpolations being springed + // The interpolations are keyed by index, because they are fixed order + const resultSpringed: Springed = { [springed]: true, - subSprings: expressions.reduce((subSprings, expr, i) => { - subSprings[i] = createSpring(expr) - return subSprings - }, {}), + subSprings: expressions.reduce>( + (subSprings, expr, i) => { + subSprings[i] = isSpringed(expr) ? expr : createSpring(expr) + return subSprings + }, + {}, + ), composeTweenedValues: tweenedValues => - firstArg.reduce( + input.reduce( (builtString, stringChunk, i) => builtString + stringChunk + (tweenedValues[i] || ''), '', @@ -82,9 +119,29 @@ export const initSpring = ({ } return resultSpringed } - const resultSpringed: ComposedSpring = { + if (typeof input === 'function') { + const lastSprings: Springed[] = [] + const resultSpringed: Springed = { + [springed]: true, + subSprings: {}, + composeTweenedValues() { + let i = 0 + return input(s => { + if (lastSprings[i]) copySpringState(lastSprings[i], s) + lastSprings[i] = s + i++ + return computeSpring(s) + }) + }, + } + return resultSpringed + } + // Use case: spring({foo: true, bar: spring(asdf ? 1 : 2)}) + // Returns a composed spring with whichever properties are springed as sub springs + // They are keyed by object key + const resultSpringed: Springed = { [springed]: true, - subSprings: Object.entries(firstArg).reduce( + subSprings: Object.entries(input).reduce( (subSprings, [key, value]) => { if (isSpringed(value)) subSprings[key] = value return subSprings @@ -92,21 +149,18 @@ export const initSpring = ({ {}, ), composeTweenedValues: tweenedValues => - Object.entries(firstArg).reduce( - (acc, [key, val]) => { - // @ts-ignore - acc[key] = isSpringed(val) ? tweenedValues[key] : firstArg[key] - return acc - }, - {} as T, - ), + Object.entries(input).reduce((acc, [key, val]) => { + // @ts-ignore + acc[key] = isSpringed(val) ? tweenedValues[key] : input[key] + return acc + }, {}), } return resultSpringed } return createSpring } -const getSpringFramePosition = (spring: DirectSpring) => { +const getSpringFramePosition = (spring: Springed) => { const currentTime = Date.now() const state: SpringState = spring.state || { position: spring.target, @@ -144,38 +198,35 @@ type AnimatedObject = { > } -const isComposedSpring = ( - spring: Springed, -): spring is ComposedSpring => - (spring as ComposedSpring).subSprings !== undefined +const isSpringedNumber = ( + spring: Springed, +): spring is Springed => + (spring as Springed).target !== undefined -const isDirectSpring = (spring: Springed): spring is DirectSpring => - (spring as DirectSpring).target !== undefined - -const computeSpring = (spring: Springed): T => { - if (isComposedSpring(spring)) { - return spring.composeTweenedValues( - Object.entries(spring.subSprings).reduce( - (acc, [key, value]) => { - acc[key] = isSpringed(value) ? computeSpring(value) : value - return acc - }, - {} as { [key: string]: any }, - ), - ) +const computeSpring = >(spring: T): UnSpring => { + if (isSpringedNumber(spring)) { + return getSpringFramePosition(spring) as UnSpring } - return (getSpringFramePosition(spring) as unknown) as T + return spring.composeTweenedValues( + Object.entries(spring.subSprings).reduce( + (acc, [key, value]) => { + acc[key] = isSpringed(value) ? computeSpring(value) : value + return acc + }, + {} as { [key: string]: any }, + ), + ) as UnSpring } -const copySpringState = ( +const copySpringState = ( oldSpring: Springed, newSpring: Springed, ) => { - if (isDirectSpring(oldSpring)) { - if (isDirectSpring(newSpring)) { + if (isSpringedNumber(oldSpring)) { + if (isSpringedNumber(newSpring)) { newSpring.state = oldSpring.state } - } else if (isComposedSpring(newSpring)) { + } else { const subSpringKeys = Object.keys(newSpring.subSprings) for (const key of subSpringKeys) { const oldSubSpring = oldSpring.subSprings[key] @@ -201,13 +252,12 @@ export const Animated = new Proxy({} as any, { props: AnimatifyProps, ) => { const lastSpring = useRef | null>(null) + const propsEntries = Object.entries(props) const staticProps = Object.fromEntries( - Object.entries(props).filter(([, val]) => !isSpringed(val)), + propsEntries.filter(([, val]) => !isSpringed(val)), ) const propsSpring = initSpring()( - Object.fromEntries( - Object.entries(props).filter(([, val]) => isSpringed(val)), - ), + Object.fromEntries(propsEntries.filter(([, val]) => isSpringed(val))), ) if (lastSpring.current) copySpringState(lastSpring.current, propsSpring) lastSpring.current = propsSpring @@ -243,3 +293,46 @@ export const Animated = new Proxy({} as any, { return Component }, }) + +const colorRegex = /[.\d]+/g + +const colorEl = document.createElement('div') +document.body.append(colorEl) + +export const tweenColor = ( + spring: CreateSpring, + color: string, +): Springed => { + colorEl.style.color = color + // We have to check that the property still exists, otherwise the property has been rejected by the css parser + const targetColor = colorEl.style.color && getComputedStyle(colorEl).color + colorEl.style.color = '' + const [targetRed = 0, targetGreen = 0, targetBlue = 0, targetAlpha = 1] = + (targetColor && targetColor.match(colorRegex)) || [] + const red = spring(Number(targetRed)) + const green = spring(Number(targetGreen)) + const blue = spring(Number(targetBlue)) + const alpha = spring(Number(targetAlpha)) + + return { + [springed]: true, + subSprings: { red, green, blue, alpha }, + composeTweenedValues: ({ red, green, blue, alpha }) => + `rgba(${red},${green},${blue},${alpha})`, + } +} + +export const tweenLength = ( + spring: CreateSpring, + targetVal: string, + measure: (el: HTMLElement) => number, +): Springed => { + let v: number + return { + [springed]: true, + targetVal, + measure: el => (v = measure(el)), + subSprings: { v: spring(v || 0) }, + composeTweenedValues: ({ v }) => v, + } +} From b08ec72265b812a5dbc490d6cf1d2359a86ca592 Mon Sep 17 00:00:00 2001 From: Caleb Eby Date: Tue, 5 Nov 2019 01:58:17 -0800 Subject: [PATCH 09/12] Rewrite almost everything for really nice compositoin API --- springy-refactor.md | 87 ++++++++ src/routes/springy.tsx | 69 ++++++- src/spring/use.tsx | 452 +++++++++++++++++++++++++---------------- 3 files changed, 427 insertions(+), 181 deletions(-) create mode 100644 springy-refactor.md diff --git a/springy-refactor.md b/springy-refactor.md new file mode 100644 index 000000000..4c07a53dd --- /dev/null +++ b/springy-refactor.md @@ -0,0 +1,87 @@ +- spring should not have all these overloads, creating super-springs should be separate module exports. They aren't actually related to the individual spring configs so it is silly the way it is currently. + +```ts +import { initSpring, templateSpring, templateSpringAll, springedObject } from '' +const spring = initSpring() +const x = spring(foo ? 2 : 1) // Springed +const y = foo ? 2 : 1 // number + +// Springed - "spring on a spring" +const foo = spring(x) + +// Springed - the result of this is directly tied with the x spring, it is not double springed +const bar = createDerivedSpring(evalSpring => { + return y / evalSpring(x) +}) + +// Spring<{background: 'green', asdf: number}> -- asdf is springed but not background +const css3 = springedObject({ + background: foo ? 'green' : 'blue', + asdf: spring(foo ? 1 : 2), +}) + +// Spring - x is single springed, y is not springed +const css = templateSpring`transform: translateX(${x}px) translateY(${y}px)` + +// Spring - both x and y are springed. x is double springed, y is only springed once +const css2 = spring( + templateSpring`transform: translateX(${x}px) translateY(${y}px)`, +) +``` + +```ts +interface Spring { + [springed]: true + computeValue(state: any): T + compose(outerSpring: CreateSpring): Spring +} +``` + +- Where should spring state be stored - in Animated is best. + +```ts +const Animated = () => { + setInterval(() => { + // springsCache holds values for ONE FRAME so that they do not get computed multiple times + // The values are the resolved/computed values from the springs + const springsCache = Map, T> + // compute root props spring + // All sub springs when they get computed, get added to springsCache + }) +} +``` + +- What happens if I pass a springed value down into a regular component? + +Each component (each element, even) has its own frame loop, so it wouldn't be good (/accurate) if they shared the same cache +_unless_ we have a global frame loop - think about this more in the future +a global thing that Animated components can register themselves into + +```tsx +const A = () => { + const spring = initSpring() + const x = spring(foo ? 1 : 2) + return ( + + + + + ) +} + +const B = ({x}) => { + // x is Springed + return +} +``` + +- "measuring" oof + +```tsx +const A = () => { + const spring = initSpring() + const width = spring(foo ? '50%' : 'auto', (el: HTMLElement) => el.innerWidth) + + return +} +``` diff --git a/src/routes/springy.tsx b/src/routes/springy.tsx index 2c536d770..f2632b392 100644 --- a/src/routes/springy.tsx +++ b/src/routes/springy.tsx @@ -1,5 +1,11 @@ import { h, FunctionComponent } from 'preact' -import { initSpring, Animated } from '@/spring/use' +import { + initSpring, + Animated, + createDerivedSpring, + templateSpring, + springedObject, +} from '@/spring/use' import { useState } from 'preact/hooks' import { css } from 'linaria' @@ -7,11 +13,13 @@ const wrapperStyle = css` width: 100%; height: 100vh; overflow: hidden; + background: black; ` const width = 50 const boxStyle = css` + position: absolute; width: ${width}px; height: ${width}px; background: purple; @@ -32,8 +40,13 @@ const boxStyle = css` const Springy: FunctionComponent = () => { const spring = initSpring({ - friction: 0.007, - mass: 0.0023, + friction: 0.012, + mass: 0.003, + springStrength: 0.02, + }) + const heavySpring = initSpring({ + friction: 0.013, + mass: 0.007, springStrength: 0.02, }) const [x, setX] = useState(0) @@ -42,12 +55,33 @@ const Springy: FunctionComponent = () => { const targetY = y - width / 2 const offsetX = spring(targetX) const offsetY = spring(targetY) - const angle = spring(getValue => { + const angle = createDerivedSpring(getValue => { const currentX = getValue(offsetX) const currentY = getValue(offsetY) - console.log(Math.atan((targetY - currentY) / (targetX - currentX))) - // Rise over run - return Math.atan((targetY - currentY) / (targetX - currentX)) - Math.PI / 2 + return -Math.atan2(currentY - targetY, targetX - currentX) + Math.PI / 2 + }) + const offsetX2 = spring(offsetX) + const offsetY2 = spring(offsetY) + const angle2 = createDerivedSpring(getValue => { + const currentX = getValue(offsetX2) + const currentY = getValue(offsetY2) + return ( + -Math.atan2(currentY - getValue(offsetY), getValue(offsetX) - currentX) + + Math.PI / 2 + ) + }) + const offsetX3 = spring(offsetX2) + const offsetY3 = spring(offsetY2) + const angle3 = createDerivedSpring(getValue => { + const currentX = getValue(offsetX3) + const currentY = getValue(offsetY3) + return ( + -Math.atan2( + currentY - getValue(offsetY2), + getValue(offsetX2) - currentX, + ) + + Math.PI / 2 + ) }) return ( @@ -61,9 +95,24 @@ const Springy: FunctionComponent = () => { > evalSpring(offsetX) * 10), + )} + style={templateSpring`transform: translate(${offsetX}px, ${offsetY}px) rotate(${heavySpring( + angle, + )}rad)`} + /> + + ) diff --git a/src/spring/use.tsx b/src/spring/use.tsx index 0f1c78db4..0632bd3d8 100644 --- a/src/spring/use.tsx +++ b/src/spring/use.tsx @@ -3,42 +3,26 @@ import { useRef, useEffect } from 'preact/hooks' const springed = Symbol('springed') -interface SpringState { +interface SpringedNumberState { lastTime: number velocity: number position: number } -interface SubSprings { - [key: string]: Springed -} - -type _Springed = T extends number - ? { - [springed]: true - opts: SpringOpts - target: number - state?: SpringState - } - : { - [springed]: true - measure?: (endEl: HTMLElement) => void - subSprings: T extends string ? SubSprings : SubSprings - composeTweenedValues: (tweenedValues: { [key: string]: number }) => T - } - -// Mapped types cause TS to not resolve the types inline when you hover over them. -// So this is a useless mapped type that is only being used so that it doesn't get inlined when you hover over it -export type Springed = { - [K in keyof _Springed]: _Springed[K] -} - interface SpringOpts { mass: number springStrength: number friction: number } +/** Map of springs to their results */ +type SpringCache = Map, { state: unknown; value: unknown }> + +const enum SpringType { + number, + other, +} + type UnSpring = T extends Springed ? U extends object ? UnSpringObject @@ -48,142 +32,292 @@ type UnSpring = T extends Springed // This is a conditional type so that TS will inline the result of the type when you hover over it. // If it isn't conditional, it doesn't inline it. IDK why. type UnSpringObject = T extends object - ? { - [K in keyof T]: UnSpring - } + ? { [K in keyof T]: UnSpring } : never -type ComputeFromSubSprings = ( - getValue: >(input: U) => UnSpring, -) => T +interface Springed { + [springed]: true + computeValue(state: S, springCache: SpringCache): { state: S; value: T } + compose(createSpring: CreateSpring): Springed + getInitialState: (springCache: SpringCache) => S + target?: any + type: SpringType +} interface CreateSpring { - (value: number): Springed - (computeFromDependentSprings: ComputeFromSubSprings): Springed - (value: T): Springed> - ( - strings: TemplateStringsArray, - ...expressions: (number | Springed)[] - ): Springed + /** Creates a spring that smoothly transitions to the specified target */ + (target: number): Springed + /** Composes another spring, "spring on a spring" */ + (target: Springed): Springed + /** General overloads because https://github.com/microsoft/TypeScript/issues/14107 */ + (target: Springed | number): Springed + >(target: T | number): T | Springed } -const isTemplateStringsArray = (input: any): input is TemplateStringsArray => - Array.isArray(input) && Array.isArray((input as any).raw) - export const initSpring = ({ friction = 0.007, mass = 0.0003, springStrength = 0.08, }: Partial = {}) => { - const createSpring: CreateSpring = ( - input: - | TemplateStringsArray - | number - | object - | ComputeFromSubSprings, - ...expressions: (number | Springed)[] // Only exists when being used as a template string - ): any => { - if (typeof input === 'number') { - // Use case: spring(asdf ? 1 : 2) - // Returns a direct spring holding the config values - const resultSpringed: Springed = { + const createSpring: CreateSpring = ( + target: number | Springed, + ): Springed | Springed => { + if (typeof target === 'number') { + const spring: Springed = { [springed]: true, - opts: { - friction, - mass, - springStrength, + computeValue: state => { + const v = getSpringFramePosition( + target, + state, + springStrength, + friction, + mass, + ) + return v }, - target: input, + compose: createSpring => createSpring(spring), + type: SpringType.number, + target, + getInitialState: () => ({ + position: target, + lastTime: Date.now(), + velocity: 0, + }), } - return resultSpringed + return spring } - if (isTemplateStringsArray(input)) { - // Use case: spring`transform: translate${asdf ? 1 : 2}` - // Returns a composed spring with all of the interpolations being springed - // The interpolations are keyed by index, because they are fixed order - const resultSpringed: Springed = { - [springed]: true, - subSprings: expressions.reduce>( - (subSprings, expr, i) => { - subSprings[i] = isSpringed(expr) ? expr : createSpring(expr) - return subSprings - }, - {}, - ), - composeTweenedValues: tweenedValues => - input.reduce( - (builtString, stringChunk, i) => - builtString + stringChunk + (tweenedValues[i] || ''), - '', - ), + // Target is Springed + if (isSpringedNumber(target)) { + interface WrappedSpringState { + subSpringState: unknown + state: SpringedNumberState } - return resultSpringed - } - if (typeof input === 'function') { - const lastSprings: Springed[] = [] - const resultSpringed: Springed = { + const subSpring = target + + const spring: Springed = { [springed]: true, - subSprings: {}, - composeTweenedValues() { - let i = 0 - return input(s => { - if (lastSprings[i]) copySpringState(lastSprings[i], s) - lastSprings[i] = s - i++ - return computeSpring(s) - }) + computeValue({ subSpringState, state }, springCache) { + const subSpringResult = computeSpring( + subSpring, + subSpringState, + springCache, + ) + const target = subSpringResult.value + const springResult = getSpringFramePosition( + target, + state, + springStrength, + friction, + mass, + ) + const thisSpringState = springResult.state + const value = springResult.value + return { + value, + state: { + subSpringState: subSpringResult.state, + state: thisSpringState, + }, + } }, - } - return resultSpringed - } - // Use case: spring({foo: true, bar: spring(asdf ? 1 : 2)}) - // Returns a composed spring with whichever properties are springed as sub springs - // They are keyed by object key - const resultSpringed: Springed = { - [springed]: true, - subSprings: Object.entries(input).reduce( - (subSprings, [key, value]) => { - if (isSpringed(value)) subSprings[key] = value - return subSprings + compose: createSpring => createSpring(spring), + type: SpringType.number, + getInitialState(springCache) { + const subSpring = target + const subSpringState = target.getInitialState(springCache) + return { + state: { + lastTime: Date.now(), + position: computeSpring(subSpring, subSpringState, springCache) + .value, + velocity: 0, + }, + subSpring, + subSpringState, + } }, - {}, - ), - composeTweenedValues: tweenedValues => - Object.entries(input).reduce((acc, [key, val]) => { - // @ts-ignore - acc[key] = isSpringed(val) ? tweenedValues[key] : input[key] - return acc - }, {}), + } + return spring } - return resultSpringed + + // Target is Springed + return target.compose(createSpring) } return createSpring } -const getSpringFramePosition = (spring: Springed) => { - const currentTime = Date.now() - const state: SpringState = spring.state || { - position: spring.target, - lastTime: currentTime, - velocity: 0, +const isSpringedNumber = ( + s: Springed, +): s is Springed => s.type === SpringType.number + +type TemplateSpringState = { [i: number]: unknown } + +type TemplateLiteralExpression = string | number | boolean + +export const templateSpring = ( + strings: TemplateStringsArray, + ...expressions: ( + | TemplateLiteralExpression + | Springed)[] +): Springed => { + return { + compose: createSpring => + templateSpring( + strings, + // If it is a string passed directly in, it cannot be springed i.e. `${"hi"}` + // But if it is a number passed in directly, or an existing Springed, it should be springed/re-springed + ...expressions.map(e => + typeof e === 'number' || typeof e === 'object' ? createSpring(e) : e, + ), + ), + target: expressions, + computeValue: (state, springCache) => { + const newState: TemplateSpringState = {} + const computedExpressions: TemplateLiteralExpression[] = expressions.map( + (expr, i) => { + if (!isSpringed(expr)) return expr + const matchingState = + state[i] === undefined + ? expr.getInitialState(springCache) + : state[i] + const springResult = computeSpring(expr, matchingState, springCache) + newState[i] = springResult.state + return springResult.value + }, + ) + const value = strings.reduce((builtString, chunk, i) => { + const expr = computedExpressions[i] + return builtString + chunk + (expr === undefined ? '' : expr) + }, '') + return { state: newState, value } + }, + type: SpringType.other, + [springed]: true, + getInitialState: () => ({}), + } +} + +/** Array of the sub spring states */ +type DerivedSpringState = unknown[] + +export const createDerivedSpring = ( + cb: (evalSpring: (s: Springed) => U) => T, +) => { + const spring: Springed = { + [springed]: true, + type: SpringType.other, + compose: createSpring => + createDerivedSpring(evalSpring => { + const value = evalSpring(spring) + if (typeof value !== 'number') + throw new TypeError('Cannot wrap a non-number from derived spring') + return evalSpring(createSpring(value)) + }), + computeValue: (subSpringStates, springCache) => { + let i = 0 + const newSubSpringStates: unknown[] = [] + const evalSpring = (subSpring: Springed) => { + const subSpringState = + subSpringStates[i] === undefined + ? subSpring.getInitialState(springCache) + : subSpringStates[i] + const result = computeSpring(subSpring, subSpringState, springCache) + newSubSpringStates[i] = result.state + i++ + return result.value + } + const value = cb(evalSpring) + return { state: newSubSpringStates, value } + }, + getInitialState: () => [], + } + return spring +} + +interface SpringedObjectState { + /** Spring state by key */ + [key: string]: unknown +} + +export const springedObject = ( + input: T, +): Springed, SpringedObjectState> => { + const spring: Springed, SpringedObjectState> = { + [springed]: true, + compose(createSpring) { + const modifiedObject = Object.fromEntries( + Object.entries(input).map(([key, val]) => { + if (isSpringed(val) || typeof val === 'number') + return [key, createSpring] + return [key, val] + }), + ) + return springedObject(modifiedObject) as Springed> + }, + target: input, + computeValue(state, springCache) { + const newState: SpringedObjectState = {} + const value = Object.fromEntries( + Object.entries(input).map(([key, val]: [string, unknown]) => { + if (!isSpringed(val)) return [key, val] + const result = computeSpring( + val as Springed, + state[key] || val.getInitialState(springCache), + springCache, + ) + newState[key] = result.state + return [key, result.value] + }), + ) as UnSpringObject + return { state: newState, value } + }, + type: SpringType.other, + getInitialState: () => ({}), } - spring.state = state - const displacement = spring.target - state.position - const springForce = spring.opts.springStrength * displacement + return spring +} + +const computeSpring = ( + spring: Springed, + state: S, + springCache: SpringCache, +) => { + const cachedMatch = springCache.get(spring) + if (cachedMatch) return cachedMatch as { state: S; value: T } + const result = spring.computeValue(state, springCache) + springCache.set(spring, result) + return result +} + +const getSpringFramePosition = ( + target: number, + state: SpringedNumberState, + springStrength: number, + friction: number, + mass: number, +) => { + const currentTime = Date.now() + const displacement = target - state.position + if (Math.abs(displacement) < 1e-3) + return { + state: { lastTime: currentTime, velocity: 0, position: target }, + value: target, + } + const springForce = springStrength * displacement // Friction isn't really proportional to velocity // But this makes it "feel" right // And it makes everything way easier to tune - const frictionalForce = spring.opts.friction * state.velocity + const frictionalForce = friction * state.velocity const force = springForce - frictionalForce // f = ma - const acceleration = force / spring.opts.mass + const acceleration = force / mass const dTime = (currentTime - state.lastTime) / 1000 state.lastTime = currentTime // v' = v + at state.velocity += acceleration * dTime // x' = x + vt state.position += state.velocity * dTime - return state.position + return { state, value: state.position } } const isSpringed = (prop: any): prop is Springed => prop[springed] @@ -198,44 +332,6 @@ type AnimatedObject = { > } -const isSpringedNumber = ( - spring: Springed, -): spring is Springed => - (spring as Springed).target !== undefined - -const computeSpring = >(spring: T): UnSpring => { - if (isSpringedNumber(spring)) { - return getSpringFramePosition(spring) as UnSpring - } - return spring.composeTweenedValues( - Object.entries(spring.subSprings).reduce( - (acc, [key, value]) => { - acc[key] = isSpringed(value) ? computeSpring(value) : value - return acc - }, - {} as { [key: string]: any }, - ), - ) as UnSpring -} - -const copySpringState = ( - oldSpring: Springed, - newSpring: Springed, -) => { - if (isSpringedNumber(oldSpring)) { - if (isSpringedNumber(newSpring)) { - newSpring.state = oldSpring.state - } - } else { - const subSpringKeys = Object.keys(newSpring.subSprings) - for (const key of subSpringKeys) { - const oldSubSpring = oldSpring.subSprings[key] - const newSubSpring = newSpring.subSprings[key] - if (oldSubSpring) copySpringState(oldSubSpring, newSubSpring) - } - } -} - // We need to make sure to return the same component between multiple renders // so that state/refs are preserved between renders // Otherwise a different component is returned and everything is reset @@ -251,21 +347,27 @@ export const Animated = new Proxy({} as any, { const Component = ( props: AnimatifyProps, ) => { - const lastSpring = useRef | null>(null) const propsEntries = Object.entries(props) const staticProps = Object.fromEntries( propsEntries.filter(([, val]) => !isSpringed(val)), ) - const propsSpring = initSpring()( - Object.fromEntries(propsEntries.filter(([, val]) => isSpringed(val))), - ) - if (lastSpring.current) copySpringState(lastSpring.current, propsSpring) - lastSpring.current = propsSpring + const propsSpring = springedObject(Object.fromEntries( + propsEntries.filter(([, val]) => isSpringed(val)), + ) as typeof props) const elementRef = useRef() + const propsStateRef = useRef( + propsSpring.getInitialState(new Map()), + ) useEffect(() => { const updateComponentSprings = () => { const element = elementRef.current - const tweenedProps = computeSpring(propsSpring) + const tweenedPropsResult = computeSpring( + propsSpring, + propsStateRef.current, + new Map(), + ) + const tweenedProps = tweenedPropsResult.value + propsStateRef.current = tweenedPropsResult.state if (element) { if (typeof tweenedProps.style === 'string') { ;(element as HTMLElement).style.cssText = tweenedProps.style @@ -274,6 +376,14 @@ export const Animated = new Proxy({} as any, { ;(element as HTMLElement).style[key] = tweenedProps.style[key] } } + for (const key in tweenedProps) { + if (key !== 'style') { + ;(element as HTMLElement).setAttribute( + key, + tweenedProps[key as keyof typeof props], + ) + } + } } timeoutId = setTimeout( () => (rafId = requestAnimationFrame(updateComponentSprings)), @@ -286,7 +396,7 @@ export const Animated = new Proxy({} as any, { clearTimeout(timeoutId) cancelAnimationFrame(rafId) } - }, [propsSpring]) + }) return } animatedComponentCache[El] = Component From 70e97cd522493fe287e71a444c9e3fae433eeed4 Mon Sep 17 00:00:00 2001 From: Caleb Eby Date: Tue, 5 Nov 2019 02:02:04 -0800 Subject: [PATCH 10/12] Empty commit to trigger netlify From e66f2f1bf4e1bf2e59ccd9e7ab3d5e69bd8725ba Mon Sep 17 00:00:00 2001 From: Caleb Eby Date: Fri, 15 Nov 2019 14:52:40 -0800 Subject: [PATCH 11/12] Thoughts about createDerivedSpring --- springy-refactor.md | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/springy-refactor.md b/springy-refactor.md index 4c07a53dd..5e88f710e 100644 --- a/springy-refactor.md +++ b/springy-refactor.md @@ -10,10 +10,36 @@ const y = foo ? 2 : 1 // number const foo = spring(x) // Springed - the result of this is directly tied with the x spring, it is not double springed -const bar = createDerivedSpring(evalSpring => { - return y / evalSpring(x) +const bar = createDerivedSpring([x], ([x]) => { + return y / x }) +// Springed - This is double springed. The return value of the createDerivedSpring got springed +const asdf = spring(bar) + +// Springed - The return value of createDerivedSpring got springed +const foobar = spring( + createDerivedSpring([x], ([x]) => { + return x > 10 ? 100 : 35 + }), +) + +const color = (r, g, b) => + createDerivedSpring([r, g, b].map(spring), ([r, g, b]) => { + return `rgba(${r}, ${g}, ${b})` + }) + +// The "expected result" would be a color that smoothly transitions +// In this case createDerivedSpring(...).compose() will be called +// We _want_ that to wrap all the input springs with the wrapper springs +// But that conflicts with above where the return value got springed. +// We could use typeof on the return value. That might be the best solution +// NO that won't work because we don't have access to the return value in .compose() +// So we'll need two separate methods for creating derived springs. One that is for numbers and one that is not +const respringedColor = spring( + asdf ? color(235, 220, 130) : color(130, 250, 120), +) + // Spring<{background: 'green', asdf: number}> -- asdf is springed but not background const css3 = springedObject({ background: foo ? 'green' : 'blue', From 2f24034eded827d03773296c813a27ac6aa2d171 Mon Sep 17 00:00:00 2001 From: Caleb Eby Date: Fri, 15 Nov 2019 18:51:15 -0800 Subject: [PATCH 12/12] Refactor more things --- springy-refactor.md | 2 +- src/routes/event-team.tsx | 23 +++- src/routes/springy.tsx | 64 ++++----- src/spring/use.tsx | 283 ++++++++++++++++++++++++-------------- 4 files changed, 230 insertions(+), 142 deletions(-) diff --git a/springy-refactor.md b/springy-refactor.md index 5e88f710e..97601c449 100644 --- a/springy-refactor.md +++ b/springy-refactor.md @@ -19,7 +19,7 @@ const asdf = spring(bar) // Springed - The return value of createDerivedSpring got springed const foobar = spring( - createDerivedSpring([x], ([x]) => { + createDerivedNumberSpring([x], ([x]) => { return x > 10 ? 100 : 35 }), ) diff --git a/src/routes/event-team.tsx b/src/routes/event-team.tsx index d03308120..59f358d1a 100644 --- a/src/routes/event-team.tsx +++ b/src/routes/event-team.tsx @@ -23,6 +23,9 @@ import { Springed, tweenColor, tweenLength, + springedObject, + templateSpring, + measure, } from '@/spring/use' import { useSchema } from '@/cache/schema/use' @@ -147,14 +150,24 @@ const TestComponent = () => { const [toggle, setToggle] = useState(false) const spring = initSpring({ mass: 0.0007 }) - const styles = spring({ + const styles = springedObject({ padding: '0.5rem', 'border-radius': '0.2rem', - transform: spring`translateX(${toggle ? 200 : -200}px)`, + // transform: spring(templateSpring`translateX(${toggle ? 200 : -200}px)`), 'font-family': '"Dank Mono", "Fira Code", "Source Code Pro"', - background: tweenColor(spring, toggle ? '#282828' : 'black'), - color: tweenColor(spring, toggle ? '#b16286' : '#994cc3'), - width: tweenLength(spring, toggle ? '20vw' : '100%', el => el.offsetWidth), + // left: spring(templateSpring`${spring(toggle ? 10 : 100)}px`), + left: toggle ? 0 : '', + right: toggle ? '' : 0, + position: 'absolute', + transform: spring( + templateSpring`translateX(${measure(elSnapshot => { + console.log('hi', elSnapshot.offsetLeft) + return -elSnapshot.offsetLeft + })}px)`, + ), + // background: tweenColor(spring, toggle ? '#282828' : 'black'), + // color: tweenColor(spring, toggle ? '#b16286' : '#994cc3'), + // width: tweenLength(spring, toggle ? '20vw' : '100%', el => el.offsetWidth), // ...(toggle // ? { // background: tweenColor(spring, '#282828'), diff --git a/src/routes/springy.tsx b/src/routes/springy.tsx index f2632b392..5522b566d 100644 --- a/src/routes/springy.tsx +++ b/src/routes/springy.tsx @@ -2,9 +2,8 @@ import { h, FunctionComponent } from 'preact' import { initSpring, Animated, - createDerivedSpring, templateSpring, - springedObject, + createDerivedNumberSpring, } from '@/spring/use' import { useState } from 'preact/hooks' import { css } from 'linaria' @@ -40,7 +39,7 @@ const boxStyle = css` const Springy: FunctionComponent = () => { const spring = initSpring({ - friction: 0.012, + friction: 0.01, mass: 0.003, springStrength: 0.02, }) @@ -55,62 +54,59 @@ const Springy: FunctionComponent = () => { const targetY = y - width / 2 const offsetX = spring(targetX) const offsetY = spring(targetY) - const angle = createDerivedSpring(getValue => { - const currentX = getValue(offsetX) - const currentY = getValue(offsetY) - return -Math.atan2(currentY - targetY, targetX - currentX) + Math.PI / 2 - }) + const angle = createDerivedNumberSpring( + [offsetX, offsetY], + ([offsetX, offsetY]) => + -Math.atan2(offsetY - targetY, targetX - offsetX) + Math.PI / 2, + ) const offsetX2 = spring(offsetX) const offsetY2 = spring(offsetY) - const angle2 = createDerivedSpring(getValue => { - const currentX = getValue(offsetX2) - const currentY = getValue(offsetY2) - return ( - -Math.atan2(currentY - getValue(offsetY), getValue(offsetX) - currentX) + - Math.PI / 2 - ) - }) + const angle2 = createDerivedNumberSpring( + [offsetX2, offsetY2], + ([offsetX, offsetY]) => + -Math.atan2(offsetY - targetY, targetX - offsetX) + Math.PI / 2, + ) const offsetX3 = spring(offsetX2) const offsetY3 = spring(offsetY2) - const angle3 = createDerivedSpring(getValue => { - const currentX = getValue(offsetX3) - const currentY = getValue(offsetY3) - return ( - -Math.atan2( - currentY - getValue(offsetY2), - getValue(offsetX2) - currentX, - ) + - Math.PI / 2 - ) - }) + const angle3 = createDerivedNumberSpring( + [offsetX3, offsetY3], + ([offsetX, offsetY]) => + -Math.atan2(offsetY - targetY, targetX - offsetX) + Math.PI / 2, + ) return ( // eslint-disable-next-line caleb/jsx-a11y/no-static-element-interactions, caleb/jsx-a11y/click-events-have-key-events
{ + onMouseMove={(e: MouseEvent) => { setX(e.x) setY(e.y) }} + // onClick={(e: MouseEvent) => { + // setX(e.x) + // setY(e.y) + // }} > evalSpring(offsetX) * 10), - )} - style={templateSpring`transform: translate(${offsetX}px, ${offsetY}px) rotate(${heavySpring( + style={templateSpring`transform: translate(${offsetX}px, ${offsetY}px) rotate(${spring( angle, )}rad)`} /> + {/* */} diff --git a/src/spring/use.tsx b/src/spring/use.tsx index 0632bd3d8..b4024feaa 100644 --- a/src/spring/use.tsx +++ b/src/spring/use.tsx @@ -1,5 +1,5 @@ import { JSX, FunctionComponent, h } from 'preact' -import { useRef, useEffect } from 'preact/hooks' +import { useRef, useLayoutEffect } from 'preact/hooks' const springed = Symbol('springed') @@ -37,10 +37,13 @@ type UnSpringObject = T extends object interface Springed { [springed]: true - computeValue(state: S, springCache: SpringCache): { state: S; value: T } + computeValue( + state: S | undefined, + springCache: SpringCache, + ): { state: S; value: T } compose(createSpring: CreateSpring): Springed - getInitialState: (springCache: SpringCache) => S target?: any + measure: (element: HTMLElement, state: S | undefined) => S | undefined type: SpringType } @@ -66,9 +69,14 @@ export const initSpring = ({ const spring: Springed = { [springed]: true, computeValue: state => { + if (!state) console.log('no s', state) const v = getSpringFramePosition( target, - state, + state || { + position: target, + lastTime: Date.now(), + velocity: 0, + }, springStrength, friction, mass, @@ -77,12 +85,8 @@ export const initSpring = ({ }, compose: createSpring => createSpring(spring), type: SpringType.number, + measure: (_el, state) => state, target, - getInitialState: () => ({ - position: target, - lastTime: Date.now(), - velocity: 0, - }), } return spring } @@ -90,13 +94,14 @@ export const initSpring = ({ if (isSpringedNumber(target)) { interface WrappedSpringState { subSpringState: unknown - state: SpringedNumberState + state: SpringedNumberState | undefined } const subSpring = target const spring: Springed = { [springed]: true, - computeValue({ subSpringState, state }, springCache) { + computeValue(s, springCache) { + const subSpringState = s?.subSpringState const subSpringResult = computeSpring( subSpring, subSpringState, @@ -105,7 +110,12 @@ export const initSpring = ({ const target = subSpringResult.value const springResult = getSpringFramePosition( target, - state, + s?.state || { + lastTime: Date.now(), + position: computeSpring(subSpring, subSpringState, springCache) + .value, + velocity: 0, + }, springStrength, friction, mass, @@ -120,22 +130,12 @@ export const initSpring = ({ }, } }, + measure: (el, s) => ({ + subSpringState: subSpring.measure(el, s?.subSpringState), + state: s?.state, + }), compose: createSpring => createSpring(spring), type: SpringType.number, - getInitialState(springCache) { - const subSpring = target - const subSpringState = target.getInitialState(springCache) - return { - state: { - lastTime: Date.now(), - position: computeSpring(subSpring, subSpringState, springCache) - .value, - velocity: 0, - }, - subSpring, - subSpringState, - } - }, } return spring } @@ -156,9 +156,7 @@ type TemplateLiteralExpression = string | number | boolean export const templateSpring = ( strings: TemplateStringsArray, - ...expressions: ( - | TemplateLiteralExpression - | Springed)[] + ...expressions: (TemplateLiteralExpression | Springed)[] ): Springed => { return { compose: createSpring => @@ -171,15 +169,18 @@ export const templateSpring = ( ), ), target: expressions, + measure: (el, state) => + expressions.reduce((state, expr, i) => { + if (isSpringed(expr)) state[i] = expr.measure(el, state[i]) + return state + }, state || {}), computeValue: (state, springCache) => { + // console.log(state && state[0]) const newState: TemplateSpringState = {} const computedExpressions: TemplateLiteralExpression[] = expressions.map( (expr, i) => { if (!isSpringed(expr)) return expr - const matchingState = - state[i] === undefined - ? expr.getInitialState(springCache) - : state[i] + const matchingState = state?.[i] const springResult = computeSpring(expr, matchingState, springCache) newState[i] = springResult.state return springResult.value @@ -193,43 +194,91 @@ export const templateSpring = ( }, type: SpringType.other, [springed]: true, - getInitialState: () => ({}), } } /** Array of the sub spring states */ type DerivedSpringState = unknown[] -export const createDerivedSpring = ( - cb: (evalSpring: (s: Springed) => U) => T, -) => { - const spring: Springed = { +export const createDerivedSpring = [], O>( + subSprings: T, + cb: (computedValues: { [K in keyof T]: UnSpring }) => O, +): Springed => { + const spring: Springed = { [springed]: true, type: SpringType.other, + // Here composing the spring means re-springing all the input springs compose: createSpring => - createDerivedSpring(evalSpring => { - const value = evalSpring(spring) - if (typeof value !== 'number') - throw new TypeError('Cannot wrap a non-number from derived spring') - return evalSpring(createSpring(value)) - }), + createDerivedSpring(subSprings.map(createSpring), cb as any), + measure: (el, subSpringStates) => { + return subSprings.map((subSpring, i) => + subSpring.measure(el, subSpringStates?.[i]), + ) + }, computeValue: (subSpringStates, springCache) => { - let i = 0 - const newSubSpringStates: unknown[] = [] - const evalSpring = (subSpring: Springed) => { - const subSpringState = - subSpringStates[i] === undefined - ? subSpring.getInitialState(springCache) - : subSpringStates[i] - const result = computeSpring(subSpring, subSpringState, springCache) - newSubSpringStates[i] = result.state - i++ - return result.value + const subSpringResults = subSprings.map((subSpring, i) => { + return subSpring.computeValue(subSpringStates?.[i], springCache) + }) + // eslint-disable-next-line caleb/standard/no-callback-literal + const value = cb(subSpringResults.map(r => r.value) as any) + return { state: subSpringResults.map(r => r.state), value } + }, + } + return spring +} + +interface DerivedSpringedNumberState { + callbackResultState: unknown + subSpringStates: unknown[] +} + +export const createDerivedNumberSpring = []>( + subSprings: T, + cb: ( + computedValues: { [K in keyof T]: UnSpring }, + ) => number | Springed, +): Springed => { + const spring: Springed = { + [springed]: true, + type: SpringType.other, + compose: createSpring => + createDerivedNumberSpring(subSprings, computedValues => { + const v = cb(computedValues) + return createSpring(v) + }), + measure: (el, s) => ({ + subSpringStates: subSprings.map((subSpring, i) => + subSpring.measure(el, s?.subSpringStates?.[i]), + ), + callbackResultState: s?.callbackResultState, + }), + computeValue: (s, springCache) => { + const subSpringResults = subSprings.map((subSpring, i) => { + return subSpring.computeValue(s?.subSpringStates?.[i], springCache) + }) + // eslint-disable-next-line caleb/standard/no-callback-literal + const callbackResult = cb(subSpringResults.map(r => r.value) as any) + const callbackResultState = s?.callbackResultState + const newSubSpringStates = subSpringResults.map(r => r.state) + if (isSpringed(callbackResult)) { + const { state, value } = computeSpring( + callbackResult, + callbackResultState, + springCache, + ) + return { + state: { + callbackResultState: state, + subSpringStates: newSubSpringStates, + }, + value, + } + } + return { + state: { callbackResultState, subSpringStates: newSubSpringStates }, + value: callbackResult, } - const value = cb(evalSpring) - return { state: newSubSpringStates, value } }, - getInitialState: () => [], } return spring } @@ -244,17 +293,24 @@ export const springedObject = ( ): Springed, SpringedObjectState> => { const spring: Springed, SpringedObjectState> = { [springed]: true, - compose(createSpring) { + compose(wrapperSpring) { const modifiedObject = Object.fromEntries( Object.entries(input).map(([key, val]) => { if (isSpringed(val) || typeof val === 'number') - return [key, createSpring] + return [key, wrapperSpring] return [key, val] }), ) return springedObject(modifiedObject) as Springed> }, target: input, + measure(el, state) { + const newState: SpringedObjectState = {} + Object.entries(input).forEach(([key, val]: [string, unknown]) => { + if (isSpringed(val)) newState[key] = val.measure(el, state?.[key]) + }) + return newState + }, computeValue(state, springCache) { const newState: SpringedObjectState = {} const value = Object.fromEntries( @@ -262,7 +318,7 @@ export const springedObject = ( if (!isSpringed(val)) return [key, val] const result = computeSpring( val as Springed, - state[key] || val.getInitialState(springCache), + state?.[key], springCache, ) newState[key] = result.state @@ -272,14 +328,13 @@ export const springedObject = ( return { state: newState, value } }, type: SpringType.other, - getInitialState: () => ({}), } return spring } const computeSpring = ( spring: Springed, - state: S, + state: S | undefined, springCache: SpringCache, ) => { const cachedMatch = springCache.get(spring) @@ -320,6 +375,39 @@ const getSpringFramePosition = ( return { state, value: state.position } } +/** Offset value */ +type MeasuredSpringState = number + +export const measure = ( + getOffset: (elSnapshot: HTMLElement) => number, +): Springed => { + const spring: Springed = { + [springed]: true, + type: SpringType.other, + measure: (el, state) => { + return getOffset(el) + }, + compose: wrapperSpring => + createDerivedNumberSpring([spring], ([offset]) => { + // console.log(offset) + const wrappedSpring = wrapperSpring(offset) + // const originalCompute = wrappedSpring.computeValue + // wrappedSpring.computeValue = (state, springCache) => { + + // } + return offset + }), + computeValue(offset) { + const value = offset || 0 + return { + state: offset || 0, + value, + } + }, + } + return spring +} + const isSpringed = (prop: any): prop is Springed => prop[springed] type AnimatifyProps = { @@ -351,15 +439,15 @@ export const Animated = new Proxy({} as any, { const staticProps = Object.fromEntries( propsEntries.filter(([, val]) => !isSpringed(val)), ) - const propsSpring = springedObject(Object.fromEntries( - propsEntries.filter(([, val]) => isSpringed(val)), - ) as typeof props) - const elementRef = useRef() - const propsStateRef = useRef( - propsSpring.getInitialState(new Map()), + const propsSpring = springedObject( + Object.fromEntries( + propsEntries.filter(([, val]) => isSpringed(val)), + ) as typeof props, ) - useEffect(() => { - const updateComponentSprings = () => { + const elementRef = useRef() + const propsStateRef = useRef(undefined) + useLayoutEffect(() => { + const updateComponentSprings = (propsChanged = false) => () => { const element = elementRef.current const tweenedPropsResult = computeSpring( propsSpring, @@ -373,6 +461,7 @@ export const Animated = new Proxy({} as any, { ;(element as HTMLElement).style.cssText = tweenedProps.style } else { for (const key in tweenedProps.style) { + // @ts-ignore ;(element as HTMLElement).style[key] = tweenedProps.style[key] } } @@ -384,13 +473,22 @@ export const Animated = new Proxy({} as any, { ) } } + + if (propsChanged) { + propsStateRef.current = propsSpring.measure( + element as HTMLElement, + propsStateRef.current, + ) + updateComponentSprings(false) + } } timeoutId = setTimeout( - () => (rafId = requestAnimationFrame(updateComponentSprings)), - 1, + () => + (rafId = requestAnimationFrame(updateComponentSprings(false))), + 10, ) as any } - let rafId = requestAnimationFrame(updateComponentSprings) + let rafId = requestAnimationFrame(updateComponentSprings(true)) let timeoutId: number return () => { clearTimeout(timeoutId) @@ -417,32 +515,13 @@ export const tweenColor = ( // We have to check that the property still exists, otherwise the property has been rejected by the css parser const targetColor = colorEl.style.color && getComputedStyle(colorEl).color colorEl.style.color = '' - const [targetRed = 0, targetGreen = 0, targetBlue = 0, targetAlpha = 1] = - (targetColor && targetColor.match(colorRegex)) || [] - const red = spring(Number(targetRed)) - const green = spring(Number(targetGreen)) - const blue = spring(Number(targetBlue)) - const alpha = spring(Number(targetAlpha)) - - return { - [springed]: true, - subSprings: { red, green, blue, alpha }, - composeTweenedValues: ({ red, green, blue, alpha }) => - `rgba(${red},${green},${blue},${alpha})`, - } -} - -export const tweenLength = ( - spring: CreateSpring, - targetVal: string, - measure: (el: HTMLElement) => number, -): Springed => { - let v: number - return { - [springed]: true, - targetVal, - measure: el => (v = measure(el)), - subSprings: { v: spring(v || 0) }, - composeTweenedValues: ({ v }) => v, - } + const [targetRed, targetGreen, targetBlue, targetAlpha] = + targetColor?.match(colorRegex) || [] + + return createDerivedSpring( + [targetRed, targetGreen, targetBlue, targetAlpha].map(v => + spring(Number(v || 0)), + ), + ([red, green, blue, alpha]) => `rgba(${red},${green},${blue},${alpha})`, + ) }