[add] CSS keyframes support via 'animationName' style

Keyframes can be defined using an array of objects as the value of
'animationName'. Each keyframe is transformed into a CSS keyframe rule.
This commit is contained in:
Nicolas Gallagher
2018-01-29 08:25:35 -08:00
parent 31d428a649
commit 998e275e65
16 changed files with 252 additions and 62 deletions
@@ -17,7 +17,24 @@ exports[`components/ActivityIndicator prop "animating" is "false" 1`] = `
Object {
"animationDuration": "0.75s",
"animationIterationCount": "infinite",
"animationName": "rn-ActivityIndicator-animation",
"animationName": Array [
Object {
"0%": Object {
"transform": Array [
Object {
"rotate": "0deg",
},
],
},
"100%": Object {
"transform": Array [
Object {
"rotate": "360deg",
},
],
},
},
],
"animationPlayState": "paused",
"animationTimingFunction": "linear",
"height": 20,
@@ -80,7 +97,24 @@ exports[`components/ActivityIndicator prop "animating" is "true" 1`] = `
Object {
"animationDuration": "0.75s",
"animationIterationCount": "infinite",
"animationName": "rn-ActivityIndicator-animation",
"animationName": Array [
Object {
"0%": Object {
"transform": Array [
Object {
"rotate": "0deg",
},
],
},
"100%": Object {
"transform": Array [
Object {
"rotate": "360deg",
},
],
},
},
],
"animationTimingFunction": "linear",
"height": 20,
"width": 20,
@@ -177,7 +211,24 @@ exports[`components/ActivityIndicator prop "hidesWhenStopped" is "false" 1`] = `
Object {
"animationDuration": "0.75s",
"animationIterationCount": "infinite",
"animationName": "rn-ActivityIndicator-animation",
"animationName": Array [
Object {
"0%": Object {
"transform": Array [
Object {
"rotate": "0deg",
},
],
},
"100%": Object {
"transform": Array [
Object {
"rotate": "360deg",
},
],
},
},
],
"animationPlayState": "paused",
"animationTimingFunction": "linear",
"height": 20,
@@ -239,7 +290,24 @@ exports[`components/ActivityIndicator prop "hidesWhenStopped" is "true" 1`] = `
Object {
"animationDuration": "0.75s",
"animationIterationCount": "infinite",
"animationName": "rn-ActivityIndicator-animation",
"animationName": Array [
Object {
"0%": Object {
"transform": Array [
Object {
"rotate": "0deg",
},
],
},
"100%": Object {
"transform": Array [
Object {
"rotate": "360deg",
},
],
},
},
],
"animationPlayState": "paused",
"animationTimingFunction": "linear",
"height": 20,
@@ -302,7 +370,24 @@ exports[`components/ActivityIndicator prop "size" is "large" 1`] = `
Object {
"animationDuration": "0.75s",
"animationIterationCount": "infinite",
"animationName": "rn-ActivityIndicator-animation",
"animationName": Array [
Object {
"0%": Object {
"transform": Array [
Object {
"rotate": "0deg",
},
],
},
"100%": Object {
"transform": Array [
Object {
"rotate": "360deg",
},
],
},
},
],
"animationTimingFunction": "linear",
"height": 36,
"width": 36,
@@ -363,7 +448,24 @@ exports[`components/ActivityIndicator prop "size" is a number 1`] = `
Object {
"animationDuration": "0.75s",
"animationIterationCount": "infinite",
"animationName": "rn-ActivityIndicator-animation",
"animationName": Array [
Object {
"0%": Object {
"transform": Array [
Object {
"rotate": "0deg",
},
],
},
"100%": Object {
"transform": Array [
Object {
"rotate": "360deg",
},
],
},
},
],
"animationTimingFunction": "linear",
"height": 30,
"width": 30,
@@ -88,7 +88,12 @@ const styles = StyleSheet.create({
},
animation: {
animationDuration: '0.75s',
animationName: 'rn-ActivityIndicator-animation',
animationName: [
{
'0%': { transform: [{ rotate: '0deg' }] },
'100%': { transform: [{ rotate: '360deg' }] }
}
],
animationTimingFunction: 'linear',
animationIterationCount: 'infinite'
},
@@ -23,9 +23,7 @@ html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;-webkit-tap-highlig
body{margin:0;}
button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0;}
input::-webkit-inner-spin-button,input::-webkit-outer-spin-button,input::-webkit-search-cancel-button,input::-webkit-search-decoration,input::-webkit-search-results-button,input::-webkit-search-results-decoration{display:none;}
}
@keyframes rn-ActivityIndicator-animation{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg);}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg);}}
@keyframes rn-ProgressBar-animation{0%{-webkit-transform:translateX(-100%);transform:translateX(-100%);}100%{-webkit-transform:translateX(400%);transform:translateX(400%);}}</style>"
}</style>"
`;
exports[`CSS for an unstyled app 1`] = `
@@ -35,8 +33,6 @@ body{margin:0;}
button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0;}
input::-webkit-inner-spin-button,input::-webkit-outer-spin-button,input::-webkit-search-cancel-button,input::-webkit-search-decoration,input::-webkit-search-results-button,input::-webkit-search-results-decoration{display:none;}
}
@keyframes rn-ActivityIndicator-animation{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg);}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg);}}
@keyframes rn-ProgressBar-animation{0%{-webkit-transform:translateX(-100%);transform:translateX(-100%);}100%{-webkit-transform:translateX(400%);transform:translateX(400%);}}
.rn-pointerEvents-12vffkv > *{pointer-events:auto}
.rn-pointerEvents-12vffkv{pointer-events:none !important}
.rn-alignItems-1oszu61{-ms-flex-align:stretch;-webkit-align-items:stretch;-webkit-box-align:stretch;align-items:stretch}
+6 -1
View File
@@ -96,7 +96,12 @@ const styles = StyleSheet.create({
},
animation: {
animationDuration: '1s',
animationName: 'rn-ProgressBar-animation',
animationName: [
{
'0%': { transform: [{ translateX: '-100%' }] },
'100%': { transform: [{ translateX: '400%' }] }
}
],
animationTimingFunction: 'linear',
animationIterationCount: 'infinite'
}
@@ -25,6 +25,7 @@ const emptyObject = {};
export default class ReactNativeStyleResolver {
_init() {
this.cache = { ltr: {}, rtl: {} };
this.injectedCache = { ltr: {}, rtl: {} };
this.styleSheetManager = new StyleSheetManager();
}
@@ -43,7 +44,7 @@ export default class ReactNativeStyleResolver {
_injectRegisteredStyle(id) {
const dir = I18nManager.isRTL ? 'rtl' : 'ltr';
if (!this.cache[dir][id]) {
if (!this.injectedCache[dir][id]) {
const style = flattenStyle(id);
const domStyle = createReactDOMStyle(i18nStyle(style));
Object.keys(domStyle).forEach(styleProp => {
@@ -52,7 +53,7 @@ export default class ReactNativeStyleResolver {
this.styleSheetManager.injectDeclaration(styleProp, value);
}
});
this.cache[dir][id] = true;
this.injectedCache[dir][id] = true;
}
}
@@ -160,7 +161,11 @@ export default class ReactNativeStyleResolver {
// Certain properties and values are not transformed by 'createReactDOMStyle' as they
// require more complex transforms into multiple CSS rules. Here we assume that StyleManager
// can bind these styles to a className, and prevent them becoming invalid inline-styles.
if (styleProp === 'pointerEvents' || styleProp === 'placeholderTextColor') {
if (
styleProp === 'pointerEvents' ||
styleProp === 'placeholderTextColor' ||
styleProp === 'animationName'
) {
const className = this.styleSheetManager.injectDeclaration(styleProp, value);
if (className) {
props.classList.push(className);
@@ -17,10 +17,12 @@ const emptyObject = {};
const STYLE_ELEMENT_ID = 'react-native-stylesheet';
const createClassName = (prop, value) => {
const hashed = hash(prop + value);
const hashed = hash(prop + normalizeValue(value));
return process.env.NODE_ENV !== 'production' ? `rn-${prop}-${hashed}` : `rn-${hashed}`;
};
const normalizeValue = value => (typeof value === 'object' ? JSON.stringify(value) : value);
export default class StyleSheetManager {
_cache = {
byClassName: {},
@@ -35,8 +37,9 @@ export default class StyleSheetManager {
}
getClassName(prop, value) {
const val = normalizeValue(value);
const cache = this._cache.byProp;
return cache[prop] && cache[prop].hasOwnProperty(value) && cache[prop][value];
return cache[prop] && cache[prop].hasOwnProperty(val) && cache[prop][val];
}
getDeclaration(className) {
@@ -54,10 +57,11 @@ export default class StyleSheetManager {
}
injectDeclaration(prop, value): string {
let className = this.getClassName(prop, value);
const val = normalizeValue(value);
let className = this.getClassName(prop, val);
if (!className) {
className = createClassName(prop, value);
this._addToCache(className, prop, value);
className = createClassName(prop, val);
this._addToCache(className, prop, val);
const rules = createAtomicRules(`.${className}`, prop, value);
rules.forEach(rule => {
this._sheet.insertRuleOnce(rule);
@@ -66,6 +70,10 @@ export default class StyleSheetManager {
return className;
}
injectKeyframe(): string {
// return identifier;
}
_addToCache(className, prop, value) {
const cache = this._cache;
if (!cache.byProp[prop]) {
@@ -11,8 +11,6 @@ body{margin:0;}
button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0;}
input::-webkit-inner-spin-button,input::-webkit-outer-spin-button,input::-webkit-search-cancel-button,input::-webkit-search-decoration,input::-webkit-search-results-button,input::-webkit-search-results-decoration{display:none;}
}
@keyframes rn-ActivityIndicator-animation{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg);}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg);}}
@keyframes rn-ProgressBar-animation{0%{-webkit-transform:translateX(-100%);transform:translateX(-100%);}100%{-webkit-transform:translateX(400%);transform:translateX(400%);}}
.rn---test-property-ax3bxi{--test-property:test-value}",
}
`;
@@ -1,17 +1,22 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StyleSheet/createAtomicRules transforms custom placeholderTextColor declaration 1`] = `
exports[`StyleSheet/createAtomicRules transforms custom "animationName" declaration 1`] = `
Array [
"@media all {
.test::-webkit-input-placeholder{color:gray;opacity:1}
.test::-moz-placeholder{color:gray;opacity:1}
.test:-ms-input-placeholder{color:gray;opacity:1}
.test::placeholder{color:gray;opacity:1}
}",
"@media all {@-webkit-keyframes rn-anim-2k74q5{0%{top:0px}50%{top:5px}100%{top:10px}}}",
"@media all {@keyframes rn-anim-2k74q5{0%{top:0px}50%{top:5px}100%{top:10px}}}",
"@media all {@-webkit-keyframes rn-anim-zc91cv{from{left:0px}to{left:10px}}}",
"@media all {@keyframes rn-anim-zc91cv{from{left:0px}to{left:10px}}}",
".test{-webkit-animation-name:rn-anim-2k74q5,rn-anim-zc91cv;animation-name:rn-anim-2k74q5,rn-anim-zc91cv}",
]
`;
exports[`StyleSheet/createAtomicRules transforms custom pointerEvents declaration 1`] = `
exports[`StyleSheet/createAtomicRules transforms custom "placeholderTextColor" declaration 1`] = `
Array [
"@media all {.test::-webkit-input-placeholder{color:gray;opacity:1}.test::-moz-placeholder{color:gray;opacity:1}.test:-ms-input-placeholder{color:gray;opacity:1}.test::placeholder{color:gray;opacity:1}}",
]
`;
exports[`StyleSheet/createAtomicRules transforms custom "pointerEvents" declaration 1`] = `
Array [
".test > *{pointer-events:none}",
".test{pointer-events:auto !important}",
@@ -7,11 +7,19 @@ describe('StyleSheet/createAtomicRules', () => {
expect(createAtomicRules('.test', 'margin', 0)).toMatchSnapshot();
});
test('transforms custom pointerEvents declaration', () => {
test('transforms custom "animationName" declaration', () => {
const value = [
{ '0%': { top: 0 }, '50%': { top: 5 }, '100%': { top: 10 } },
{ from: { left: 0 }, to: { left: 10 } }
];
expect(createAtomicRules('.test', 'animationName', value)).toMatchSnapshot();
});
test('transforms custom "pointerEvents" declaration', () => {
expect(createAtomicRules('.test', 'pointerEvents', 'box-only')).toMatchSnapshot();
});
test('transforms custom placeholderTextColor declaration', () => {
test('transforms custom "placeholderTextColor" declaration', () => {
expect(createAtomicRules('.test', 'placeholderTextColor', 'gray')).toMatchSnapshot();
});
});
@@ -1,10 +1,12 @@
import createKeyframesRules from './createKeyframesRules';
import createRuleBlock from './createRuleBlock';
const createAtomicRules = (selector, prop, value) => {
const rules = [];
// Handle custom properties and custom values that require additional rules
// to be created.
switch (prop) {
// pointerEvents is a special case that requires custom values and additional rules
// See #513
case 'pointerEvents': {
let val = value;
@@ -28,12 +30,43 @@ const createAtomicRules = (selector, prop, value) => {
case 'placeholderTextColor': {
const block = createRuleBlock({ color: value, opacity: 1 });
rules.push(`@media all {
${selector}::-webkit-input-placeholder{${block}}
${selector}::-moz-placeholder{${block}}
${selector}:-ms-input-placeholder{${block}}
${selector}::placeholder{${block}}
}`);
rules.push(
'@media all {' +
`${selector}::-webkit-input-placeholder{${block}}` +
`${selector}::-moz-placeholder{${block}}` +
`${selector}:-ms-input-placeholder{${block}}` +
`${selector}::placeholder{${block}}` +
'}'
);
break;
}
case 'animationName': {
if (typeof value === 'string') {
// add a className referencing the animation
const block = createRuleBlock({ [prop]: value });
rules.push(`${selector}{${block}}`);
} else {
const animationNames = [];
// add the keyframes needed to implement each value
value.forEach(keyframes => {
if (typeof keyframes === 'string') {
animationNames.push(keyframes);
} else {
const { identifier, rules: keyframesRules } = createKeyframesRules(keyframes);
keyframesRules.forEach(rule => {
rules.push(rule);
});
animationNames.push(identifier);
}
});
// add a className referencing the animation identifiers
const block = createRuleBlock({ [prop]: animationNames.join(',') });
rules.push(`${selector}{${block}}`);
}
break;
}
@@ -0,0 +1,37 @@
import createRuleBlock from './createRuleBlock';
import createReactDOMStyle from './createReactDOMStyle';
import i18nStyle from './i18nStyle';
import hash from '../../vendor/hash';
const hashObject = obj => hash(JSON.stringify(obj));
const createIdentifier = obj => {
const hashed = hashObject(obj);
return process.env.NODE_ENV !== 'production' ? `rn-anim-${hashed}` : `rn-${hashed}`;
};
const prefixes = ['-webkit-', ''];
const makeBlock = rule => {
const domStyle = createReactDOMStyle(i18nStyle(rule));
return createRuleBlock(domStyle);
};
const makeSteps = keyframes =>
Object.keys(keyframes)
.map(stepName => {
const rule = keyframes[stepName];
const block = makeBlock(rule);
return `${stepName}{${block}}`;
})
.join('');
const createKeyframesRules = (keyframes: Object): Array<String> => {
const identifier = createIdentifier(keyframes);
const rules = prefixes.map(prefix => {
return `@media all {@${prefix}keyframes ${identifier}{${makeSteps(keyframes)}}}`;
});
return { identifier, rules };
};
export default createKeyframesRules;
@@ -25,12 +25,12 @@ const createDeclarationString = (prop, val) => {
/**
* Generates valid CSS rule body from a JS object
*
* generateCss({ width: 20, color: 'blue' });
* createRuleBlock({ width: 20, color: 'blue' });
* // => 'color:blue;width:20px'
*/
const generateCss = style =>
const createRuleBlock = style =>
mapKeyValue(prefixStyles(style), createDeclarationString)
.sort()
.join(';');
export default generateCss;
export default createRuleBlock;
@@ -22,19 +22,6 @@ const resets = [
'input::-webkit-search-results-button,input::-webkit-search-results-decoration{display:none;}'
];
const reset = safeRule(resets.join('\n'));
const reset = [safeRule(resets.join('\n'))];
const initialRules = [
reset,
// temporary keyframes
'@keyframes rn-ActivityIndicator-animation{' +
'0%{-webkit-transform:rotate(0deg);transform:rotate(0deg);}' +
'100%{-webkit-transform:rotate(360deg);transform:rotate(360deg);}' +
'}',
'@keyframes rn-ProgressBar-animation{' +
'0%{-webkit-transform:translateX(-100%);transform:translateX(-100%);}' +
'100%{-webkit-transform:translateX(400%);transform:translateX(400%);}' +
'}'
];
export default initialRules;
export default reset;
@@ -8,7 +8,7 @@
* @flow
*/
import { number, oneOf, oneOfType, string } from 'prop-types';
import { arrayOf, number, object, oneOf, oneOfType, string } from 'prop-types';
const AnimationPropTypes = {
animationDelay: string,
@@ -16,7 +16,7 @@ const AnimationPropTypes = {
animationDuration: string,
animationFillMode: oneOf(['none', 'forwards', 'backwards', 'both']),
animationIterationCount: oneOfType([number, oneOf(['infinite'])]),
animationName: string,
animationName: oneOfType([string, arrayOf(oneOfType([string, object]))]),
animationPlayState: oneOf(['paused', 'running']),
animationTimingFunction: string
};
+1
View File
@@ -54,4 +54,5 @@ function murmurhash2_32_gc(str, seed) {
}
const hash = str => murmurhash2_32_gc(str, 1).toString(36);
export default hash;
@@ -334,7 +334,7 @@ const stylePropTypes = [
{
label: 'web',
name: 'animationName',
typeInfo: 'string'
typeInfo: 'string | Array<Object>'
},
{
label: 'web',
@@ -776,7 +776,7 @@ const stylePropTypes = [
},
{
name: 'transform',
typeInfo: 'array'
typeInfo: 'Array<Object>'
},
{
label: 'web',