From 892594de0665e8fc5c8a737d292812842ea03d64 Mon Sep 17 00:00:00 2001 From: Sean Perkins <13732623+sean-perkins@users.noreply.github.com> Date: Wed, 20 Mar 2024 11:23:00 -0400 Subject: [PATCH] feat: remove css animations support for ionic animations (#29123) Issue number: Internal --------- ## What is the current behavior? Ionic Framework provides a small utility wrapper around the Web Animations API. Historically not all browsers that Ionic Framework supported, had support for the Web Animations API. To offer backwards compatibility, Ionic Framework provided fallback behaviors for the different wrapped APIs. ## What is the new behavior? - Removes the legacy CSS animations fallback behavior from the Web Animations API animation utility. Maintaining a few no-op behaviors for test environments. - Resolved a few internal type usages that were casting to any - Removed spec tests that were testing the fallback CSS animations behavior and/or already had test coverage from other unit tests. ## Does this introduce a breaking change? - [x] Yes - [ ] No All modern browsers support the Web Animations API today. If a developer needs to target an older browser that does not support Web Animations, they should either use [a polyfill](https://github.com/web-animations/web-animations-js), or implement the fallback behavior themselves. ## Other information --------- Co-authored-by: Liam DeBeasi --- BREAKING.md | 3 + core/src/utils/animation/animation-utils.ts | 129 ------------- core/src/utils/animation/animation.ts | 180 +----------------- .../utils/animation/test/animation.spec.ts | 111 ----------- .../test/animationbuilder/animation.e2e.ts | 5 - .../test/animationbuilder/index.html | 5 - .../animation/test/basic/animation.e2e.ts | 5 - .../src/utils/animation/test/basic/index.html | 5 - .../animation/test/display/animation.e2e.ts | 5 - .../utils/animation/test/display/index.html | 5 - .../animation/test/hooks/animation.e2e.ts | 5 - .../src/utils/animation/test/hooks/index.html | 5 - .../animation/test/multiple/animation.e2e.ts | 8 - .../utils/animation/test/multiple/index.html | 5 - .../src/utils/animation/test/reuse/index.html | 5 - 15 files changed, 13 insertions(+), 468 deletions(-) diff --git a/BREAKING.md b/BREAKING.md index a93fca71fc..a8ce6b1a82 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -60,8 +60,11 @@ This section details the desktop browser, JavaScript framework, and mobile platf | iOS | 15+ | | Android | 5.1+ with Chromium 89+ | +Ionic Framework v8 removes backwards support for CSS Animations in favor of the [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API). All minimum browser versions listed above support the Web Animations API. +

Dark Mode

+ In previous versions, it was recommended to define the dark palette in the following way: ```css diff --git a/core/src/utils/animation/animation-utils.ts b/core/src/utils/animation/animation-utils.ts index 0e8a6fde6f..bfc5e1b2f9 100644 --- a/core/src/utils/animation/animation-utils.ts +++ b/core/src/utils/animation/animation-utils.ts @@ -1,41 +1,5 @@ -import type { AnimationKeyFrames } from './animation-interface'; - let animationPrefix: string | undefined; -/** - * Web Animations requires hyphenated CSS properties - * to be written in camelCase when animating - */ -export const processKeyframes = (keyframes: AnimationKeyFrames) => { - keyframes.forEach((keyframe) => { - for (const key in keyframe) { - // eslint-disable-next-line no-prototype-builtins - if (keyframe.hasOwnProperty(key)) { - const value = keyframe[key]; - - if (key === 'easing') { - const newKey = 'animation-timing-function'; - keyframe[newKey] = value; - delete keyframe[key]; - } else { - const newKey = convertCamelCaseToHypen(key); - - if (newKey !== key) { - keyframe[newKey] = value; - delete keyframe[key]; - } - } - } - } - }); - - return keyframes; -}; - -const convertCamelCaseToHypen = (str: string) => { - return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); -}; - export const getAnimationPrefix = (el: HTMLElement): string => { if (animationPrefix === undefined) { const supportsUnprefixed = el.style.animationName !== undefined; @@ -50,99 +14,6 @@ export const setStyleProperty = (element: HTMLElement, propertyName: string, val element.style.setProperty(prefix + propertyName, value); }; -export const removeStyleProperty = (element: HTMLElement, propertyName: string) => { - const prefix = propertyName.startsWith('animation') ? getAnimationPrefix(element) : ''; - element.style.removeProperty(prefix + propertyName); -}; - -export const animationEnd = (el: HTMLElement | null, callback: (ev?: TransitionEvent) => void) => { - let unRegTrans: (() => void) | undefined; - const opts: AddEventListenerOptions = { passive: true }; - - const unregister = () => { - if (unRegTrans) { - unRegTrans(); - } - }; - - const onTransitionEnd = (ev: Event) => { - if (el === ev.target) { - unregister(); - callback(ev as TransitionEvent); - } - }; - - if (el) { - el.addEventListener('webkitAnimationEnd', onTransitionEnd, opts); - el.addEventListener('animationend', onTransitionEnd, opts); - - unRegTrans = () => { - el.removeEventListener('webkitAnimationEnd', onTransitionEnd, opts); - el.removeEventListener('animationend', onTransitionEnd, opts); - }; - } - - return unregister; -}; - -// TODO(FW-2832): type -export const generateKeyframeRules = (keyframes: any[] = []) => { - return keyframes - .map((keyframe) => { - const offset = keyframe.offset; - - const frameString = []; - for (const property in keyframe) { - // eslint-disable-next-line no-prototype-builtins - if (keyframe.hasOwnProperty(property) && property !== 'offset') { - frameString.push(`${property}: ${keyframe[property]};`); - } - } - - return `${offset * 100}% { ${frameString.join(' ')} }`; - }) - .join(' '); -}; - -const keyframeIds: string[] = []; - -export const generateKeyframeName = (keyframeRules: string) => { - let index = keyframeIds.indexOf(keyframeRules); - if (index < 0) { - index = keyframeIds.push(keyframeRules) - 1; - } - return `ion-animation-${index}`; -}; - -export const getStyleContainer = (element: HTMLElement) => { - // getRootNode is not always available in SSR environments. - // TODO(FW-2832): types - const rootNode = element.getRootNode !== undefined ? (element.getRootNode() as any) : element; - return rootNode.head || rootNode; -}; - -export const createKeyframeStylesheet = ( - keyframeName: string, - keyframeRules: string, - element: HTMLElement -): HTMLElement => { - const styleContainer = getStyleContainer(element); - const keyframePrefix = getAnimationPrefix(element); - - const existingStylesheet = styleContainer.querySelector('#' + keyframeName); - if (existingStylesheet) { - return existingStylesheet; - } - - const stylesheet = (element.ownerDocument ?? document).createElement('style'); - stylesheet.id = keyframeName; - stylesheet.textContent = `@${keyframePrefix}keyframes ${keyframeName} { ${keyframeRules} } @${keyframePrefix}keyframes ${keyframeName}-alt { ${keyframeRules} }`; - - styleContainer.appendChild(stylesheet); - - return stylesheet; -}; - export const addClassToArray = (classes: string[] = [], className: string | string[] | undefined): string[] => { if (className !== undefined) { const classNameToAppend = Array.isArray(className) ? className : [className]; diff --git a/core/src/utils/animation/animation.ts b/core/src/utils/animation/animation.ts index a2376c312a..c7c57b9f87 100644 --- a/core/src/utils/animation/animation.ts +++ b/core/src/utils/animation/animation.ts @@ -1,5 +1,4 @@ import { win } from '../browser'; -import { raf } from '../helpers'; import type { Animation, @@ -12,16 +11,7 @@ import type { AnimationLifecycle, AnimationPlayOptions, } from './animation-interface'; -import { - addClassToArray, - animationEnd, - createKeyframeStylesheet, - generateKeyframeName, - generateKeyframeRules, - processKeyframes, - removeStyleProperty, - setStyleProperty, -} from './animation-utils'; +import { addClassToArray, setStyleProperty } from './animation-utils'; // TODO(FW-2832): types @@ -64,7 +54,6 @@ export const createAnimation = (animationId?: string): Animation => { let willComplete = true; let finished = false; let shouldCalculateNumAnimations = true; - let keyframeName: string | undefined; let ani: Animation; let paused = false; @@ -83,11 +72,17 @@ export const createAnimation = (animationId?: string): Animation => { const supportsAnimationEffect = typeof (AnimationEffect as any) === 'function' || (win !== undefined && typeof (win as any).AnimationEffect === 'function'); + /** + * This is a feature detection for Web Animations. + * + * Certain environments such as emulated browser environments for testing, + * do not support Web Animations. As a result, we need to check for support + * and provide a fallback to test certain functionality related to Web Animations. + */ const supportsWebAnimations = typeof (Element as any) === 'function' && typeof (Element as any).prototype!.animate === 'function' && supportsAnimationEffect; - const ANIMATION_END_FALLBACK_PADDING_MS = 100; const getWebAnimations = () => { return webAnimations; @@ -198,21 +193,6 @@ export const createAnimation = (animationId?: string): Animation => { }); webAnimations.length = 0; - } else { - const elementsArray = elements.slice(); - - raf(() => { - elementsArray.forEach((element) => { - removeStyleProperty(element, 'animation-name'); - removeStyleProperty(element, 'animation-duration'); - removeStyleProperty(element, 'animation-timing-function'); - removeStyleProperty(element, 'animation-iteration-count'); - removeStyleProperty(element, 'animation-delay'); - removeStyleProperty(element, 'animation-play-state'); - removeStyleProperty(element, 'animation-fill-mode'); - removeStyleProperty(element, 'animation-direction'); - }); - }); } }; @@ -533,8 +513,6 @@ export const createAnimation = (animationId?: string): Animation => { animation.effect = newEffect; } }); - } else { - initializeCSSAnimation(); } }; @@ -643,39 +621,6 @@ export const createAnimation = (animationId?: string): Animation => { } }; - const initializeCSSAnimation = (toggleAnimationName = true) => { - cleanUpStyleSheets(); - - const processedKeyframes = processKeyframes(_keyframes); - elements.forEach((element) => { - if (processedKeyframes.length > 0) { - const keyframeRules = generateKeyframeRules(processedKeyframes); - keyframeName = animationId !== undefined ? animationId : generateKeyframeName(keyframeRules); - const stylesheet = createKeyframeStylesheet(keyframeName, keyframeRules, element); - stylesheets.push(stylesheet); - - setStyleProperty(element, 'animation-duration', `${getDuration()}ms`); - setStyleProperty(element, 'animation-timing-function', getEasing()); - setStyleProperty(element, 'animation-delay', `${getDelay()}ms`); - setStyleProperty(element, 'animation-fill-mode', getFill()); - setStyleProperty(element, 'animation-direction', getDirection()); - - const iterationsCount = getIterations() === Infinity ? 'infinite' : getIterations().toString(); - - setStyleProperty(element, 'animation-iteration-count', iterationsCount); - setStyleProperty(element, 'animation-play-state', 'paused'); - - if (toggleAnimationName) { - setStyleProperty(element, 'animation-name', `${stylesheet.id}-alt`); - } - - raf(() => { - setStyleProperty(element, 'animation-name', stylesheet.id || null); - }); - } - }); - }; - const initializeWebAnimation = () => { elements.forEach((element) => { const animation = element.animate(_keyframes, { @@ -700,14 +645,12 @@ export const createAnimation = (animationId?: string): Animation => { } }; - const initializeAnimation = (toggleAnimationName = true) => { + const initializeAnimation = () => { beforeAnimation(); if (_keyframes.length > 0) { if (supportsWebAnimations) { initializeWebAnimation(); - } else { - initializeCSSAnimation(toggleAnimationName); } } @@ -722,15 +665,6 @@ export const createAnimation = (animationId?: string): Animation => { animation.currentTime = animation.effect!.getComputedTiming().delay! + getDuration() * step; animation.pause(); }); - } else { - const animationDuration = `-${getDuration() * step}ms`; - - elements.forEach((element) => { - if (_keyframes.length > 0) { - setStyleProperty(element, 'animation-delay', animationDuration); - setStyleProperty(element, 'animation-play-state', 'paused'); - } - }); } }; @@ -751,35 +685,6 @@ export const createAnimation = (animationId?: string): Animation => { } }; - const updateCSSAnimation = (toggleAnimationName = true, step?: number) => { - raf(() => { - elements.forEach((element) => { - setStyleProperty(element, 'animation-name', keyframeName || null); - setStyleProperty(element, 'animation-duration', `${getDuration()}ms`); - setStyleProperty(element, 'animation-timing-function', getEasing()); - setStyleProperty( - element, - 'animation-delay', - step !== undefined ? `-${step! * getDuration()}ms` : `${getDelay()}ms` - ); - setStyleProperty(element, 'animation-fill-mode', getFill() || null); - setStyleProperty(element, 'animation-direction', getDirection() || null); - - const iterationsCount = getIterations() === Infinity ? 'infinite' : getIterations().toString(); - - setStyleProperty(element, 'animation-iteration-count', iterationsCount); - - if (toggleAnimationName) { - setStyleProperty(element, 'animation-name', `${keyframeName}-alt`); - } - - raf(() => { - setStyleProperty(element, 'animation-name', keyframeName || null); - }); - }); - }); - }; - const update = (deep = false, toggleAnimationName = true, step?: number) => { if (deep) { childAnimations.forEach((animation) => { @@ -789,8 +694,6 @@ export const createAnimation = (animationId?: string): Animation => { if (supportsWebAnimations) { updateWebAnimation(step); - } else { - updateCSSAnimation(toggleAnimationName, step); } return ani; @@ -892,11 +795,6 @@ export const createAnimation = (animationId?: string): Animation => { return ani; }; - const onAnimationEndFallback = () => { - cssAnimationsTimerFallback = undefined; - animationFinish(); - }; - const clearCSSAnimationsTimeout = () => { if (cssAnimationsTimerFallback) { clearTimeout(cssAnimationsTimerFallback); @@ -906,63 +804,7 @@ export const createAnimation = (animationId?: string): Animation => { const playCSSAnimations = () => { clearCSSAnimationsTimeout(); - raf(() => { - elements.forEach((element) => { - if (_keyframes.length > 0) { - setStyleProperty(element, 'animation-play-state', 'running'); - } - }); - }); - - if (_keyframes.length === 0 || elements.length === 0) { - animationFinish(); - } else { - /** - * This is a catchall in the event that a CSS Animation did not finish. - * The Web Animations API has mechanisms in place for preventing this. - * CSS Animations will not fire an `animationend` event - * for elements with `display: none`. The Web Animations API - * accounts for this, but using raw CSS Animations requires - * this workaround. - */ - const animationDelay = getDelay() || 0; - const animationDuration = getDuration() || 0; - const animationIterations = getIterations() || 1; - - // No need to set a timeout when animation has infinite iterations - if (isFinite(animationIterations)) { - cssAnimationsTimerFallback = setTimeout( - onAnimationEndFallback, - animationDelay + animationDuration * animationIterations + ANIMATION_END_FALLBACK_PADDING_MS - ); - } - - animationEnd(elements[0], () => { - clearCSSAnimationsTimeout(); - - /** - * Ensure that clean up - * is always done a frame - * before the onFinish handlers - * are fired. Otherwise, there - * may be flickering if a new - * animation is started on the same - * element too quickly - */ - raf(() => { - clearCSSAnimationPlayState(); - raf(animationFinish); - }); - }); - } - }; - - const clearCSSAnimationPlayState = () => { - elements.forEach((element) => { - removeStyleProperty(element, 'animation-duration'); - removeStyleProperty(element, 'animation-delay'); - removeStyleProperty(element, 'animation-play-state'); - }); + animationFinish(); }; const playWebAnimations = () => { @@ -979,8 +821,6 @@ export const createAnimation = (animationId?: string): Animation => { if (supportsWebAnimations) { setAnimationStep(0); updateWebAnimation(); - } else { - updateCSSAnimation(); } }; diff --git a/core/src/utils/animation/test/animation.spec.ts b/core/src/utils/animation/test/animation.spec.ts index 4e485782ef..7156d7097c 100644 --- a/core/src/utils/animation/test/animation.spec.ts +++ b/core/src/utils/animation/test/animation.spec.ts @@ -1,6 +1,5 @@ import { createAnimation } from '../animation'; import type { Animation } from '../animation-interface'; -import { processKeyframes } from '../animation-utils'; import { getTimeGivenProgression } from '../cubic-bezier'; describe('Animation Class', () => { @@ -53,104 +52,6 @@ describe('Animation Class', () => { animation.play(); expect(animation.isRunning()).toEqual(false); }); - - it('should be running', () => { - const el = document.createElement('div'); - animation.addElement(el); - animation.keyframes([ - { transform: 'scale(1)', opacity: 1, offset: 0 }, - { transform: 'scale(0)', opacity: 0, offset: 1 }, - ]); - animation.duration(250); - - animation.play(); - expect(animation.isRunning()).toEqual(true); - }); - - it('should not be running after finishing the animation', async () => { - const el = document.createElement('div'); - animation.addElement(el); - animation.keyframes([ - { transform: 'scale(1)', opacity: 1, offset: 0 }, - { transform: 'scale(0)', opacity: 0, offset: 1 }, - ]); - animation.duration(250); - - await animation.play(); - - expect(animation.isRunning()).toEqual(false); - }); - - it('should not be running after calling pause', () => { - const el = document.createElement('div'); - animation.addElement(el); - animation.keyframes([ - { transform: 'scale(1)', opacity: 1, offset: 0 }, - { transform: 'scale(0)', opacity: 0, offset: 1 }, - ]); - animation.duration(250); - - animation.play(); - expect(animation.isRunning()).toEqual(true); - - animation.pause(); - expect(animation.isRunning()).toEqual(false); - }); - - it('should not be running when doing progress steps', () => { - const el = document.createElement('div'); - animation.addElement(el); - animation.keyframes([ - { transform: 'scale(1)', opacity: 1, offset: 0 }, - { transform: 'scale(0)', opacity: 0, offset: 1 }, - ]); - animation.duration(250); - - animation.play(); - - animation.progressStart(); - - expect(animation.isRunning()).toEqual(false); - }); - - it('should be running after calling progressEnd', () => { - const el = document.createElement('div'); - animation.addElement(el); - animation.keyframes([ - { transform: 'scale(1)', opacity: 1, offset: 0 }, - { transform: 'scale(0)', opacity: 0, offset: 1 }, - ]); - animation.duration(250); - - animation.play(); - - animation.progressStart(); - animation.progressEnd(1, 0); - - expect(animation.isRunning()).toEqual(true); - }); - - it('should not be running after playing to beginning', async () => { - const el = document.createElement('div'); - animation.addElement(el); - animation.keyframes([ - { transform: 'scale(1)', opacity: 1, offset: 0 }, - { transform: 'scale(0)', opacity: 0, offset: 1 }, - ]); - animation.duration(250); - - await animation.play(); - - animation.progressStart(); - animation.progressEnd(0, 0); - - await new Promise((resolve) => { - animation.onFinish(() => { - expect(animation.isRunning()).toEqual(false); - resolve(); - }); - }); - }); }); describe('addElement()', () => { @@ -228,18 +129,6 @@ describe('Animation Class', () => { expect(animation.getKeyframes().length).toEqual(3); }); - it('should convert properties for CSS Animations', () => { - const processedKeyframes = processKeyframes([ - { borderRadius: '0px', easing: 'ease-in', offset: 0 }, - { borderRadius: '4px', easing: 'ease-out', offset: 1 }, - ]); - - expect(processedKeyframes).toEqual([ - { 'border-radius': '0px', 'animation-timing-function': 'ease-in', offset: 0 }, - { 'border-radius': '4px', 'animation-timing-function': 'ease-out', offset: 1 }, - ]); - }); - it('should set the from keyframe properly', () => { animation.from('opacity', 0).from('background', 'red').from('color', 'purple'); diff --git a/core/src/utils/animation/test/animationbuilder/animation.e2e.ts b/core/src/utils/animation/test/animationbuilder/animation.e2e.ts index 96ea221853..a37b7b0694 100644 --- a/core/src/utils/animation/test/animationbuilder/animation.e2e.ts +++ b/core/src/utils/animation/test/animationbuilder/animation.e2e.ts @@ -12,11 +12,6 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => await page.goto('/src/utils/animation/test/animationbuilder', config); await testNavigation(page); }); - - test('ios-transition css', async ({ page }) => { - await page.goto('/src/utils/animation/test/animationbuilder?ionic:_forceCSSAnimations=true', config); - await testNavigation(page); - }); }); }); diff --git a/core/src/utils/animation/test/animationbuilder/index.html b/core/src/utils/animation/test/animationbuilder/index.html index a61fa92ee7..795b95efeb 100644 --- a/core/src/utils/animation/test/animationbuilder/index.html +++ b/core/src/utils/animation/test/animationbuilder/index.html @@ -13,11 +13,6 @@