Skip to content

Commit 6f170fc

Browse files
authored
feat: trigger hardware accelerated transition if possible (#10)
* wip: linear easing function * feat: trigger hardware accelerated transition if possible * test: fix test cases
1 parent 9a77969 commit 6f170fc

File tree

5 files changed

+167
-21
lines changed

5 files changed

+167
-21
lines changed

src/core/animate.ts

+127-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
springSettlingDuration,
88
Spring,
99
} from './spring'
10-
import { isBrowserSupported, mapValues, zip } from './utils'
10+
import { isBrowserSupported, mapValues, range, zip } from './utils'
1111
import {
1212
ParsedStyleValue,
1313
completeParsedStyleUnit,
@@ -85,7 +85,16 @@ export function animate<T extends Record<string, [AnimateValue, AnimateValue]>>(
8585
set,
8686
})
8787

88-
if (isBrowserSupported()) {
88+
if (canUseLinearTimingFunction(parsedFromTo, options.velocity)) {
89+
animateWithLinearTimingFunction({
90+
spring,
91+
fromTo: parsedFromTo,
92+
velocity: options.velocity,
93+
duration,
94+
settlingDuration,
95+
set,
96+
})
97+
} else if (isBrowserSupported()) {
8998
animateWithCssTransition({
9099
spring,
91100
fromTo: parsedFromTo,
@@ -106,6 +115,122 @@ export function animate<T extends Record<string, [AnimateValue, AnimateValue]>>(
106115
return ctx
107116
}
108117

118+
/**
119+
* Check if the animation can be done with linear() timing function.
120+
* The animation can be done with linear() timing function if:
121+
* - All the velocities in the same property are zero or
122+
* - Only one value will be animated in the same property.
123+
*/
124+
function canUseLinearTimingFunction(
125+
fromTo: Record<string, [ParsedStyleValue, ParsedStyleValue]>,
126+
velocity: Partial<Record<string, number[]>> | undefined,
127+
): boolean {
128+
if (!velocity) {
129+
return true
130+
}
131+
132+
return Object.keys(fromTo).every((key) => {
133+
const [from, to] = fromTo[key]!
134+
const velocities = velocity[key]
135+
136+
if (!velocities || velocities.every((v) => v === 0)) {
137+
return true
138+
}
139+
140+
const animatedValues = zip(from.values, to.values).filter(([from, to]) => {
141+
return from !== to
142+
})
143+
if (animatedValues.length <= 1) {
144+
return true
145+
}
146+
147+
return false
148+
})
149+
}
150+
151+
function animateWithLinearTimingFunction({
152+
spring,
153+
fromTo,
154+
velocity,
155+
duration,
156+
settlingDuration,
157+
set,
158+
}: {
159+
spring: Spring
160+
fromTo: Record<string, [ParsedStyleValue, ParsedStyleValue]>
161+
velocity: Partial<Record<string, number[]>> | undefined
162+
duration: number
163+
settlingDuration: number
164+
set: (style: Record<string, string>) => void
165+
}): void {
166+
const fromStyle = mapValues(fromTo, ([from, to]) => {
167+
// Skip animation if the value is not consistent
168+
if (from.values.length !== to.values.length) {
169+
return interpolateParsedStyle(to, to.values)
170+
}
171+
172+
return interpolateParsedStyle(from, from.values)
173+
})
174+
175+
set({
176+
...fromStyle,
177+
transition: 'none',
178+
})
179+
180+
requestAnimationFrame(() => {
181+
requestAnimationFrame(() => {
182+
const toStyle = mapValues(fromTo, ([_from, to]) => {
183+
return interpolateParsedStyle(to, to.values)
184+
})
185+
186+
// 60fps
187+
const steps = settlingDuration / 60
188+
189+
const easingValues = mapValues(fromTo, ([from, to], key) => {
190+
const initialVelocity = zip(from.values, to.values).reduce<
191+
number | undefined
192+
>((acc, [from, to], i) => {
193+
if (acc !== undefined) {
194+
return acc
195+
}
196+
197+
if (from === to) {
198+
return undefined
199+
}
200+
201+
return (velocity?.[key]?.[i] ?? 0) / (to - from)
202+
}, undefined)
203+
204+
return range(0, steps + 1).map((i) => {
205+
const t = (i / steps) * (settlingDuration / duration)
206+
const value = springValue(spring, {
207+
time: t,
208+
from: 0,
209+
to: 1,
210+
initialVelocity: initialVelocity ?? 0,
211+
})
212+
return value
213+
})
214+
})
215+
216+
const transition = Object.entries(easingValues)
217+
.map(([key, value]) => {
218+
if (value) {
219+
return `${key} ${settlingDuration}ms linear(${value.join(',')})`
220+
} else {
221+
return `${key} 0s`
222+
}
223+
})
224+
.join(',')
225+
226+
set({
227+
...toStyle,
228+
transition,
229+
})
230+
})
231+
})
232+
}
233+
109234
function animateWithCssTransition({
110235
spring,
111236
fromTo,

src/core/utils.ts

+8
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ export function zip<T>(a: readonly T[], b: readonly T[]): [T, T][] {
1818
return result
1919
}
2020

21+
export function range(start: number, end: number): number[] {
22+
const result = []
23+
for (let i = start; i < end; i++) {
24+
result.push(i)
25+
}
26+
return result
27+
}
28+
2129
export function isBrowserSupported(): boolean {
2230
return (
2331
typeof CSS !== 'undefined' &&

test/core/animate.test.ts

+10-7
Original file line numberDiff line numberDiff line change
@@ -186,18 +186,21 @@ describe('animate', () => {
186186
value = v
187187
},
188188
{
189-
duration: 10,
189+
duration: 100,
190190
},
191191
)
192-
expect(value?.x).not.toBe('0')
193-
expect(value?.x).not.toBe('10')
194-
expect(value?.x).not.toMatch(/\d+px/)
195-
expect(value?.y).not.toBe('0px')
196-
expect(value?.y).not.toBe('20px')
197-
expect(value?.y).toMatch(/\d+px/)
192+
expect(value?.x).toBe('0')
193+
expect(value?.y).toBe('0px')
194+
expect(value?.transition).toBe('none')
195+
await raf()
196+
await raf()
197+
expect(value?.x).toBe('10')
198+
expect(value?.y).toBe('20px')
199+
expect(value?.transition).not.toBe('none')
198200
await ctx.settlingPromise
199201
expect(value?.x).toBe('10')
200202
expect(value?.y).toBe('20px')
203+
expect(value?.transition).toBe('')
201204
})
202205

203206
test('pass to style value immediately if the value is not animatable (from is not animatable)', async () => {

test/core/controller.test.ts

+21-11
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ vitest.mock('../../src/core/utils', async () => {
1010
}
1111
})
1212

13+
function raf(): Promise<void> {
14+
return new Promise((resolve) => requestAnimationFrame(() => resolve()))
15+
}
16+
1317
describe('AnimationController', () => {
1418
test('set initial style', () => {
1519
let actual: Record<string, string> | undefined
@@ -33,7 +37,7 @@ describe('AnimationController', () => {
3337
expect(actual).toEqual({ width: '200px', transition: '', [t]: '' })
3438
})
3539

36-
test('set style with animation', () => {
40+
test('set style with animation', async () => {
3741
let actual: Record<string, string> | undefined
3842
const controller = createAnimateController((style) => {
3943
actual = style
@@ -43,10 +47,12 @@ describe('AnimationController', () => {
4347
expect(actual?.width).toEqual('100px')
4448

4549
controller.setStyle({ width: `200px` })
46-
expect(actual?.width).not.toBe('100px')
47-
expect(actual?.width).not.toBe('200px')
48-
expect(actual?.transition).not.toBe('')
49-
expect(actual?.[t]).not.toBe('')
50+
expect(actual?.width).toBe('100px')
51+
expect(actual?.transition).toBe('none')
52+
await raf()
53+
await raf()
54+
expect(actual?.width).toBe('200px')
55+
expect(actual?.[t]).not.toBe('none')
5056
})
5157

5258
test('set style without animation if the value is not animatable', () => {
@@ -77,7 +83,7 @@ describe('AnimationController', () => {
7783
expect(actual?.[t]).toBe('')
7884
})
7985

80-
test('trigger animation if any style value is different', () => {
86+
test('trigger animation if any style value is different', async () => {
8187
let actual: Record<string, string> | undefined
8288
const controller = createAnimateController((style) => {
8389
actual = style
@@ -88,10 +94,14 @@ describe('AnimationController', () => {
8894
expect(actual?.height).toBe('200px')
8995

9096
controller.setStyle({ width: `100px`, height: `100px` })
91-
expect(actual?.width).not.toBe('100px')
92-
expect(actual?.height).not.toBe('200px')
93-
expect(actual?.transition).not.toBe('')
94-
expect(actual?.[t]).not.toBe('')
97+
expect(actual?.width).toBe('100px')
98+
expect(actual?.height).toBe('200px')
99+
expect(actual?.transition).toBe('none')
100+
await raf()
101+
await raf()
102+
expect(actual?.width).toBe('100px')
103+
expect(actual?.height).toBe('100px')
104+
expect(actual?.transition).not.toBe('none')
95105
})
96106

97107
test('complete style unit if the value is 0 without unit', () => {
@@ -212,7 +222,7 @@ describe('AnimationController', () => {
212222
const controller = createAnimateController((_style) => {
213223
style = _style
214224
})
215-
controller.setOptions({ duration: 10 })
225+
controller.setOptions({ duration: 100 })
216226
controller.setStyle({ width: '100px' }, { animate: false })
217227
controller.setStyle({ width: '200px' })
218228

test/vue/use-spring.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ describe('useSpring', () => {
235235
width: `${value.value}px`,
236236
}),
237237
{
238-
duration: 20,
238+
duration: 100,
239239
},
240240
)
241241

0 commit comments

Comments
 (0)