2
* Copyright (c) 2022-2024 Huawei Device Co., Ltd.
3
* Licensed under the Apache License, Version 2.0 (the "License");
4
* you may not use this file except in compliance with the License.
5
* You may obtain a copy of the License at
7
* http://www.apache.org/licenses/LICENSE-2.0
9
* Unless required by applicable law or agreed to in writing, software
10
* distributed under the License is distributed on an "AS IS" BASIS,
11
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
* See the License for the specific language governing permissions and
13
* limitations under the License.
16
import { float64, int32, int64, isFiniteNumber, uint32, uint64 } from "@koalaui/common"
17
import { AnimationRange, NumberAnimationRange } from "./AnimationRange"
18
import { Easing, EasingCurve } from "./Easing"
19
import { scheduleCallback } from "../states/GlobalStateManager"
21
export const DEFAULT_ANIMATION_DURATION: uint32 = 300
24
* This interface specifies animations in AnimatedState (or MutableAnimatedState).
25
* Its methods should not be used directly.
27
export interface TimeAnimation<Value> {
28
readonly running: boolean
29
getValue(time: uint64): Value
30
onStart(time: uint64): void
31
onPause(time: uint64): void
35
* This interface allows to control various settings of a duration-based animation.
37
export interface AnimationSpec {
38
readonly duration: uint32
39
readonly delay: uint32
40
readonly easing: EasingCurve
41
readonly onEdge: OnEdge
42
readonly onPause: OnPause
43
readonly iterations: int32 | undefined // an amount of iterations of the specified function
44
readonly onStart: (() => void) | undefined // called when the animation has started
45
readonly onEnd: (() => void) | undefined // called when the animation has reached the target value
46
readonly onReset: (() => void) | undefined // called when the animation is reset to the initial value
50
* This enumeration specifies the animation behavior when duration is expired.
53
Nothing, // the value stops changing
54
Reverse, // the value starts changing to the initial state
55
Restart, // the value starts changing from the initial state again
59
* This enumeration specifies the animation behavior on pause requested.
62
Nothing, // the value stops changing immediately
63
Reset, // the value resets to the initial state and stops changing
64
Fade, // the value starts changing to the initial state and then stops changing
69
* @param value - value constantly returned by the animation
70
* @returns animation that always produces the same value
72
export function constAnimation<Value>(value: Value): TimeAnimation<Value> {
73
return new ConstAnimationImpl<Value>(value)
77
* @param compute - value supplier to be computed for every frame
78
* @param initialTime - initial time value of the animation
79
* @returns simple time-based animation that starts with 0
81
export function timeAnimation<Value>(compute: (time: int64) => Value, initialTime: int64 = 0): TimeAnimation<Value> {
82
if (!isFiniteNumber(initialTime)) throw new Error("illegal initial time: " + initialTime)
83
return new TimeAnimationImpl<Value>(compute, initialTime)
87
* Computes smoothly changing values from `from` to `to` with given period in milliseconds.
88
* Current law of change is sinus.
90
* @param period during which value changes from `from` back to `from`
91
* @param from which value animation starts, default is 0.0
92
* @param to which value animation reaches, default is 1.0
93
* @returns animated value in 0.0 to 1.0 range
95
export function smoothAnimation(period: uint32, from: float64 = 0.0, to: float64 = 1.0): TimeAnimation<float64> {
96
if (!isFiniteNumber(period) || (period < 1)) throw new Error("illegal period: " + period)
97
if (from >= to) throw new Error("`from` must be smaller than `to`")
98
return new TimeAnimationImpl<float64>((time: int64): float64 => (1 - Math.cos(time / period * Math.PI)) / 2 * (to - from) + from)
102
* @param period - number of milliseconds after which the animated state toggles boolean value
103
* @param initialValue - initial blinking state of the animation
104
* @returns animation of boolean value that is toggled periodically
106
export function blinkAnimation(period: uint32, initialValue: boolean = false): TimeAnimation<boolean> {
107
return periodicAnimation<boolean>(period, (count: int64): boolean => (count % 2) != 0, initialValue ? 1 : 0)
111
* @param period - number of milliseconds after which the animated state increments number value
112
* @param initialCount - initial counter value of the animation
113
* @returns animation of integer value that is incremented periodically
115
export function countAnimation(period: uint32, initialCount: int64 = 0): TimeAnimation<int64> {
116
return periodicAnimation<int64>(period, (count: int64): int64 => count, initialCount)
120
* @param period - number of milliseconds after which the animated state computes its value from period counter
121
* @param compute - value supplier to be computed every `period` milliseconds
122
* @param initialCount - initial counter value of the animation
123
* @returns animation of integer value that is computed periodically from counter
125
export function periodicAnimation<Value>(period: uint32, compute: (count: int64) => Value, initialCount: int64 = 0): TimeAnimation<Value> {
126
if (!isFiniteNumber(period) || (period < 1)) throw new Error("illegal time period: " + period)
127
if (!isFiniteNumber(initialCount)) throw new Error("illegal initial count: " + initialCount)
128
return new PeriodicAnimationImpl<Value>(0, period, compute, initialCount)
132
* @param delay - number of milliseconds after which the animated state computes its first value
133
* @param period - number of milliseconds after which the animated state computes its value from period counter
134
* @param compute - value supplier to be computed every `period` milliseconds
135
* @param initialCount - initial counter value of the animation
136
* @returns animation of integer value that is computed periodically from counter
138
export function periodicAnimationWithDelay<Value>(delay: uint32, period: uint32, compute: (count: int64) => Value, initialCount: int64 = 0): TimeAnimation<Value> {
139
if (!isFiniteNumber(period) || (period < 1)) throw new Error("illegal time period: " + period)
140
if (!isFiniteNumber(delay) || (delay < 1)) throw new Error("illegal time delay: " + delay)
141
if (!isFiniteNumber(initialCount)) throw new Error("illegal initial count: " + initialCount)
142
return new PeriodicAnimationImpl<Value>(delay - period, period, compute, initialCount)
147
* @param frameTime - array of frame durations in milliseconds
148
* @param compute - value supplier to be computed when frame index is changed
149
* @returns frame-based animation
151
export function frameAnimation<Value>(frameTime: ReadonlyArray<uint32>, compute: (index: int64) => Value): TimeAnimation<Value> {
152
const count = frameTime.length
153
if (count == 1) return constAnimation<Value>(compute(0))
154
if (count < 2) throw new Error("illegal frames count: " + count)
155
const time = new Array<uint32>(count)
156
for (let index = 0; index < count; index++) {
157
const value = frameTime[index]
158
if (!isFiniteNumber(value) || (value < 1)) throw new Error("illegal time of frame " + index + ": " + value)
159
time[index] = index > 0 ? time[index - 1] + value : value
161
return new FrameAnimationImpl<Value>(time, compute)
166
* @param spec - the animation specification
167
* @param from - a first base array that corresponds to the `0` state
168
* @param to - a second base array that corresponds to the `1` state
169
* @returns duration-based animation of number value
171
export function numberAnimation(spec: Partial<AnimationSpec>, to: float64 = 1.0, from: float64 = 0.0): TimeAnimation<float64> {
172
return animation<float64>(spec, NumberAnimationRange(from, to))
176
* @param spec - the animation specification
177
* @param compute - value supplier to be computed when state animated from 0 to 1
178
* @param initialState - initial inner state of the animation (-1..1]
179
* @returns duration-based animation
181
export function animation<Value>(spec: Partial<AnimationSpec>, compute: AnimationRange<Value>, initialState: float64 = 0): TimeAnimation<Value> {
182
if (!isFiniteNumber(initialState) || (initialState <= -1) || (initialState > 1)) throw new Error("illegal initial state: " + initialState)
183
let duration: uint32 = spec.duration ?? DEFAULT_ANIMATION_DURATION
184
if (!isFiniteNumber(duration) || (duration < 0)) throw new Error("duration must not be negative, but is " + spec.duration)
185
let delay: uint32 = spec.delay ?? 0
186
if (!isFiniteNumber(delay) || (delay < 0)) throw new Error("delay must not be negative, but is " + spec.delay)
187
let easing = spec.easing ?? Easing.Linear
188
let onEdge = spec.onEdge ?? OnEdge.Nothing
189
let iterations = spec.iterations
190
if (iterations !== undefined) {
191
if (!Number.isInteger(iterations) || (iterations < 1)) throw new Error("iterations must be positive integer, but is " + spec.iterations)
192
if (onEdge == OnEdge.Reverse) {
193
easing = Easing.thereAndBackAgain(easing)
196
if (iterations > 1) {
197
easing = Easing.repeated(easing, iterations)
198
duration *= iterations
200
if (onEdge == OnEdge.Restart) {
201
easing = Easing.restarted(easing)
203
onEdge = OnEdge.Nothing
205
if (duration == 0 && onEdge != OnEdge.Nothing) {
206
throw new Error("cyclic animation must have a positive duration, but has " + spec.duration)
208
return new AnimationImpl<Value>(
209
duration, delay, easing, onEdge, spec.onPause ?? OnPause.Nothing,
210
spec.onStart, spec.onEnd, spec.onReset, compute, initialState)
215
* @param duration - duration of state transition from 0 to 1
216
* @param easing - a way in which a motion tween proceeds
217
* @param compute - value supplier to be computed when state animated from 0 to 1
218
* @param initialState - initial inner state of the animation (-1..1]
219
* @returns duration-based value transition
221
export function transition<Value>(duration: uint32, easing: EasingCurve, compute: AnimationRange<Value>, initialState: int64 = 0): TimeAnimation<Value> {
222
if (!isFiniteNumber(initialState) || (initialState <= -1) || (initialState > 1)) throw new Error("illegal initial state: " + initialState)
223
if (!isFiniteNumber(duration) || (duration <= 0)) throw new Error("duration must be positive, but is " + duration)
224
return new AnimationImpl<Value>(duration, 0, easing, OnEdge.Nothing, OnPause.Fade, undefined, undefined, undefined, compute, initialState)
228
* @param duration - duration of state transition from 0 to 1
229
* @param compute - value supplier to be computed when state animated from 0 to 1
230
* @param initialState - initial inner state of the animation (-1..1]
231
* @returns duration-based value transition
233
export function linearTransition<Value>(duration: uint32, compute: AnimationRange<Value>, initialState: int64 = 0): TimeAnimation<Value> {
234
return transition<Value>(duration, Easing.Linear, compute, initialState)
238
// IMPLEMENTATION DETAILS: DO NOT USE IT DIRECTLY
241
class TimeAnimationImpl<Value> implements TimeAnimation<Value> {
242
private startTime: uint64 | undefined
243
private lastState: int64
244
private lastValue: Value
245
private readonly compute: (time: int64) => Value
249
constructor(compute: (time: int64) => Value, initial: int64 = 0) {
250
this.lastState = initial
251
this.lastValue = compute(initial)
252
this.compute = compute
256
return this.lastState
259
getState(startTime: uint64, currentTime: uint64): int64 {
260
this.startTime = currentTime
261
return this.lastState + (currentTime - startTime)
264
getValue(time: uint64): Value {
265
if (this.startTime === undefined) return this.lastValue // paused
266
if (this.startTime! >= time) return this.lastValue // delayed
267
const state = this.getState(this.startTime!, time)
268
if (this.lastState == state) return this.lastValue // not changed
269
this.lastState = state
270
this.lastValue = this.compute(state)
271
return this.lastValue
274
onStart(time: uint64): void {
275
this.startTime = time
279
onPause(time: uint64): void {
280
this.startTime = undefined
286
class PeriodicAnimationImpl<Value> extends TimeAnimationImpl<Value> {
287
private readonly period: uint32
288
private readonly delay: int32
290
constructor(delay: int32, period: uint32, compute: (count: int64) => Value, initial: int64 = 0) {
291
super(compute, initial)
296
getState(startTime: uint64, currentTime: uint64): int64 {
297
let result = this.state
298
let passedTime = currentTime - startTime
299
if (passedTime > this.period) {
300
result += Math.floor(passedTime / this.period)
301
passedTime = passedTime % this.period
302
// tune start time for long animations
303
super.onStart(currentTime - passedTime)
308
onStart(time: uint64) {
309
super.onStart(time + this.delay)
314
class ConstAnimationImpl<Value> implements TimeAnimation<Value> {
315
private lastValue: Value
317
constructor(value: Value) {
318
this.lastValue = value
321
readonly running: boolean = false
323
getValue(time: uint64): Value {
324
return this.lastValue
327
onStart(time: uint64): void {
330
onPause(time: uint64): void {
335
class FrameAnimationImpl<Value> extends TimeAnimationImpl<Value> {
336
private readonly time: ReadonlyArray<uint32>
338
constructor(time: ReadonlyArray<uint32>, compute: (time: int64) => Value) {
343
getState(startTime: uint64, currentTime: uint64): int64 {
344
const cycleTime = this.time[this.time.length - 1]
346
let passedTime = currentTime - startTime
347
if (passedTime > cycleTime) {
348
passedTime = passedTime % cycleTime
349
// tune start time for long animations
350
super.onStart(currentTime - passedTime)
352
for (let index = 0; index < this.time.length; index++) {
353
if (passedTime < this.time[index]) return index
360
class AnimationImpl<Value> implements TimeAnimation<Value> {
361
private startTime: uint64 | undefined
362
private lastState: float64
363
private lastValue: Value
364
private readonly duration: uint32
365
private readonly delay: uint32
366
private readonly onEdgePolicy: OnEdge
367
private readonly onPausePolicy: OnPause
368
private readonly onStartCallback: (() => void) | undefined
369
private readonly onEndCallback: (() => void) | undefined
370
private readonly onResetCallback: (() => void) | undefined
371
private readonly compute: AnimationRange<Value>
372
private isPauseRequested = false
382
onStart: (() => void) | undefined,
383
onEnd: (() => void) | undefined,
384
onReset: (() => void) | undefined,
385
compute: AnimationRange<Value>,
388
this.duration = duration
390
this.onEdgePolicy = onEdge
391
this.onPausePolicy = onPause
392
this.onStartCallback = onStart
393
this.onEndCallback = onEnd
394
this.onResetCallback = onReset
395
this.compute = (state: float64): Value => compute(easing(Math.abs(state)))
396
this.lastState = initial
397
this.lastValue = this.compute(initial)
400
getState(startTime: uint64, currentTime: uint64): float64 {
401
const onPause = this.isPauseRequested ? this.onPausePolicy : OnPause.Nothing
402
if (onPause == OnPause.Reset) return this.onPauseReset() // pause
403
if (this.duration == 0) return this.onEdgeReached() // stop immediately
405
const cycleTime: uint64 = onPause == OnPause.Fade || this.onEdgePolicy == OnEdge.Reverse
409
let passedTime = currentTime - startTime
410
if (passedTime > cycleTime) {
411
if (onPause == OnPause.Fade) return this.onPauseReset() // fade stopped
412
if (this.onEdgePolicy == OnEdge.Nothing) return this.onEdgeReached() // stop on the edge
413
passedTime = passedTime % cycleTime
414
// tune start time for long animations
416
this.startTime = currentTime - passedTime
418
let state = passedTime / this.duration
419
return state > 1 ? state - 2 : state
422
getValue(time: uint64): Value {
423
if (this.startTime === undefined) return this.lastValue // paused
424
if (this.startTime! > time) return this.lastValue // delayed
425
const state = this.getState(this.startTime!, time)
426
if (this.lastState == state) return this.lastValue // not changed
427
this.lastState = state
428
this.lastValue = this.compute(state)
429
return this.lastValue
432
onStart(time: uint64): void {
433
scheduleCallback(this.onStartCallback)
434
if (this.isPauseRequested) {
435
this.isPauseRequested = false
436
if (this.lastState < 0) {
437
// tune start time on direction change
439
this.startTime = time + this.lastState * this.duration
443
// set start time to continue animation from the current state
445
this.startTime = time - (
447
? (2 + this.lastState) * this.duration
449
? this.lastState * this.duration
450
: (0 - this.delay) // add delay for the state 0 only
455
onPause(time: uint64): void {
456
if (this.lastState && this.onPausePolicy == OnPause.Reset) {
457
this.isPauseRequested = true
459
else if (this.lastState && this.onPausePolicy == OnPause.Fade) {
460
this.isPauseRequested = true
461
if (this.lastState > 0) {
462
// tune start time on direction change
464
this.startTime = time - this.duration * (2 - this.lastState)
469
this.startTime = undefined
473
private onPauseReset(): float64 {
475
this.startTime = undefined
476
this.isPauseRequested = false
477
scheduleCallback(this.onResetCallback)
481
private onEdgeReached(): float64 {
484
scheduleCallback(this.onEndCallback)