[add] I18nManager and StyleSheet support for RTL without left/right flip

I18nManager supports `doLeftAndRightSwapInRTL` and
`swapLeftAndRightInRTL` to query and control the BiDi-flipping of
left/right properties and values. For example, you may choose to use
`end`/`start` for positioning that flips with writing direction, and
then disable `left`/`right` swapping in RTL so that `left` will always
be `left`.

The StyleSheet resolver cache must also account for the third "direction"
variant: RTL with no swapping of left/right.
This commit is contained in:
Nicolas Gallagher
2018-02-14 13:13:21 -08:00
parent b754776373
commit 92794cdc9f
8 changed files with 275 additions and 247 deletions
+14 -5
View File
@@ -14,11 +14,14 @@ import ExecutionEnvironment from 'fbjs/lib/ExecutionEnvironment';
type I18nManagerStatus = {
allowRTL: (allowRTL: boolean) => void,
doLeftAndRightSwapInRTL: boolean,
forceRTL: (forceRTL: boolean) => void,
isRTL: boolean,
setPreferredLanguageRTL: (setRTL: boolean) => void,
isRTL: boolean
swapLeftAndRightInRTL: (flipStyles: boolean) => void
};
let doLeftAndRightSwapInRTL = true;
let isPreferredLanguageRTL = false;
let isRTLAllowed = true;
let isRTLForced = false;
@@ -30,7 +33,7 @@ const isRTL = () => {
return isRTLAllowed && isPreferredLanguageRTL;
};
const onChange = () => {
const onDirectionChange = () => {
if (ExecutionEnvironment.canUseDOM) {
if (document.documentElement && document.documentElement.setAttribute) {
document.documentElement.setAttribute('dir', isRTL() ? 'rtl' : 'ltr');
@@ -41,15 +44,21 @@ const onChange = () => {
const I18nManager: I18nManagerStatus = {
allowRTL(bool) {
isRTLAllowed = bool;
onChange();
onDirectionChange();
},
forceRTL(bool) {
isRTLForced = bool;
onChange();
onDirectionChange();
},
setPreferredLanguageRTL(bool) {
isPreferredLanguageRTL = bool;
onChange();
onDirectionChange();
},
swapLeftAndRightInRTL(bool) {
doLeftAndRightSwapInRTL = bool;
},
get doLeftAndRightSwapInRTL() {
return doLeftAndRightSwapInRTL;
},
get isRTL() {
return isRTL();
@@ -24,8 +24,8 @@ const emptyObject = {};
export default class ReactNativeStyleResolver {
_init() {
this.cache = { ltr: {}, rtl: {} };
this.injectedCache = { ltr: {}, rtl: {} };
this.cache = { ltr: {}, rtl: {}, rtlNoSwap: {} };
this.injectedCache = { ltr: {}, rtl: {}, rtlNoSwap: {} };
this.styleSheetManager = new StyleSheetManager();
}
@@ -43,7 +43,8 @@ export default class ReactNativeStyleResolver {
}
_injectRegisteredStyle(id) {
const dir = I18nManager.isRTL ? 'rtl' : 'ltr';
const { doLeftAndRightSwapInRTL, isRTL } = I18nManager;
const dir = isRTL ? (doLeftAndRightSwapInRTL ? 'rtl' : 'rtlNoSwap') : 'ltr';
if (!this.injectedCache[dir][id]) {
const style = flattenStyle(id);
const domStyle = createReactDOMStyle(i18nStyle(style));
@@ -120,7 +121,7 @@ export default class ReactNativeStyleResolver {
// Create next DOM style props from current and next RN styles
const { classList: rdomClassListNext, style: rdomStyleNext } = this.resolve([
I18nManager.isRTL ? i18nStyle(rnStyle) : rnStyle,
i18nStyle(rnStyle),
rnStyleNext
]);
@@ -196,7 +197,8 @@ export default class ReactNativeStyleResolver {
*/
_resolveStyleIfNeeded(style, key) {
if (key) {
const dir = I18nManager.isRTL ? 'rtl' : 'ltr';
const { doLeftAndRightSwapInRTL, isRTL } = I18nManager;
const dir = isRTL ? (doLeftAndRightSwapInRTL ? 'rtl' : 'rtlNoSwap') : 'ltr';
if (!this.cache[dir][key]) {
// slow: convert style object to props and cache
this.cache[dir][key] = this._resolveStyle(style);
@@ -42,14 +42,22 @@ describe('StyleSheet/ReactNativeStyleResolver', () => {
testResolve(a, b, c);
});
test('with register before RTL, resolves to className', () => {
test('with register before RTL, resolves to correct className', () => {
const a = ReactNativePropRegistry.register({ left: '12.34%' });
const b = ReactNativePropRegistry.register({ textAlign: 'left' });
const c = ReactNativePropRegistry.register({ marginLeft: 10 });
I18nManager.forceRTL(true);
const resolved = styleResolver.resolve([a, b, c]);
const resolved1 = styleResolver.resolve([a, b, c]);
expect(resolved1).toMatchSnapshot();
I18nManager.swapLeftAndRightInRTL(false);
const resolved2 = styleResolver.resolve([a, b, c]);
expect(resolved2).toMatchSnapshot();
I18nManager.swapLeftAndRightInRTL(true);
I18nManager.forceRTL(false);
expect(resolved).toMatchSnapshot();
});
test('with register, resolves to mixed', () => {
@@ -102,7 +110,7 @@ describe('StyleSheet/ReactNativeStyleResolver', () => {
expect(resolved).toMatchSnapshot();
});
test('when RTL=true, resolves to flipped inline styles', () => {
test('when isRTL=true, resolves to flipped inline styles', () => {
// note: DOM state resolved from { marginLeft: 5, left: 5 } in RTL mode
node.style.cssText = 'margin-right: 5px; right: 5px;';
I18nManager.forceRTL(true);
@@ -111,8 +119,8 @@ describe('StyleSheet/ReactNativeStyleResolver', () => {
expect(resolved).toMatchSnapshot();
});
test('when RTL=true, resolves to flipped classNames', () => {
// note: DOM state resolved from { marginLeft: 5, left: 5 } in RTL mode
test('when isRTL=true, resolves to flipped classNames', () => {
// note: DOM state resolved from { marginLeft: 5, left: 5 }
node.style.cssText = 'margin-right: 5px; right: 5px;';
const nextStyle = ReactNativePropRegistry.register({ marginLeft: 10, right: 1 });
@@ -121,5 +129,19 @@ describe('StyleSheet/ReactNativeStyleResolver', () => {
I18nManager.forceRTL(false);
expect(resolved).toMatchSnapshot();
});
test('when isRTL=true & doLeftAndRightSwapInRTL=false, resolves to non-flipped inline styles', () => {
// note: DOM state resolved from { marginRight 5, right: 5, paddingEnd: 5 }
node.style.cssText = 'margin-right: 5px; right: 5px; padding-left: 5px';
I18nManager.forceRTL(true);
I18nManager.swapLeftAndRightInRTL(false);
const resolved = styleResolver.resolveWithNode(
{ marginRight: 10, right: 10, paddingEnd: 10 },
node
);
I18nManager.forceRTL(false);
I18nManager.swapLeftAndRightInRTL(true);
expect(resolved).toMatchSnapshot();
});
});
});
@@ -9,7 +9,7 @@ Object {
}
`;
exports[`StyleSheet/ReactNativeStyleResolver resolve with register before RTL, resolves to className 1`] = `
exports[`StyleSheet/ReactNativeStyleResolver resolve with register before RTL, resolves to correct className 1`] = `
Object {
"classList": Array [
"rn-marginRight-zso239",
@@ -20,6 +20,17 @@ Object {
}
`;
exports[`StyleSheet/ReactNativeStyleResolver resolve with register before RTL, resolves to correct className 2`] = `
Object {
"classList": Array [
"rn-left-2s0hu9",
"rn-marginLeft-1n0xq6e",
"rn-textAlign-fdjqy7",
],
"className": "rn-left-2s0hu9 rn-marginLeft-1n0xq6e rn-textAlign-fdjqy7",
}
`;
exports[`StyleSheet/ReactNativeStyleResolver resolve with register, resolves to className 1`] = `
Object {
"classList": Array [
@@ -246,7 +257,18 @@ Object {
}
`;
exports[`StyleSheet/ReactNativeStyleResolver resolveWithNode when RTL=true, resolves to flipped classNames 1`] = `
exports[`StyleSheet/ReactNativeStyleResolver resolveWithNode when isRTL=true & doLeftAndRightSwapInRTL=false, resolves to non-flipped inline styles 1`] = `
Object {
"className": "",
"style": Object {
"marginRight": "10px",
"paddingLeft": "10px",
"right": "10px",
},
}
`;
exports[`StyleSheet/ReactNativeStyleResolver resolveWithNode when isRTL=true, resolves to flipped classNames 1`] = `
Object {
"className": "rn-left-1u10d71 rn-marginRight-zso239",
"style": Object {
@@ -256,7 +278,7 @@ Object {
}
`;
exports[`StyleSheet/ReactNativeStyleResolver resolveWithNode when RTL=true, resolves to flipped inline styles 1`] = `
exports[`StyleSheet/ReactNativeStyleResolver resolveWithNode when isRTL=true, resolves to flipped inline styles 1`] = `
Object {
"className": "",
"style": Object {
@@ -1,105 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StyleSheet/i18nStyle LTR mode converts and doesn't flip start/end 1`] = `
Object {
"borderBottomLeftRadius": 20,
"borderBottomRightRadius": "2rem",
"borderLeftColor": "red",
"borderLeftStyle": "solid",
"borderLeftWidth": 5,
"borderRightColor": "blue",
"borderRightStyle": "dotted",
"borderRightWidth": 6,
"borderTopLeftRadius": 10,
"borderTopRightRadius": "1rem",
"left": 1,
"marginLeft": 7,
"marginRight": 8,
"paddingLeft": 9,
"paddingRight": 10,
"right": 2,
"textAlign": "left",
"textShadowOffset": Object {
"height": 10,
"width": "1rem",
},
}
`;
exports[`StyleSheet/i18nStyle LTR mode doesn't flip left/right 1`] = `
Object {
"borderBottomLeftRadius": 20,
"borderBottomRightRadius": "2rem",
"borderLeftColor": "red",
"borderLeftStyle": "solid",
"borderLeftWidth": 5,
"borderRightColor": "blue",
"borderRightStyle": "dotted",
"borderRightWidth": 6,
"borderTopLeftRadius": 10,
"borderTopRightRadius": "1rem",
"left": 1,
"marginLeft": 7,
"marginRight": 8,
"paddingLeft": 9,
"paddingRight": 10,
"right": 2,
"textAlign": "left",
"textShadowOffset": Object {
"height": 10,
"width": "1rem",
},
}
`;
exports[`StyleSheet/i18nStyle RTL mode converts and flips start/end 1`] = `
Object {
"borderBottomLeftRadius": "2rem",
"borderBottomRightRadius": 20,
"borderLeftColor": "blue",
"borderLeftStyle": "dotted",
"borderLeftWidth": 6,
"borderRightColor": "red",
"borderRightStyle": "solid",
"borderRightWidth": 5,
"borderTopLeftRadius": "1rem",
"borderTopRightRadius": 10,
"left": 2,
"marginLeft": 8,
"marginRight": 7,
"paddingLeft": 10,
"paddingRight": 9,
"right": 1,
"textAlign": "right",
"textShadowOffset": Object {
"height": 10,
"width": "-1rem",
},
}
`;
exports[`StyleSheet/i18nStyle RTL mode flips left/right 1`] = `
Object {
"borderBottomLeftRadius": "2rem",
"borderBottomRightRadius": 20,
"borderLeftColor": "blue",
"borderLeftStyle": "dotted",
"borderLeftWidth": 6,
"borderRightColor": "red",
"borderRightStyle": "solid",
"borderRightWidth": 5,
"borderTopLeftRadius": "1rem",
"borderTopRightRadius": 10,
"left": 2,
"marginLeft": 8,
"marginRight": 7,
"paddingLeft": 10,
"paddingRight": 9,
"right": 1,
"textAlign": "right",
"textShadowOffset": Object {
"height": 10,
"width": "-1rem",
},
}
`;
@@ -3,50 +3,8 @@
import I18nManager from '../../I18nManager';
import i18nStyle from '../i18nStyle';
const styleLeftRight = {
borderLeftColor: 'red',
borderRightColor: 'blue',
borderTopLeftRadius: 10,
borderTopRightRadius: '1rem',
borderBottomLeftRadius: 20,
borderBottomRightRadius: '2rem',
borderLeftStyle: 'solid',
borderRightStyle: 'dotted',
borderLeftWidth: 5,
borderRightWidth: 6,
left: 1,
marginLeft: 7,
marginRight: 8,
paddingLeft: 9,
paddingRight: 10,
right: 2,
textAlign: 'left',
textShadowOffset: { width: '1rem', height: 10 }
};
const styleStartEnd = {
borderStartColor: 'red',
borderEndColor: 'blue',
borderTopStartRadius: 10,
borderTopEndRadius: '1rem',
borderBottomStartRadius: 20,
borderBottomEndRadius: '2rem',
borderStartStyle: 'solid',
borderEndStyle: 'dotted',
borderStartWidth: 5,
borderEndWidth: 6,
start: 1,
marginStart: 7,
marginEnd: 8,
paddingStart: 9,
paddingEnd: 10,
end: 2,
textAlign: 'start',
textShadowOffset: { width: '1rem', height: 10 }
};
describe('StyleSheet/i18nStyle', () => {
describe('LTR mode', () => {
describe('isRTL = false', () => {
beforeEach(() => {
I18nManager.allowRTL(false);
});
@@ -56,32 +14,59 @@ describe('StyleSheet/i18nStyle', () => {
});
test("doesn't flip left/right", () => {
expect(i18nStyle(styleLeftRight)).toMatchSnapshot();
const initial = {
borderLeftColor: 'red',
left: 1,
marginLeft: 5,
paddingRight: 10,
textAlign: 'right',
textShadowOffset: { width: '1rem', height: 10 }
};
expect(i18nStyle(initial)).toEqual(initial);
});
test("converts and doesn't flip start/end", () => {
expect(i18nStyle(styleStartEnd)).toMatchSnapshot();
const initial = {
borderStartColor: 'red',
start: 1,
marginStart: 5,
paddingEnd: 10,
textAlign: 'end',
textShadowOffset: { width: '1rem', height: 10 }
};
const expected = {
borderLeftColor: 'red',
left: 1,
marginLeft: 5,
paddingRight: 10,
textAlign: 'right',
textShadowOffset: { width: '1rem', height: 10 }
};
expect(i18nStyle(initial)).toEqual(expected);
});
test('start/end takes precedence over left/right', () => {
const style = {
borderTopStartRadius: 10,
borderTopLeftRadius: 0,
const initial = {
borderStartWidth: 10,
borderLeftWidth: 0,
end: 10,
right: 0,
marginStart: 10,
marginLeft: 0
};
const expected = {
borderTopLeftRadius: 10,
borderLeftWidth: 10,
marginLeft: 10,
right: 10
};
expect(i18nStyle(style)).toEqual(expected);
expect(i18nStyle(initial)).toEqual(expected);
});
});
describe('RTL mode', () => {
describe('isRTL = true', () => {
beforeEach(() => {
I18nManager.forceRTL(true);
});
@@ -90,29 +75,125 @@ describe('StyleSheet/i18nStyle', () => {
I18nManager.forceRTL(false);
});
test('flips left/right', () => {
expect(i18nStyle(styleLeftRight)).toMatchSnapshot();
describe('doLeftAndRightSwapInRTL = true', () => {
test('flips left/right', () => {
const initial = {
borderLeftColor: 'red',
left: 1,
marginLeft: 5,
paddingRight: 10,
textAlign: 'right',
textShadowOffset: { width: '1rem', height: 10 }
};
const expected = {
borderRightColor: 'red',
right: 1,
marginRight: 5,
paddingLeft: 10,
textAlign: 'left',
textShadowOffset: { width: '-1rem', height: 10 }
};
expect(i18nStyle(initial)).toEqual(expected);
});
test('converts and flips start/end', () => {
const initial = {
borderStartColor: 'red',
start: 1,
marginStart: 5,
paddingEnd: 10,
textAlign: 'end'
};
const expected = {
borderRightColor: 'red',
right: 1,
marginRight: 5,
paddingLeft: 10,
textAlign: 'left'
};
expect(i18nStyle(initial)).toEqual(expected);
});
test('start/end takes precedence over left/right', () => {
const style = {
borderStartWidth: 10,
borderLeftWidth: 0,
end: 10,
right: 0,
marginStart: 10,
marginLeft: 0
};
const expected = {
borderRightWidth: 10,
marginRight: 10,
left: 10
};
expect(i18nStyle(style)).toEqual(expected);
});
});
test('converts and flips start/end', () => {
expect(i18nStyle(styleStartEnd)).toMatchSnapshot();
});
describe('doLeftAndRightSwapInRTL = false', () => {
beforeEach(() => {
I18nManager.swapLeftAndRightInRTL(false);
});
test('start/end takes precedence over left/right', () => {
const style = {
borderTopStartRadius: 10,
borderTopLeftRadius: 0,
end: 10,
right: 0,
marginStart: 10,
marginLeft: 0
};
const expected = {
borderTopRightRadius: 10,
marginRight: 10,
left: 10
};
expect(i18nStyle(style)).toEqual(expected);
afterEach(() => {
I18nManager.swapLeftAndRightInRTL(true);
});
test("doesn't flip left/right", () => {
const initial = {
borderLeftColor: 'red',
left: 1,
marginLeft: 5,
paddingRight: 10,
textAlign: 'right',
textShadowOffset: { width: '1rem', height: 10 }
};
expect(i18nStyle(initial)).toEqual(initial);
});
test('converts start/end', () => {
const initial = {
borderStartColor: 'red',
start: 1,
marginStart: 5,
paddingEnd: 10,
textAlign: 'end'
};
const expected = {
borderRightColor: 'red',
right: 1,
marginRight: 5,
paddingLeft: 10,
textAlign: 'left'
};
expect(i18nStyle(initial)).toEqual(expected);
});
test('start/end takes precedence over left/right', () => {
const style = {
borderStartWidth: 10,
borderRightWidth: 0,
end: 10,
left: 0,
marginStart: 10,
marginRight: 0
};
const expected = {
borderRightWidth: 10,
marginRight: 10,
left: 10
};
expect(i18nStyle(style)).toEqual(expected);
});
});
});
});
+30 -47
View File
@@ -79,69 +79,52 @@ const PROPERTIES_VALUE = {
// Invert the sign of a numeric-like value
const additiveInverse = (value: String | Number) => multiplyStyleLengthValue(value, -1);
// Convert I18N properties and values
const convertProperty = (prop: String): String => {
return PROPERTIES_I18N.hasOwnProperty(prop) ? PROPERTIES_I18N[prop] : prop;
};
const convertValue = (value: String): String => {
return value === 'start' ? 'left' : value === 'end' ? 'right' : value;
};
// BiDi flip properties and values
const flipProperty = (prop: String): String => {
return PROPERTIES_FLIP.hasOwnProperty(prop) ? PROPERTIES_FLIP[prop] : prop;
};
const flipValue = (value: String): String => {
return value === 'left' ? 'right' : value === 'right' ? 'left' : value;
};
const i18nStyle = originalStyle => {
const isRTL = I18nManager.isRTL;
const { doLeftAndRightSwapInRTL, isRTL } = I18nManager;
const style = originalStyle || emptyObject;
const nextStyle = {};
const frozenProps = {};
const nextStyle = {};
for (const originalProp in style) {
if (!Object.prototype.hasOwnProperty.call(style, originalProp)) {
continue;
}
const originalValue = style[originalProp];
let prop = originalProp;
let value = style[originalProp];
let shouldFreezeProp = false;
let value = originalValue;
// Process I18N properties and values
if (PROPERTIES_I18N[prop]) {
prop = convertProperty(prop);
// I18N properties takes precendence over left/right
shouldFreezeProp = true;
} else if (PROPERTIES_VALUE[prop]) {
value = convertValue(value);
// BiDi flip properties
if (PROPERTIES_I18N.hasOwnProperty(originalProp)) {
// convert start/end
const convertedProp = PROPERTIES_I18N[originalProp];
prop = isRTL ? PROPERTIES_FLIP[convertedProp] : convertedProp;
} else if (isRTL && doLeftAndRightSwapInRTL && PROPERTIES_FLIP[originalProp]) {
prop = PROPERTIES_FLIP[originalProp];
}
if (isRTL) {
if (PROPERTIES_FLIP[prop]) {
const newProp = flipProperty(prop);
if (!frozenProps[prop]) {
nextStyle[newProp] = value;
// BiDi flip values
if (PROPERTIES_VALUE.hasOwnProperty(originalProp)) {
if (originalValue === 'start') {
value = isRTL ? 'right' : 'left';
} else if (originalValue === 'end') {
value = isRTL ? 'left' : 'right';
} else if (isRTL && doLeftAndRightSwapInRTL) {
if (originalValue === 'left') {
value = 'right';
} else if (originalValue === 'right') {
value = 'left';
}
} else if (PROPERTIES_VALUE[prop]) {
nextStyle[prop] = flipValue(value);
} else if (prop === 'textShadowOffset') {
nextStyle[prop] = value;
nextStyle[prop].width = additiveInverse(value.width);
} else {
nextStyle[prop] = style[prop];
}
} else {
if (!frozenProps[prop]) {
nextStyle[prop] = value;
}
}
// Mark the style prop as frozen
if (shouldFreezeProp) {
if (isRTL && prop === 'textShadowOffset') {
nextStyle[prop] = value;
nextStyle[prop].width = additiveInverse(value.width);
} else if (!frozenProps[prop]) {
nextStyle[prop] = value;
}
if (PROPERTIES_I18N[originalProp]) {
frozenProps[prop] = true;
}
}