AcceleratedAnimation.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import { supportsLinearEasing, startWaapiAnimation, attachTimeline, isGenerator, isWaapiSupportedEasing } from 'motion-dom';
  2. import { millisecondsToSeconds, secondsToMilliseconds, noop } from 'motion-utils';
  3. import { anticipate } from '../../easing/anticipate.mjs';
  4. import { backInOut } from '../../easing/back.mjs';
  5. import { circInOut } from '../../easing/circ.mjs';
  6. import { DOMKeyframesResolver } from '../../render/dom/DOMKeyframesResolver.mjs';
  7. import { BaseAnimation } from './BaseAnimation.mjs';
  8. import { MainThreadAnimation } from './MainThreadAnimation.mjs';
  9. import { acceleratedValues } from './utils/accelerated-values.mjs';
  10. import { getFinalKeyframe } from './waapi/utils/get-final-keyframe.mjs';
  11. import { supportsWaapi } from './waapi/utils/supports-waapi.mjs';
  12. /**
  13. * 10ms is chosen here as it strikes a balance between smooth
  14. * results (more than one keyframe per frame at 60fps) and
  15. * keyframe quantity.
  16. */
  17. const sampleDelta = 10; //ms
  18. /**
  19. * Implement a practical max duration for keyframe generation
  20. * to prevent infinite loops
  21. */
  22. const maxDuration = 20000;
  23. /**
  24. * Check if an animation can run natively via WAAPI or requires pregenerated keyframes.
  25. * WAAPI doesn't support spring or function easings so we run these as JS animation before
  26. * handing off.
  27. */
  28. function requiresPregeneratedKeyframes(options) {
  29. return (isGenerator(options.type) ||
  30. options.type === "spring" ||
  31. !isWaapiSupportedEasing(options.ease));
  32. }
  33. function pregenerateKeyframes(keyframes, options) {
  34. /**
  35. * Create a main-thread animation to pregenerate keyframes.
  36. * We sample this at regular intervals to generate keyframes that we then
  37. * linearly interpolate between.
  38. */
  39. const sampleAnimation = new MainThreadAnimation({
  40. ...options,
  41. keyframes,
  42. repeat: 0,
  43. delay: 0,
  44. isGenerator: true,
  45. });
  46. let state = { done: false, value: keyframes[0] };
  47. const pregeneratedKeyframes = [];
  48. /**
  49. * Bail after 20 seconds of pre-generated keyframes as it's likely
  50. * we're heading for an infinite loop.
  51. */
  52. let t = 0;
  53. while (!state.done && t < maxDuration) {
  54. state = sampleAnimation.sample(t);
  55. pregeneratedKeyframes.push(state.value);
  56. t += sampleDelta;
  57. }
  58. return {
  59. times: undefined,
  60. keyframes: pregeneratedKeyframes,
  61. duration: t - sampleDelta,
  62. ease: "linear",
  63. };
  64. }
  65. const unsupportedEasingFunctions = {
  66. anticipate,
  67. backInOut,
  68. circInOut,
  69. };
  70. function isUnsupportedEase(key) {
  71. return key in unsupportedEasingFunctions;
  72. }
  73. class AcceleratedAnimation extends BaseAnimation {
  74. constructor(options) {
  75. super(options);
  76. const { name, motionValue, element, keyframes } = this.options;
  77. this.resolver = new DOMKeyframesResolver(keyframes, (resolvedKeyframes, finalKeyframe) => this.onKeyframesResolved(resolvedKeyframes, finalKeyframe), name, motionValue, element);
  78. this.resolver.scheduleResolve();
  79. }
  80. initPlayback(keyframes, finalKeyframe) {
  81. let { duration = 300, times, ease, type, motionValue, name, startTime, } = this.options;
  82. /**
  83. * If element has since been unmounted, return false to indicate
  84. * the animation failed to initialised.
  85. */
  86. if (!motionValue.owner || !motionValue.owner.current) {
  87. return false;
  88. }
  89. /**
  90. * If the user has provided an easing function name that isn't supported
  91. * by WAAPI (like "anticipate"), we need to provide the corressponding
  92. * function. This will later get converted to a linear() easing function.
  93. */
  94. if (typeof ease === "string" &&
  95. supportsLinearEasing() &&
  96. isUnsupportedEase(ease)) {
  97. ease = unsupportedEasingFunctions[ease];
  98. }
  99. /**
  100. * If this animation needs pre-generated keyframes then generate.
  101. */
  102. if (requiresPregeneratedKeyframes(this.options)) {
  103. const { onComplete, onUpdate, motionValue, element, ...options } = this.options;
  104. const pregeneratedAnimation = pregenerateKeyframes(keyframes, options);
  105. keyframes = pregeneratedAnimation.keyframes;
  106. // If this is a very short animation, ensure we have
  107. // at least two keyframes to animate between as older browsers
  108. // can't animate between a single keyframe.
  109. if (keyframes.length === 1) {
  110. keyframes[1] = keyframes[0];
  111. }
  112. duration = pregeneratedAnimation.duration;
  113. times = pregeneratedAnimation.times;
  114. ease = pregeneratedAnimation.ease;
  115. type = "keyframes";
  116. }
  117. const animation = startWaapiAnimation(motionValue.owner.current, name, keyframes, { ...this.options, duration, times, ease });
  118. // Override the browser calculated startTime with one synchronised to other JS
  119. // and WAAPI animations starting this event loop.
  120. animation.startTime = startTime ?? this.calcStartTime();
  121. if (this.pendingTimeline) {
  122. attachTimeline(animation, this.pendingTimeline);
  123. this.pendingTimeline = undefined;
  124. }
  125. else {
  126. /**
  127. * Prefer the `onfinish` prop as it's more widely supported than
  128. * the `finished` promise.
  129. *
  130. * Here, we synchronously set the provided MotionValue to the end
  131. * keyframe. If we didn't, when the WAAPI animation is finished it would
  132. * be removed from the element which would then revert to its old styles.
  133. */
  134. animation.onfinish = () => {
  135. const { onComplete } = this.options;
  136. motionValue.set(getFinalKeyframe(keyframes, this.options, finalKeyframe));
  137. onComplete && onComplete();
  138. this.cancel();
  139. this.resolveFinishedPromise();
  140. };
  141. }
  142. return {
  143. animation,
  144. duration,
  145. times,
  146. type,
  147. ease,
  148. keyframes: keyframes,
  149. };
  150. }
  151. get duration() {
  152. const { resolved } = this;
  153. if (!resolved)
  154. return 0;
  155. const { duration } = resolved;
  156. return millisecondsToSeconds(duration);
  157. }
  158. get time() {
  159. const { resolved } = this;
  160. if (!resolved)
  161. return 0;
  162. const { animation } = resolved;
  163. return millisecondsToSeconds(animation.currentTime || 0);
  164. }
  165. set time(newTime) {
  166. const { resolved } = this;
  167. if (!resolved)
  168. return;
  169. const { animation } = resolved;
  170. animation.currentTime = secondsToMilliseconds(newTime);
  171. }
  172. get speed() {
  173. const { resolved } = this;
  174. if (!resolved)
  175. return 1;
  176. const { animation } = resolved;
  177. return animation.playbackRate;
  178. }
  179. get finished() {
  180. return this.resolved.animation.finished;
  181. }
  182. set speed(newSpeed) {
  183. const { resolved } = this;
  184. if (!resolved)
  185. return;
  186. const { animation } = resolved;
  187. animation.playbackRate = newSpeed;
  188. }
  189. get state() {
  190. const { resolved } = this;
  191. if (!resolved)
  192. return "idle";
  193. const { animation } = resolved;
  194. return animation.playState;
  195. }
  196. get startTime() {
  197. const { resolved } = this;
  198. if (!resolved)
  199. return null;
  200. const { animation } = resolved;
  201. // Coerce to number as TypeScript incorrectly types this
  202. // as CSSNumberish
  203. return animation.startTime;
  204. }
  205. /**
  206. * Replace the default DocumentTimeline with another AnimationTimeline.
  207. * Currently used for scroll animations.
  208. */
  209. attachTimeline(timeline) {
  210. if (!this._resolved) {
  211. this.pendingTimeline = timeline;
  212. }
  213. else {
  214. const { resolved } = this;
  215. if (!resolved)
  216. return noop;
  217. const { animation } = resolved;
  218. attachTimeline(animation, timeline);
  219. }
  220. return noop;
  221. }
  222. play() {
  223. if (this.isStopped)
  224. return;
  225. const { resolved } = this;
  226. if (!resolved)
  227. return;
  228. const { animation } = resolved;
  229. if (animation.playState === "finished") {
  230. this.updateFinishedPromise();
  231. }
  232. animation.play();
  233. }
  234. pause() {
  235. const { resolved } = this;
  236. if (!resolved)
  237. return;
  238. const { animation } = resolved;
  239. animation.pause();
  240. }
  241. stop() {
  242. this.resolver.cancel();
  243. this.isStopped = true;
  244. if (this.state === "idle")
  245. return;
  246. this.resolveFinishedPromise();
  247. this.updateFinishedPromise();
  248. const { resolved } = this;
  249. if (!resolved)
  250. return;
  251. const { animation, keyframes, duration, type, ease, times } = resolved;
  252. if (animation.playState === "idle" ||
  253. animation.playState === "finished") {
  254. return;
  255. }
  256. /**
  257. * WAAPI doesn't natively have any interruption capabilities.
  258. *
  259. * Rather than read commited styles back out of the DOM, we can
  260. * create a renderless JS animation and sample it twice to calculate
  261. * its current value, "previous" value, and therefore allow
  262. * Motion to calculate velocity for any subsequent animation.
  263. */
  264. if (this.time) {
  265. const { motionValue, onUpdate, onComplete, element, ...options } = this.options;
  266. const sampleAnimation = new MainThreadAnimation({
  267. ...options,
  268. keyframes,
  269. duration,
  270. type,
  271. ease,
  272. times,
  273. isGenerator: true,
  274. });
  275. const sampleTime = secondsToMilliseconds(this.time);
  276. motionValue.setWithVelocity(sampleAnimation.sample(sampleTime - sampleDelta).value, sampleAnimation.sample(sampleTime).value, sampleDelta);
  277. }
  278. const { onStop } = this.options;
  279. onStop && onStop();
  280. this.cancel();
  281. }
  282. complete() {
  283. const { resolved } = this;
  284. if (!resolved)
  285. return;
  286. resolved.animation.finish();
  287. }
  288. cancel() {
  289. const { resolved } = this;
  290. if (!resolved)
  291. return;
  292. resolved.animation.cancel();
  293. }
  294. static supports(options) {
  295. const { motionValue, name, repeatDelay, repeatType, damping, type } = options;
  296. if (!motionValue ||
  297. !motionValue.owner ||
  298. !(motionValue.owner.current instanceof HTMLElement)) {
  299. return false;
  300. }
  301. const { onUpdate, transformTemplate } = motionValue.owner.getProps();
  302. return (supportsWaapi() &&
  303. name &&
  304. acceleratedValues.has(name) &&
  305. (name !== "transform" || !transformTemplate) &&
  306. /**
  307. * If we're outputting values to onUpdate then we can't use WAAPI as there's
  308. * no way to read the value from WAAPI every frame.
  309. */
  310. !onUpdate &&
  311. !repeatDelay &&
  312. repeatType !== "mirror" &&
  313. damping !== 0 &&
  314. type !== "inertia");
  315. }
  316. }
  317. export { AcceleratedAnimation };