Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ eslintTester.run('../platform-colors', rule, {
valid: [
"const color = PlatformColor('labelColor');",
"const color = PlatformColor('controlAccentColor', 'controlColor');",
"const color = PlatformColor('labelColor', {fallback: '#FF0000'});",
"const color = PlatformColor('controlAccentColor', 'controlColor', {fallback: 'red'});",
"const color = DynamicColorIOS({light: 'black', dark: 'white'});",
"const color = DynamicColorIOS({light: PlatformColor('black'), dark: PlatformColor('white')});",
"const color = DynamicColorIOS({light: PlatformColor('black'), dark: PlatformColor('white'), highContrastLight: PlatformColor('black'), highContrastDark: PlatformColor('white')});",
Expand All @@ -32,6 +34,14 @@ eslintTester.run('../platform-colors', rule, {
code: "const labelColor = 'labelColor'; const color = PlatformColor(labelColor);",
errors: [{message: rule.meta.messages.platformColorArgTypes}],
},
{
code: "const raw = '#FF0000'; const color = PlatformColor('labelColor', {fallback: raw});",
errors: [{message: rule.meta.messages.platformColorArgTypes}],
},
{
code: "const color = PlatformColor({fallback: '#FF0000'}, 'labelColor');",
errors: [{message: rule.meta.messages.platformColorArgTypes}],
},
{
code: "const tuple = {light: 'black', dark: 'white'}; const color = DynamicColorIOS(tuple);",
errors: [{message: rule.meta.messages.dynamicColorIOSArg}],
Expand Down
22 changes: 21 additions & 1 deletion packages/eslint-plugin-react-native/platform-colors.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,34 @@ module.exports = {
CallExpression: function (node) {
if (node.callee.name === 'PlatformColor') {
const args = node.arguments;
// The optional trailing options object carries a lazy raw color
// fallback: PlatformColor('token', {fallback: '#RRGGBB'}). Its
// fallback value must itself be a literal so it stays statically
// analyzable.
const isFallbackObject = arg =>
arg.type === 'ObjectExpression' &&
arg.properties.length > 0 &&
arg.properties.every(
property =>
property.type === 'Property' &&
property.key.type === 'Identifier' &&
property.key.name === 'fallback' &&
property.value.type === 'Literal',
);
if (args.length === 0) {
context.report({
node,
messageId: 'platformColorArgsLength',
});
return;
}
if (!args.every(arg => arg.type === 'Literal')) {
if (
!args.every(
(arg, index) =>
arg.type === 'Literal' ||
(index === args.length - 1 && isFallbackObject(arg)),
)
) {
context.report({
node,
messageId: 'platformColorArgTypes',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,38 @@ import type {NativeColorValue} from './StyleSheet';
/** The actual type of the opaque NativeColorValue on Android platform */
type LocalNativeColorValue = {
resource_paths?: Array<string>,
fallback?: string,
};

export const PlatformColor = (...names: Array<string>): NativeColorValue => {
export const PlatformColor = (
...args: Array<string | {fallback: string}>
): NativeColorValue => {
const lastArg = args[args.length - 1];
if (lastArg == null || typeof lastArg === 'string') {
// Fast path: the {fallback} object is only honored as the trailing argument,
// so when it is absent the rest-param array is already the list of names and
// can be handed to native without allocating and rebuilding a second array.
/* $FlowExpectedError[incompatible-type]
* LocalNativeColorValue is the actual type of the opaque NativeColorValue on Android platform */
return {resource_paths: args} as LocalNativeColorValue;
}
const names: Array<string> = [];
for (let i = 0; i < args.length - 1; i++) {
const arg = args[i];
if (typeof arg === 'string') {
names.push(arg);
}
// A non-string before the final position is ignored (a lint error at authoring).
}
// The fallback is a raw color string, intentionally not processed here; it
// crosses to native untouched and is only parsed on a token miss.
const color: LocalNativeColorValue = {
resource_paths: names,
fallback: lastArg.fallback,
};
/* $FlowExpectedError[incompatible-type]
* LocalNativeColorValue is the actual type of the opaque NativeColorValue on Android platform */
return {resource_paths: names} as LocalNativeColorValue;
return color as LocalNativeColorValue;
};

export const normalizeColorObject = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ import {OpaqueColorValue} from './StyleSheet';
*
* @see https://reactnative.dev/docs/platformcolor#example
*/
export function PlatformColor(...colors: string[]): OpaqueColorValue;
export function PlatformColor(
...colors: Array<string | {fallback: string}>
): OpaqueColorValue;
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {ColorValue, NativeColorValue} from './StyleSheet';
/** The actual type of the opaque NativeColorValue on iOS platform */
type LocalNativeColorValue = {
semantic?: Array<string>,
fallback?: string,
dynamic?: {
light: ?(ColorValue | ProcessedColorValue),
dark: ?(ColorValue | ProcessedColorValue),
Expand All @@ -22,9 +23,33 @@ type LocalNativeColorValue = {
},
};

export const PlatformColor = (...names: Array<string>): NativeColorValue => {
export const PlatformColor = (
...args: Array<string | {fallback: string}>
): NativeColorValue => {
const lastArg = args[args.length - 1];
if (lastArg == null || typeof lastArg === 'string') {
// Fast path: the {fallback} object is only honored as the trailing argument,
// so when it is absent the rest-param array is already the list of names and
// can be handed to native without allocating and rebuilding a second array.
// $FlowExpectedError[incompatible-type] LocalNativeColorValue is the iOS LocalNativeColorValue type
return {semantic: args} as LocalNativeColorValue;
}
const names: Array<string> = [];
for (let i = 0; i < args.length - 1; i++) {
const arg = args[i];
if (typeof arg === 'string') {
names.push(arg);
}
// A non-string before the final position is ignored (a lint error at authoring).
}
// The fallback is a raw color string, intentionally not processed here; it
// crosses to native untouched and is only parsed on a token miss.
const color: LocalNativeColorValue = {
semantic: names,
fallback: lastArg.fallback,
};
// $FlowExpectedError[incompatible-type] LocalNativeColorValue is the iOS LocalNativeColorValue type
return {semantic: names} as LocalNativeColorValue;
return color as LocalNativeColorValue;
};

export type DynamicColorIOSTuplePrivate = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type {NativeColorValue} from './StyleSheet';
* @see https://reactnative.dev/docs/platformcolor#example
*/
declare export function PlatformColor(
...names: Array<string>
...names: Array<string | {fallback: string}>
): NativeColorValue;

declare export function normalizeColorObject(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,16 @@ describe('processColor', () => {
const expectedColor = {dynamic: {light: 0xff000000, dark: 0xffffffff}};
expect(processedColor).toEqual(expectedColor);
});

it('should carry an unprocessed fallback on iOS PlatformColor colors', () => {
const color = PlatformColorIOS('systemRedColor', {fallback: '#ff0000'});
const processedColor = processColor(color);
const expectedColor = {
semantic: ['systemRedColor'],
fallback: '#ff0000',
};
expect(processedColor).toEqual(expectedColor);
});
}
});

Expand All @@ -120,6 +130,18 @@ describe('processColor', () => {
const expectedColor = {resource_paths: ['?attr/colorPrimary']};
expect(processedColor).toEqual(expectedColor);
});

it('should carry an unprocessed fallback on Android PlatformColor colors', () => {
const color = PlatformColorAndroid('?attr/colorPrimary', {
fallback: '#000000',
});
const processedColor = processColor(color);
const expectedColor = {
resource_paths: ['?attr/colorPrimary'],
fallback: '#000000',
};
expect(processedColor).toEqual(expectedColor);
});
}
});
});
73 changes: 73 additions & 0 deletions packages/react-native/React/Base/RCTConvert.mm
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,71 @@ + (RCTColorSpace)RCTColorSpaceFromString:(NSString *)colorSpace
return RCTGetDefaultColorSpace();
}

// Parses a raw hex color string (#RGB, #RGBA, #RRGGBB, #RRGGBBAA — alpha last, as
// in CSS) into a UIColor, or nil if it is not a supported hex color. Used only as
// a PlatformColor fallback when every semantic name misses. (On the iOS New
// Architecture the fallback is parsed by the shared CSS color parser, which also
// accepts rgb()/rgba() and named colors; this legacy path handles the common hex
// forms.)
static UIColor *RCTFallbackUIColorFromHexString(NSString *colorString)
{
if (![colorString isKindOfClass:[NSString class]]) {
return nil;
}
NSString *hex = [colorString stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
if (![hex hasPrefix:@"#"]) {
return nil;
}
hex = [hex substringFromIndex:1];
NSUInteger length = hex.length;
// Expand shorthand #RGB / #RGBA to #RRGGBB / #RRGGBBAA.
if (length == 3 || length == 4) {
NSMutableString *expanded = [NSMutableString stringWithCapacity:length * 2];
for (NSUInteger i = 0; i < length; i++) {
unichar c = [hex characterAtIndex:i];
[expanded appendFormat:@"%C%C", c, c];
}
hex = expanded;
length = hex.length;
}
if (length != 6 && length != 8) {
return nil;
}
NSCharacterSet *nonHex = [[NSCharacterSet characterSetWithCharactersInString:@"0123456789abcdefABCDEF"] invertedSet];
if ([hex rangeOfCharacterFromSet:nonHex].location != NSNotFound) {
return nil;
}
unsigned int value = 0;
if (![[NSScanner scannerWithString:hex] scanHexInt:&value]) {
return nil;
}
CGFloat r;
CGFloat g;
CGFloat b;
CGFloat a;
if (length == 6) {
r = ((value >> 16) & 0xFF) / 255.0;
g = ((value >> 8) & 0xFF) / 255.0;
b = (value & 0xFF) / 255.0;
a = 1.0;
} else {
r = ((value >> 24) & 0xFF) / 255.0;
g = ((value >> 16) & 0xFF) / 255.0;
b = ((value >> 8) & 0xFF) / 255.0;
a = (value & 0xFF) / 255.0;
}
return [UIColor colorWithRed:r green:g blue:b alpha:a];
}

static UIColor *RCTFallbackUIColorFromColorDictionary(NSDictionary *dictionary)
{
id fallback = [dictionary objectForKey:@"fallback"];
if ([fallback isKindOfClass:[NSString class]]) {
return RCTFallbackUIColorFromHexString(fallback);
}
return nil;
}

+ (UIColor *)UIColor:(id)json
{
if (!json) {
Expand Down Expand Up @@ -983,6 +1048,10 @@ + (UIColor *)UIColor:(id)json
}
color = RCTColorFromSemanticColorName(semanticName);
if (color == nil) {
UIColor *fallbackColor = RCTFallbackUIColorFromColorDictionary(dictionary);
if (fallbackColor != nil) {
return fallbackColor;
}
RCTLogConvertError(
json,
[@"a UIColor. Expected one of the following values: " stringByAppendingString:RCTSemanticColorNames()]);
Expand All @@ -999,6 +1068,10 @@ + (UIColor *)UIColor:(id)json
return color;
}
}
UIColor *fallbackColor = RCTFallbackUIColorFromColorDictionary(dictionary);
if (fallbackColor != nil) {
return fallbackColor;
}
RCTLogConvertError(
json,
[@"a UIColor. None of the names in the array were one of the following values: "
Expand Down
2 changes: 1 addition & 1 deletion packages/react-native/ReactAndroid/api/ReactAndroid.api
Original file line number Diff line number Diff line change
Expand Up @@ -2252,7 +2252,7 @@ public class com/facebook/react/fabric/FabricUIManager : com/facebook/react/brid
public fun dispatchCommand (IILjava/lang/String;Lcom/facebook/react/bridge/ReadableArray;)V
public fun dispatchCommand (ILjava/lang/String;Lcom/facebook/react/bridge/ReadableArray;)V
public fun findNextFocusableElement (III)Ljava/lang/Integer;
public fun getColor (I[Ljava/lang/String;)I
public fun getColor (I[Ljava/lang/String;)Ljava/lang/Integer;
public fun getEventDispatcher ()Lcom/facebook/react/uimanager/events/EventDispatcher;
public fun getPerformanceCounters ()Ljava/util/Map;
public fun getRelativeAncestorList (II)[I
Expand Down
Loading
Loading