From f4de45800f25930a1c70f757d12269d859066d3d Mon Sep 17 00:00:00 2001 From: Tom Underhill Date: Mon, 2 Mar 2020 15:07:50 -0800 Subject: [PATCH] PlatformColor implementations for iOS and Android (#27908) Summary: This Pull Request implements the PlatformColor proposal discussed at https://github.com/react-native-community/discussions-and-proposals/issues/126. The changes include implementations for iOS and Android as well as a PlatformColorExample page in RNTester. Every native platform has the concept of system defined colors. Instead of specifying a concrete color value the app developer can choose a system color that varies in appearance depending on a system theme settings such Light or Dark mode, accessibility settings such as a High Contrast mode, and even its context within the app such as the traits of a containing view or window. The proposal is to add true platform color support to react-native by extending the Flow type `ColorValue` with platform specific color type information for each platform and to provide a convenience function, `PlatformColor()`, for instantiating platform specific ColorValue objects. `PlatformColor(name [, name ...])` where `name` is a system color name on a given platform. If `name` does not resolve to a color for any reason, the next `name` in the argument list will be resolved and so on. If none of the names resolve, a RedBox error occurs. This allows a latest platform color to be used, but if running on an older platform it will fallback to a previous version. The function returns a `ColorValue`. On iOS the values of `name` is one of the iOS [UI Element](https://developer.apple.com/documentation/uikit/uicolor/ui_element_colors) or [Standard Color](https://developer.apple.com/documentation/uikit/uicolor/standard_colors) names such as `labelColor` or `systemFillColor`. On Android the `name` values are the same [app resource](https://developer.android.com/guide/topics/resources/providing-resources) path strings that can be expressed in XML: XML Resource: `@ [:]/` Style reference from current theme: `?[:][/]` For example: - `?android:colorError` - `?android:attr/colorError` - `?attr/colorPrimary` - `?colorPrimaryDark` - `android:color/holo_purple` - `color/catalyst_redbox_background` On iOS another type of system dynamic color can be created using the `IOSDynamicColor({dark: , light:})` method. The arguments are a tuple containing custom colors for light and dark themes. Such dynamic colors are useful for branding colors or other app specific colors that still respond automatically to system setting changes. Example: `` Other platforms could create platform specific functions similar to `IOSDynamicColor` per the needs of those platforms. For example, macOS has a similar dynamic color type that could be implemented via a `MacDynamicColor`. On Windows custom brushes that tint or otherwise modify a system brush could be created using a platform specific method. ## Changelog [General] [Added] - Added PlatformColor implementations for iOS and Android Pull Request resolved: https://github.com/facebook/react-native/pull/27908 Test Plan: The changes have been tested using the RNTester test app for iOS and Android. On iOS a set of XCTestCase's were added to the Unit Tests. PlatformColor-ios-android In addition `PlatformColor` support has been added to other out-of-tree platforms such as macOS and Windows has been implemented using these changes: react-native for macOS branch: https://github.com/microsoft/react-native/compare/master...tom-un:tomun/platformcolors react-native for Windows branch: https://github.com/microsoft/react-native-windows/compare/master...tom-un:tomun/platformcolors iOS |Light|Dark| |{F229354502}|{F229354515}| Android |Light|Dark| |{F230114392}|{F230114490}| {F230122700} Reviewed By: hramos Differential Revision: D19837753 Pulled By: TheSavior fbshipit-source-id: 82ca70d40802f3b24591bfd4b94b61f3c38ba829 --- Libraries/ART/ARTSurfaceView.m | 1 + Libraries/ActionSheetIOS/ActionSheetIOS.js | 4 +- .../src/nodes/AnimatedInterpolation.js | 14 +- .../ActivityIndicator/ActivityIndicator.js | 3 +- Libraries/Components/Button.js | 3 +- .../AndroidCheckBoxNativeComponent.js | 8 +- .../DrawerLayoutAndroid.android.js | 2 +- .../AndroidDialogPickerNativeComponent.js | 3 +- .../AndroidDropdownPickerNativeComponent.js | 3 +- Libraries/Components/Picker/PickerIOS.ios.js | 3 +- .../Picker/RCTPickerNativeComponent.js | 3 +- .../ProgressBarAndroid.android.js | 3 +- Libraries/Components/StatusBar/StatusBar.js | 3 +- Libraries/Pressability/PressabilityDebug.js | 10 +- Libraries/Share/Share.js | 2 +- .../PlatformColorValueTypes.android.js | 41 ++ .../StyleSheet/PlatformColorValueTypes.ios.js | 77 ++++ .../PlatformColorValueTypesAndroid.android.js | 18 + .../PlatformColorValueTypesAndroid.js | 17 + .../PlatformColorValueTypesIOS.ios.js | 23 ++ .../StyleSheet/PlatformColorValueTypesIOS.js | 22 ++ Libraries/StyleSheet/StyleSheetTypes.js | 5 +- .../__tests__/normalizeColor-test.js | 52 +++ .../StyleSheet/__tests__/processColor-test.js | 36 ++ .../__tests__/processColorArray-test.js | 51 +++ Libraries/StyleSheet/normalizeColor.js | 22 +- Libraries/StyleSheet/processColor.js | 37 +- Libraries/StyleSheet/processColorArray.js | 6 +- Libraries/Text/Text/RCTTextView.m | 1 + .../RNTesterPods.xcodeproj/project.pbxproj | 4 + .../RCTConvert_UIColorTests.m | 200 ++++++++++ RNTester/js/components/RNTesterTheme.js | 41 +- .../examples/Appearance/AppearanceExample.js | 4 +- .../PlatformColor/PlatformColorExample.js | 355 ++++++++++++++++++ RNTester/js/utils/RNTesterList.android.js | 4 + RNTester/js/utils/RNTesterList.ios.js | 5 + React/Base/RCTConvert.m | 251 +++++++++++++ React/Base/RCTUtils.h | 3 + React/Base/RCTUtils.m | 2 +- React/Views/RCTView.m | 26 +- ReactAndroid/build.gradle | 1 + .../react/bridge/ColorPropConverter.java | 124 ++++++ .../uimanager/BaseViewManagerDelegate.java | 4 +- .../uimanager/ViewManagersPropertyCache.java | 61 ++- .../SwipeRefreshLayoutManager.java | 7 +- .../views/text/ReactBaseTextShadowNode.java | 2 +- .../ReactPropForShadowNodeSetterTest.java | 1 + .../react/views/text/ReactTextTest.java | 2 + index.js | 15 + .../components/__test_fixtures__/fixtures.js | 14 +- .../component-parser-test.js.snap | 100 +++++ .../src/parsers/flow/components/props.js | 2 + 52 files changed, 1621 insertions(+), 80 deletions(-) create mode 100644 Libraries/StyleSheet/PlatformColorValueTypes.android.js create mode 100644 Libraries/StyleSheet/PlatformColorValueTypes.ios.js create mode 100644 Libraries/StyleSheet/PlatformColorValueTypesAndroid.android.js create mode 100644 Libraries/StyleSheet/PlatformColorValueTypesAndroid.js create mode 100644 Libraries/StyleSheet/PlatformColorValueTypesIOS.ios.js create mode 100644 Libraries/StyleSheet/PlatformColorValueTypesIOS.js create mode 100644 RNTester/RNTesterUnitTests/RCTConvert_UIColorTests.m create mode 100644 RNTester/js/examples/PlatformColor/PlatformColorExample.js create mode 100644 ReactAndroid/src/main/java/com/facebook/react/bridge/ColorPropConverter.java diff --git a/Libraries/ART/ARTSurfaceView.m b/Libraries/ART/ARTSurfaceView.m index a715efec5b..99cb127593 100644 --- a/Libraries/ART/ARTSurfaceView.m +++ b/Libraries/ART/ARTSurfaceView.m @@ -47,6 +47,7 @@ - (void)drawRect:(CGRect)rect { + [super drawRect:rect]; CGContextRef context = UIGraphicsGetCurrentContext(); for (ARTNode *node in self.subviews) { [node renderTo:context]; diff --git a/Libraries/ActionSheetIOS/ActionSheetIOS.js b/Libraries/ActionSheetIOS/ActionSheetIOS.js index d3b904fbbb..e330253df8 100644 --- a/Libraries/ActionSheetIOS/ActionSheetIOS.js +++ b/Libraries/ActionSheetIOS/ActionSheetIOS.js @@ -14,6 +14,8 @@ import RCTActionSheetManager from './NativeActionSheetManager'; const invariant = require('invariant'); const processColor = require('../StyleSheet/processColor'); +import type {ColorValue} from '../StyleSheet/StyleSheetTypes'; +import type {ProcessedColorValue} from '../StyleSheet/processColor'; /** * Display action sheets and share sheets on iOS. @@ -45,7 +47,7 @@ const ActionSheetIOS = { +destructiveButtonIndex?: ?number | ?Array, +cancelButtonIndex?: ?number, +anchor?: ?number, - +tintColor?: number | string, + +tintColor?: ColorValue | ProcessedColorValue, +userInterfaceStyle?: string, |}, callback: (buttonIndex: number) => void, diff --git a/Libraries/Animated/src/nodes/AnimatedInterpolation.js b/Libraries/Animated/src/nodes/AnimatedInterpolation.js index 49bcca12f6..a32bb9c2a7 100644 --- a/Libraries/Animated/src/nodes/AnimatedInterpolation.js +++ b/Libraries/Animated/src/nodes/AnimatedInterpolation.js @@ -164,17 +164,17 @@ function interpolate( } function colorToRgba(input: string): string { - let int32Color = normalizeColor(input); - if (int32Color === null) { + let normalizedColor = normalizeColor(input); + if (normalizedColor === null || typeof normalizedColor !== 'number') { return input; } - int32Color = int32Color || 0; + normalizedColor = normalizedColor || 0; - const r = (int32Color & 0xff000000) >>> 24; - const g = (int32Color & 0x00ff0000) >>> 16; - const b = (int32Color & 0x0000ff00) >>> 8; - const a = (int32Color & 0x000000ff) / 255; + const r = (normalizedColor & 0xff000000) >>> 24; + const g = (normalizedColor & 0x00ff0000) >>> 16; + const b = (normalizedColor & 0x0000ff00) >>> 8; + const a = (normalizedColor & 0x000000ff) / 255; return `rgba(${r}, ${g}, ${b}, ${a})`; } diff --git a/Libraries/Components/ActivityIndicator/ActivityIndicator.js b/Libraries/Components/ActivityIndicator/ActivityIndicator.js index 0776f1b196..9d0df8f0a7 100644 --- a/Libraries/Components/ActivityIndicator/ActivityIndicator.js +++ b/Libraries/Components/ActivityIndicator/ActivityIndicator.js @@ -16,6 +16,7 @@ const StyleSheet = require('../../StyleSheet/StyleSheet'); const View = require('../View/View'); import type {HostComponent} from '../../Renderer/shims/ReactNativeTypes'; import type {ViewProps} from '../View/ViewPropTypes'; +import type {ColorValue} from '../../StyleSheet/StyleSheetTypes'; const PlatformActivityIndicator = Platform.OS === 'android' @@ -50,7 +51,7 @@ type Props = $ReadOnly<{| * * See https://reactnative.dev/docs/activityindicator.html#color */ - color?: ?string, + color?: ?ColorValue, /** * Size of the indicator (default is 'small'). diff --git a/Libraries/Components/Button.js b/Libraries/Components/Button.js index 6a2b8244f2..82ea7a1b0a 100644 --- a/Libraries/Components/Button.js +++ b/Libraries/Components/Button.js @@ -21,6 +21,7 @@ const View = require('./View/View'); const invariant = require('invariant'); import type {PressEvent} from '../Types/CoreEventTypes'; +import type {ColorValue} from '../StyleSheet/StyleSheetTypes'; type ButtonProps = $ReadOnly<{| /** @@ -41,7 +42,7 @@ type ButtonProps = $ReadOnly<{| /** * Color of the text (iOS), or background color of the button (Android) */ - color?: ?string, + color?: ?ColorValue, /** * TV preferred focus (see documentation for the View component). diff --git a/Libraries/Components/CheckBox/AndroidCheckBoxNativeComponent.js b/Libraries/Components/CheckBox/AndroidCheckBoxNativeComponent.js index 3062ae85c3..2afb989269 100644 --- a/Libraries/Components/CheckBox/AndroidCheckBoxNativeComponent.js +++ b/Libraries/Components/CheckBox/AndroidCheckBoxNativeComponent.js @@ -19,6 +19,7 @@ const requireNativeComponent = require('../../ReactNative/requireNativeComponent import type {HostComponent} from '../../Renderer/shims/ReactNativeTypes'; import type {ViewProps} from '../View/ViewPropTypes'; import type {SyntheticEvent} from '../../Types/CoreEventTypes'; +import type {ProcessedColorValue} from '../../StyleSheet/processColor'; type CheckBoxEvent = SyntheticEvent< $ReadOnly<{| @@ -47,7 +48,12 @@ type NativeProps = $ReadOnly<{| on?: ?boolean, enabled?: boolean, - tintColors: {|true: ?number, false: ?number|} | typeof undefined, + tintColors: + | {| + true: ?ProcessedColorValue, + false: ?ProcessedColorValue, + |} + | typeof undefined, |}>; type NativeType = HostComponent; diff --git a/Libraries/Components/DrawerAndroid/DrawerLayoutAndroid.android.js b/Libraries/Components/DrawerAndroid/DrawerLayoutAndroid.android.js index b09f2fc8e8..3758c669fd 100644 --- a/Libraries/Components/DrawerAndroid/DrawerLayoutAndroid.android.js +++ b/Libraries/Components/DrawerAndroid/DrawerLayoutAndroid.android.js @@ -185,7 +185,7 @@ class DrawerLayoutAndroid extends React.Component { ...props } = this.props; const drawStatusBar = - Platform.Version >= 21 && this.props.statusBarBackgroundColor; + Platform.Version >= 21 && this.props.statusBarBackgroundColor != null; const drawerViewWrapper = ( ; type PickerItemSelectEvent = $ReadOnly<{| diff --git a/Libraries/Components/Picker/AndroidDropdownPickerNativeComponent.js b/Libraries/Components/Picker/AndroidDropdownPickerNativeComponent.js index ce4ca119a5..ca155bc2e3 100644 --- a/Libraries/Components/Picker/AndroidDropdownPickerNativeComponent.js +++ b/Libraries/Components/Picker/AndroidDropdownPickerNativeComponent.js @@ -23,11 +23,12 @@ import type { import type {HostComponent} from '../../Renderer/shims/ReactNativeTypes'; import type {TextStyleProp} from '../../StyleSheet/StyleSheet'; import type {ColorValue} from '../../StyleSheet/StyleSheetTypes'; +import type {ProcessedColorValue} from '../../StyleSheet/processColor'; import type {ViewProps} from '../../Components/View/ViewPropTypes'; type PickerItem = $ReadOnly<{| label: string, - color?: ?Int32, + color?: ?ProcessedColorValue, |}>; type PickerItemSelectEvent = $ReadOnly<{| diff --git a/Libraries/Components/Picker/PickerIOS.ios.js b/Libraries/Components/Picker/PickerIOS.ios.js index e99f2b9f67..c2a5f2946f 100644 --- a/Libraries/Components/Picker/PickerIOS.ios.js +++ b/Libraries/Components/Picker/PickerIOS.ios.js @@ -24,6 +24,7 @@ import RCTPickerNativeComponent, { } from './RCTPickerNativeComponent'; import type {TextStyleProp} from '../../StyleSheet/StyleSheet'; import type {ColorValue} from '../../StyleSheet/StyleSheetTypes'; +import type {ProcessedColorValue} from '../../StyleSheet/processColor'; import type {SyntheticEvent} from '../../Types/CoreEventTypes'; import type {ViewProps} from '../View/ViewPropTypes'; @@ -37,7 +38,7 @@ type PickerIOSChangeEvent = SyntheticEvent< type RCTPickerIOSItemType = $ReadOnly<{| label: ?Label, value: ?(number | string), - textColor: ?number, + textColor: ?ProcessedColorValue, |}>; type Label = Stringish | number; diff --git a/Libraries/Components/Picker/RCTPickerNativeComponent.js b/Libraries/Components/Picker/RCTPickerNativeComponent.js index 1456468138..ca42471b60 100644 --- a/Libraries/Components/Picker/RCTPickerNativeComponent.js +++ b/Libraries/Components/Picker/RCTPickerNativeComponent.js @@ -15,6 +15,7 @@ const requireNativeComponent = require('../../ReactNative/requireNativeComponent import type {HostComponent} from '../../Renderer/shims/ReactNativeTypes'; import type {SyntheticEvent} from '../../Types/CoreEventTypes'; import type {TextStyleProp} from '../../StyleSheet/StyleSheet'; +import type {ProcessedColorValue} from '../../StyleSheet/processColor'; import codegenNativeCommands from '../../Utilities/codegenNativeCommands'; import * as React from 'react'; @@ -28,7 +29,7 @@ type PickerIOSChangeEvent = SyntheticEvent< type RCTPickerIOSItemType = $ReadOnly<{| label: ?Label, value: ?(number | string), - textColor: ?number, + textColor: ?ProcessedColorValue, |}>; type Label = Stringish | number; diff --git a/Libraries/Components/ProgressBarAndroid/ProgressBarAndroid.android.js b/Libraries/Components/ProgressBarAndroid/ProgressBarAndroid.android.js index a2651088bb..7b10a05215 100644 --- a/Libraries/Components/ProgressBarAndroid/ProgressBarAndroid.android.js +++ b/Libraries/Components/ProgressBarAndroid/ProgressBarAndroid.android.js @@ -15,6 +15,7 @@ const React = require('react'); import ProgressBarAndroidNativeComponent from './ProgressBarAndroidNativeComponent'; import type {ViewProps} from '../View/ViewPropTypes'; +import type {ColorValue} from '../../StyleSheet/StyleSheetTypes'; export type ProgressBarAndroidProps = $ReadOnly<{| ...ViewProps, @@ -49,7 +50,7 @@ export type ProgressBarAndroidProps = $ReadOnly<{| /** * Color of the progress bar. */ - color?: ?string, + color?: ?ColorValue, /** * Used to locate this view in end-to-end tests. */ diff --git a/Libraries/Components/StatusBar/StatusBar.js b/Libraries/Components/StatusBar/StatusBar.js index eb7e8661d0..81ab87e4d3 100644 --- a/Libraries/Components/StatusBar/StatusBar.js +++ b/Libraries/Components/StatusBar/StatusBar.js @@ -15,6 +15,7 @@ const React = require('react'); const invariant = require('invariant'); const processColor = require('../../StyleSheet/processColor'); +import type {ColorValue} from '../../StyleSheet/StyleSheetTypes'; import NativeStatusBarManagerAndroid from './NativeStatusBarManagerAndroid'; import NativeStatusBarManagerIOS from './NativeStatusBarManagerIOS'; @@ -62,7 +63,7 @@ type AndroidProps = $ReadOnly<{| * The background color of the status bar. * @platform android */ - backgroundColor?: ?string, + backgroundColor?: ?ColorValue, /** * If the status bar is translucent. * When translucent is set to true, the app will draw under the status bar. diff --git a/Libraries/Pressability/PressabilityDebug.js b/Libraries/Pressability/PressabilityDebug.js index 36df2290a7..058b025c5f 100644 --- a/Libraries/Pressability/PressabilityDebug.js +++ b/Libraries/Pressability/PressabilityDebug.js @@ -11,12 +11,14 @@ 'use strict'; import normalizeColor from '../StyleSheet/normalizeColor.js'; +import type {ColorValue} from '../StyleSheet/StyleSheetTypes'; + import Touchable from '../Components/Touchable/Touchable'; import View from '../Components/View/View'; import * as React from 'react'; type Props = $ReadOnly<{| - color: string, + color: ColorValue, hitSlop: ?$ReadOnly<{| bottom?: ?number, left?: ?number, @@ -43,8 +45,12 @@ type Props = $ReadOnly<{| export function PressabilityDebugView({color, hitSlop}: Props): React.Node { if (__DEV__) { if (isEnabled()) { + const normalizedColor = normalizeColor(color); + if (typeof normalizedColor !== 'number') { + return null; + } const baseColor = - '#' + (normalizeColor(color) ?? 0).toString(16).padStart(8, '0'); + '#' + (normalizedColor ?? 0).toString(16).padStart(8, '0'); return ( reject(error), diff --git a/Libraries/StyleSheet/PlatformColorValueTypes.android.js b/Libraries/StyleSheet/PlatformColorValueTypes.android.js new file mode 100644 index 0000000000..1458a9b439 --- /dev/null +++ b/Libraries/StyleSheet/PlatformColorValueTypes.android.js @@ -0,0 +1,41 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow strict-local + */ + +'use strict'; + +import type {ColorValue} from './StyleSheetTypes'; +import type {ProcessedColorValue} from './processColor'; + +export opaque type NativeColorValue = { + resource_paths?: Array, +}; + +export const PlatformColor = (...names: Array): ColorValue => { + return {resource_paths: names}; +}; + +export const ColorAndroidPrivate = (color: string): ColorValue => { + return {resource_paths: [color]}; +}; + +export const normalizeColorObject = ( + color: NativeColorValue, +): ?ProcessedColorValue => { + if ('resource_paths' in color) { + return color; + } + return null; +}; + +export const processColorObject = ( + color: NativeColorValue, +): ?NativeColorValue => { + return color; +}; diff --git a/Libraries/StyleSheet/PlatformColorValueTypes.ios.js b/Libraries/StyleSheet/PlatformColorValueTypes.ios.js new file mode 100644 index 0000000000..d329db9316 --- /dev/null +++ b/Libraries/StyleSheet/PlatformColorValueTypes.ios.js @@ -0,0 +1,77 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow strict-local + */ + +'use strict'; + +import type {ColorValue} from './StyleSheetTypes'; +import type {ProcessedColorValue} from './processColor'; + +export opaque type NativeColorValue = { + semantic?: Array, + dynamic?: { + light: ?(ColorValue | ProcessedColorValue), + dark: ?(ColorValue | ProcessedColorValue), + }, +}; + +export const PlatformColor = (...names: Array): ColorValue => { + return {semantic: names}; +}; + +export type DynamicColorIOSTuplePrivate = { + light: ColorValue, + dark: ColorValue, +}; + +export const DynamicColorIOSPrivate = ( + tuple: DynamicColorIOSTuplePrivate, +): ColorValue => { + return {dynamic: {light: tuple.light, dark: tuple.dark}}; +}; + +export const normalizeColorObject = ( + color: NativeColorValue, +): ?ProcessedColorValue => { + if ('semantic' in color) { + // an ios semantic color + return color; + } else if ('dynamic' in color && color.dynamic !== undefined) { + const normalizeColor = require('./normalizeColor'); + + // a dynamic, appearance aware color + const dynamic = color.dynamic; + const dynamicColor: NativeColorValue = { + dynamic: { + light: normalizeColor(dynamic.light), + dark: normalizeColor(dynamic.dark), + }, + }; + return dynamicColor; + } + + return null; +}; + +export const processColorObject = ( + color: NativeColorValue, +): ?NativeColorValue => { + if ('dynamic' in color && color.dynamic != null) { + const processColor = require('./processColor'); + const dynamic = color.dynamic; + const dynamicColor: NativeColorValue = { + dynamic: { + light: processColor(dynamic.light), + dark: processColor(dynamic.dark), + }, + }; + return dynamicColor; + } + return color; +}; diff --git a/Libraries/StyleSheet/PlatformColorValueTypesAndroid.android.js b/Libraries/StyleSheet/PlatformColorValueTypesAndroid.android.js new file mode 100644 index 0000000000..58f551098f --- /dev/null +++ b/Libraries/StyleSheet/PlatformColorValueTypesAndroid.android.js @@ -0,0 +1,18 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow strict-local + */ + +'use strict'; + +import type {ColorValue} from './StyleSheetTypes'; +import {ColorAndroidPrivate} from './PlatformColorValueTypes'; + +export const ColorAndroid = (color: string): ColorValue => { + return ColorAndroidPrivate(color); +}; diff --git a/Libraries/StyleSheet/PlatformColorValueTypesAndroid.js b/Libraries/StyleSheet/PlatformColorValueTypesAndroid.js new file mode 100644 index 0000000000..647000b3b1 --- /dev/null +++ b/Libraries/StyleSheet/PlatformColorValueTypesAndroid.js @@ -0,0 +1,17 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow strict-local + */ + +'use strict'; + +import type {ColorValue} from './StyleSheetTypes'; + +export const ColorAndroid = (color: string): ColorValue => { + throw new Error('ColorAndroid is not available on this platform.'); +}; diff --git a/Libraries/StyleSheet/PlatformColorValueTypesIOS.ios.js b/Libraries/StyleSheet/PlatformColorValueTypesIOS.ios.js new file mode 100644 index 0000000000..2b21c61f3d --- /dev/null +++ b/Libraries/StyleSheet/PlatformColorValueTypesIOS.ios.js @@ -0,0 +1,23 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow strict-local + */ + +'use strict'; + +import type {ColorValue} from './StyleSheetTypes'; +import {DynamicColorIOSPrivate} from './PlatformColorValueTypes'; + +export type DynamicColorIOSTuple = { + light: ColorValue, + dark: ColorValue, +}; + +export const DynamicColorIOS = (tuple: DynamicColorIOSTuple): ColorValue => { + return DynamicColorIOSPrivate({light: tuple.light, dark: tuple.dark}); +}; diff --git a/Libraries/StyleSheet/PlatformColorValueTypesIOS.js b/Libraries/StyleSheet/PlatformColorValueTypesIOS.js new file mode 100644 index 0000000000..cc9aa69e80 --- /dev/null +++ b/Libraries/StyleSheet/PlatformColorValueTypesIOS.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow strict-local + */ + +'use strict'; + +import type {ColorValue} from './StyleSheetTypes'; + +export type DynamicColorIOSTuple = { + light: ColorValue, + dark: ColorValue, +}; + +export const DynamicColorIOS = (tuple: DynamicColorIOSTuple): ColorValue => { + throw new Error('DynamicColorIOS is not available on this platform.'); +}; diff --git a/Libraries/StyleSheet/StyleSheetTypes.js b/Libraries/StyleSheet/StyleSheetTypes.js index 7d5d2f59af..81081fab0d 100644 --- a/Libraries/StyleSheet/StyleSheetTypes.js +++ b/Libraries/StyleSheet/StyleSheetTypes.js @@ -12,7 +12,10 @@ const AnimatedNode = require('../Animated/src/nodes/AnimatedNode'); -export type ColorValue = null | string; +import type {NativeColorValue} from './PlatformColorValueTypes'; + +export type ColorValue = null | string | NativeColorValue; + export type ColorArrayValue = null | $ReadOnlyArray; export type PointValue = {| x: number, diff --git a/Libraries/StyleSheet/__tests__/normalizeColor-test.js b/Libraries/StyleSheet/__tests__/normalizeColor-test.js index 35e8cfd03e..2a127626b6 100644 --- a/Libraries/StyleSheet/__tests__/normalizeColor-test.js +++ b/Libraries/StyleSheet/__tests__/normalizeColor-test.js @@ -10,8 +10,16 @@ 'use strict'; +const {OS} = require('../../Utilities/Platform'); const normalizeColor = require('../normalizeColor'); +const PlatformColorIOS = require('../PlatformColorValueTypes.ios') + .PlatformColor; +const DynamicColorIOS = require('../PlatformColorValueTypesIOS.ios') + .DynamicColorIOS; +const PlatformColorAndroid = require('../PlatformColorValueTypes.android') + .PlatformColor; + describe('normalizeColor', function() { it('should accept only spec compliant colors', function() { expect(normalizeColor('#abc')).not.toBe(null); @@ -128,4 +136,48 @@ describe('normalizeColor', function() { const normalizedColor = normalizeColor('red') || 0; expect(normalizeColor(normalizedColor)).toBe(normalizedColor); }); + + describe('iOS', () => { + if (OS === 'ios') { + it('should normalize iOS PlatformColor colors', () => { + const color = PlatformColorIOS('systemRedColor'); + const normalizedColor = normalizeColor(color); + const expectedColor = {semantic: ['systemRedColor']}; + expect(normalizedColor).toEqual(expectedColor); + }); + + it('should normalize iOS Dynamic colors with named colors', () => { + const color = DynamicColorIOS({light: 'black', dark: 'white'}); + const normalizedColor = normalizeColor(color); + const expectedColor = {dynamic: {light: 'black', dark: 'white'}}; + expect(normalizedColor).toEqual(expectedColor); + }); + + it('should normalize iOS Dynamic colors with PlatformColor colors', () => { + const color = DynamicColorIOS({ + light: PlatformColorIOS('systemBlackColor'), + dark: PlatformColorIOS('systemWhiteColor'), + }); + const normalizedColor = normalizeColor(color); + const expectedColor = { + dynamic: { + light: {semantic: ['systemBlackColor']}, + dark: {semantic: ['systemWhiteColor']}, + }, + }; + expect(normalizedColor).toEqual(expectedColor); + }); + } + }); + + describe('Android', () => { + if (OS === 'android') { + it('should normalize Android PlatformColor colors', () => { + const color = PlatformColorAndroid('?attr/colorPrimary'); + const normalizedColor = normalizeColor(color); + const expectedColor = {resource_paths: ['?attr/colorPrimary']}; + expect(normalizedColor).toEqual(expectedColor); + }); + } + }); }); diff --git a/Libraries/StyleSheet/__tests__/processColor-test.js b/Libraries/StyleSheet/__tests__/processColor-test.js index 0b60130e26..d428b854e8 100644 --- a/Libraries/StyleSheet/__tests__/processColor-test.js +++ b/Libraries/StyleSheet/__tests__/processColor-test.js @@ -13,6 +13,13 @@ const {OS} = require('../../Utilities/Platform'); const processColor = require('../processColor'); +const PlatformColorIOS = require('../PlatformColorValueTypes.ios') + .PlatformColor; +const DynamicColorIOS = require('../PlatformColorValueTypesIOS.ios') + .DynamicColorIOS; +const PlatformColorAndroid = require('../PlatformColorValueTypes.android') + .PlatformColor; + const platformSpecific = OS === 'android' ? unsigned => unsigned | 0 //eslint-disable-line no-bitwise @@ -84,4 +91,33 @@ describe('processColor', () => { expect(colorFromString).toEqual(platformSpecific(expectedInt)); }); }); + + describe('iOS', () => { + if (OS === 'ios') { + it('should process iOS PlatformColor colors', () => { + const color = PlatformColorIOS('systemRedColor'); + const processedColor = processColor(color); + const expectedColor = {semantic: ['systemRedColor']}; + expect(processedColor).toEqual(expectedColor); + }); + + it('should process iOS Dynamic colors', () => { + const color = DynamicColorIOS({light: 'black', dark: 'white'}); + const processedColor = processColor(color); + const expectedColor = {dynamic: {light: 0xff000000, dark: 0xffffffff}}; + expect(processedColor).toEqual(expectedColor); + }); + } + }); + + describe('Android', () => { + if (OS === 'android') { + it('should process Android PlatformColor colors', () => { + const color = PlatformColorAndroid('?attr/colorPrimary'); + const processedColor = processColor(color); + const expectedColor = {resource_paths: ['?attr/colorPrimary']}; + expect(processedColor).toEqual(expectedColor); + }); + } + }); }); diff --git a/Libraries/StyleSheet/__tests__/processColorArray-test.js b/Libraries/StyleSheet/__tests__/processColorArray-test.js index acd45cdd72..1be389cdb9 100644 --- a/Libraries/StyleSheet/__tests__/processColorArray-test.js +++ b/Libraries/StyleSheet/__tests__/processColorArray-test.js @@ -13,6 +13,13 @@ const {OS} = require('../../Utilities/Platform'); const processColorArray = require('../processColorArray'); +const PlatformColorIOS = require('../PlatformColorValueTypes.ios') + .PlatformColor; +const DynamicColorIOS = require('../PlatformColorValueTypesIOS.ios') + .DynamicColorIOS; +const PlatformColorAndroid = require('../PlatformColorValueTypes.android') + .PlatformColor; + const platformSpecific = OS === 'android' ? unsigned => unsigned | 0 //eslint-disable-line no-bitwise @@ -57,4 +64,48 @@ describe('processColorArray', () => { expect(colorFromNoArray).toEqual(null); }); }); + + describe('iOS', () => { + if (OS === 'ios') { + it('should convert array of iOS PlatformColor colors', () => { + const colorFromArray = processColorArray([ + PlatformColorIOS('systemColorWhite'), + PlatformColorIOS('systemColorBlack'), + ]); + const expectedColorValueArray = [ + {semantic: ['systemColorWhite']}, + {semantic: ['systemColorBlack']}, + ]; + expect(colorFromArray).toEqual(expectedColorValueArray); + }); + + it('should process iOS Dynamic colors', () => { + const colorFromArray = processColorArray([ + DynamicColorIOS({light: 'black', dark: 'white'}), + DynamicColorIOS({light: 'white', dark: 'black'}), + ]); + const expectedColorValueArray = [ + {dynamic: {light: 0xff000000, dark: 0xffffffff}}, + {dynamic: {light: 0xffffffff, dark: 0xff000000}}, + ]; + expect(colorFromArray).toEqual(expectedColorValueArray); + }); + } + }); + + describe('Android', () => { + if (OS === 'android') { + it('should convert array of Android PlatformColor colors', () => { + const colorFromArray = processColorArray([ + PlatformColorAndroid('?attr/colorPrimary'), + PlatformColorAndroid('?colorPrimaryDark'), + ]); + const expectedColorValueArray = [ + {resource_paths: ['?attr/colorPrimary']}, + {resource_paths: ['?colorPrimaryDark']}, + ]; + expect(colorFromArray).toEqual(expectedColorValueArray); + }); + } + }); }); diff --git a/Libraries/StyleSheet/normalizeColor.js b/Libraries/StyleSheet/normalizeColor.js index b14acc3106..eaee5813b1 100755 --- a/Libraries/StyleSheet/normalizeColor.js +++ b/Libraries/StyleSheet/normalizeColor.js @@ -12,7 +12,12 @@ 'use strict'; -function normalizeColor(color: string | number): ?number { +import type {ColorValue} from './StyleSheetTypes'; +import type {ProcessedColorValue} from './processColor'; + +function normalizeColor( + color: ?(ColorValue | ProcessedColorValue), +): ?ProcessedColorValue { const matchers = getMatchers(); let match; @@ -23,6 +28,21 @@ function normalizeColor(color: string | number): ?number { return null; } + if (typeof color === 'object' && color != null) { + const normalizeColorObject = require('./PlatformColorValueTypes') + .normalizeColorObject; + + const normalizedColorObj = normalizeColorObject(color); + + if (normalizedColorObj != null) { + return color; + } + } + + if (typeof color !== 'string') { + return null; + } + // Ordered based on occurrences on Facebook codebase if ((match = matchers.hex6.exec(color))) { return parseInt(match[1] + 'ff', 16) >>> 0; diff --git a/Libraries/StyleSheet/processColor.js b/Libraries/StyleSheet/processColor.js index 46b298e7be..3bfa9679e0 100644 --- a/Libraries/StyleSheet/processColor.js +++ b/Libraries/StyleSheet/processColor.js @@ -14,35 +14,48 @@ const Platform = require('../Utilities/Platform'); const normalizeColor = require('./normalizeColor'); -// TODO: This is an empty object for now, just to enforce that everything using this -// downstream is correct. This will be replaced with an import to other files -// with a platform specific implementation. See the PR for more information -// https://github.com/facebook/react-native/pull/27908 -opaque type NativeColorType = {}; -export type ProcessedColorValue = ?number | NativeColorType; +import type {ColorValue} from './StyleSheetTypes'; +import type {NativeColorValue} from './PlatformColorValueTypes'; + +export type ProcessedColorValue = number | NativeColorValue; /* eslint no-bitwise: 0 */ -function processColor(color?: ?(string | number)): ProcessedColorValue { +function processColor(color?: ?(number | ColorValue)): ?ProcessedColorValue { if (color === undefined || color === null) { return color; } - let int32Color = normalizeColor(color); - if (int32Color === null || int32Color === undefined) { + let normalizedColor = normalizeColor(color); + if (normalizedColor === null || normalizedColor === undefined) { return undefined; } + if (typeof normalizedColor === 'object') { + const processColorObject = require('./PlatformColorValueTypes') + .processColorObject; + + const processedColorObj = processColorObject(normalizedColor); + + if (processedColorObj != null) { + return processedColorObj; + } + } + + if (typeof normalizedColor !== 'number') { + return null; + } + // Converts 0xrrggbbaa into 0xaarrggbb - int32Color = ((int32Color << 24) | (int32Color >>> 8)) >>> 0; + normalizedColor = ((normalizedColor << 24) | (normalizedColor >>> 8)) >>> 0; if (Platform.OS === 'android') { // Android use 32 bit *signed* integer to represent the color // We utilize the fact that bitwise operations in JS also operates on // signed 32 bit integers, so that we can use those to convert from // *unsigned* to *signed* 32bit int that way. - int32Color = int32Color | 0x0; + normalizedColor = normalizedColor | 0x0; } - return int32Color; + return normalizedColor; } module.exports = processColor; diff --git a/Libraries/StyleSheet/processColorArray.js b/Libraries/StyleSheet/processColorArray.js index e84482c8cb..9bdb026b5b 100644 --- a/Libraries/StyleSheet/processColorArray.js +++ b/Libraries/StyleSheet/processColorArray.js @@ -11,11 +11,13 @@ 'use strict'; const processColor = require('./processColor'); + +import type {ColorValue} from './StyleSheetTypes'; import type {ProcessedColorValue} from './processColor'; function processColorArray( - colors: ?Array, -): ?Array { + colors: ?Array, +): ?Array { return colors == null ? null : colors.map(processColor); } diff --git a/Libraries/Text/Text/RCTTextView.m b/Libraries/Text/Text/RCTTextView.m index 3f4382162d..d0d19a988e 100644 --- a/Libraries/Text/Text/RCTTextView.m +++ b/Libraries/Text/Text/RCTTextView.m @@ -96,6 +96,7 @@ - (void)drawRect:(CGRect)rect { + [super drawRect:rect]; if (!_textStorage) { return; } diff --git a/RNTester/RNTesterPods.xcodeproj/project.pbxproj b/RNTester/RNTesterPods.xcodeproj/project.pbxproj index 9ce41dc135..b79cc14cfa 100644 --- a/RNTester/RNTesterPods.xcodeproj/project.pbxproj +++ b/RNTester/RNTesterPods.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 272E6B3F1BEA849E001FCF37 /* UpdatePropertiesExampleView.m in Sources */ = {isa = PBXBuildFile; fileRef = 272E6B3C1BEA849E001FCF37 /* UpdatePropertiesExampleView.m */; }; 27F441EC1BEBE5030039B79C /* FlexibleSizeExampleView.m in Sources */ = {isa = PBXBuildFile; fileRef = 27F441E81BEBE5030039B79C /* FlexibleSizeExampleView.m */; }; 2DDEF0101F84BF7B00DBDF73 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2DDEF00F1F84BF7B00DBDF73 /* Images.xcassets */; }; + 383889DA23A7398900D06C3E /* RCTConvert_UIColorTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 383889D923A7398900D06C3E /* RCTConvert_UIColorTests.m */; }; 3D2AFAF51D646CF80089D1A3 /* legacy_image@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 3D2AFAF41D646CF80089D1A3 /* legacy_image@2x.png */; }; 5C60EB1C226440DB0018C04F /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 5C60EB1B226440DB0018C04F /* AppDelegate.mm */; }; 5CB07C9B226467E60039471C /* RNTesterTurboModuleProvider.mm in Sources */ = {isa = PBXBuildFile; fileRef = 5CB07C99226467E60039471C /* RNTesterTurboModuleProvider.mm */; }; @@ -81,6 +82,7 @@ 27F441EA1BEBE5030039B79C /* FlexibleSizeExampleView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = FlexibleSizeExampleView.h; path = RNTester/NativeExampleViews/FlexibleSizeExampleView.h; sourceTree = ""; }; 2DDEF00F1F84BF7B00DBDF73 /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = RNTester/Images.xcassets; sourceTree = ""; }; 34028D6B10F47E490042EB27 /* Pods-RNTesterUnitTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNTesterUnitTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-RNTesterUnitTests/Pods-RNTesterUnitTests.debug.xcconfig"; sourceTree = ""; }; + 383889D923A7398900D06C3E /* RCTConvert_UIColorTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTConvert_UIColorTests.m; sourceTree = ""; }; 3D2AFAF41D646CF80089D1A3 /* legacy_image@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "legacy_image@2x.png"; path = "RNTester/legacy_image@2x.png"; sourceTree = ""; }; 5BEC8567F3741044B6A5EFC5 /* Pods-RNTester.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNTester.release.xcconfig"; path = "Pods/Target Support Files/Pods-RNTester/Pods-RNTester.release.xcconfig"; sourceTree = ""; }; 5C60EB1B226440DB0018C04F /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = RNTester/AppDelegate.mm; sourceTree = ""; }; @@ -324,6 +326,7 @@ E7DB20CC22B2BAA5005AC45F /* RCTComponentPropsTests.m */, E7DB20CA22B2BAA5005AC45F /* RCTConvert_NSURLTests.m */, E7DB20CE22B2BAA5005AC45F /* RCTConvert_YGValueTests.m */, + 383889D923A7398900D06C3E /* RCTConvert_UIColorTests.m */, E7DB20C822B2BAA5005AC45F /* RCTDevMenuTests.m */, E7DB20C022B2BAA4005AC45F /* RCTEventDispatcherTests.m */, E7DB20AF22B2BAA4005AC45F /* RCTFontTests.m */, @@ -686,6 +689,7 @@ E7DB20D322B2BAA6005AC45F /* RCTBlobManagerTests.m in Sources */, E7DB20DC22B2BAA6005AC45F /* RCTUIManagerTests.m in Sources */, E7DB20E322B2BAA6005AC45F /* RCTAllocationTests.m in Sources */, + 383889DA23A7398900D06C3E /* RCTConvert_UIColorTests.m in Sources */, E7DB20E622B2BAA6005AC45F /* RCTImageLoaderHelpers.m in Sources */, E7DB20D622B2BAA6005AC45F /* RCTFontTests.m in Sources */, E7DB20DB22B2BAA6005AC45F /* RCTNativeAnimatedNodesManagerTests.m in Sources */, diff --git a/RNTester/RNTesterUnitTests/RCTConvert_UIColorTests.m b/RNTester/RNTesterUnitTests/RCTConvert_UIColorTests.m new file mode 100644 index 0000000000..fbb6824885 --- /dev/null +++ b/RNTester/RNTesterUnitTests/RCTConvert_UIColorTests.m @@ -0,0 +1,200 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#import + +@interface RCTConvert_NSColorTests : XCTestCase + +@end + +static BOOL CGColorsAreEqual(CGColorRef color1, CGColorRef color2) { + CGFloat rgba1[4]; + CGFloat rgba2[4]; + RCTGetRGBAColorComponents(color1, rgba1); + RCTGetRGBAColorComponents(color2, rgba2); + for (int i = 0; i < 4; i++) { + if (rgba1[i] != rgba2[i]) { + return NO; + } + } + return YES; +} + +@implementation RCTConvert_NSColorTests + +- (void)testColor +{ + id json = RCTJSONParse(@"{ \"semantic\": \"lightTextColor\" }", nil); + UIColor *value = [RCTConvert UIColor:json]; + XCTAssertEqualObjects(value, [UIColor lightTextColor]); +} + +- (void)testColorFailure +{ + id json = RCTJSONParse(@"{ \"semantic\": \"bogusColor\" }", nil); + + __block NSString *errorMessage = nil; + RCTLogFunction defaultLogFunction = RCTGetLogFunction(); + RCTSetLogFunction(^(__unused RCTLogLevel level, __unused RCTLogSource source, __unused NSString *fileName, __unused NSNumber *lineNumber, NSString *message) { + errorMessage = message; + }); + + UIColor *value = [RCTConvert UIColor:json]; + + RCTSetLogFunction(defaultLogFunction); + + XCTAssertEqualObjects(value, nil); + XCTAssertTrue([errorMessage containsString:@"labelColor"]); // the RedBox message will contain a list of the valid color names. +} + +- (void)testFallbackColor +{ + id json = RCTJSONParse(@"{ \"semantic\": \"unitTestFallbackColorIOS\" }", nil); + UIColor *value = [RCTConvert UIColor:json]; + XCTAssertTrue(CGColorsAreEqual([value CGColor], [[UIColor blueColor] CGColor])); +} + +- (void)testDynamicColor +{ + // 0 == 0x00000000 == black + // 16777215 == 0x00FFFFFF == white + id json = RCTJSONParse(@"{ \"dynamic\": { \"light\":0, \"dark\":16777215 } }", nil); + UIColor *value = [RCTConvert UIColor:json]; + XCTAssertNotNil(value); + +#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000 + if (@available(iOS 13.0, *)) { + id savedTraitCollection = [UITraitCollection currentTraitCollection]; + + [UITraitCollection setCurrentTraitCollection:[UITraitCollection traitCollectionWithUserInterfaceStyle:UIUserInterfaceStyleLight]]; + CGFloat rgba[4]; + RCTGetRGBAColorComponents([value CGColor], rgba); + XCTAssertEqual(rgba[0], 0); + XCTAssertEqual(rgba[1], 0); + XCTAssertEqual(rgba[2], 0); + XCTAssertEqual(rgba[3], 0); + + [UITraitCollection setCurrentTraitCollection:[UITraitCollection traitCollectionWithUserInterfaceStyle:UIUserInterfaceStyleDark]]; + RCTGetRGBAColorComponents([value CGColor], rgba); + XCTAssertEqual(rgba[0], 1); + XCTAssertEqual(rgba[1], 1); + XCTAssertEqual(rgba[2], 1); + XCTAssertEqual(rgba[3], 0); + + [UITraitCollection setCurrentTraitCollection:savedTraitCollection]; + } +#endif +} + +- (void)testCompositeDynamicColor +{ + id json = RCTJSONParse(@"{ \"dynamic\": { \"light\": { \"semantic\": \"systemRedColor\" }, \"dark\":{ \"semantic\": \"systemBlueColor\" } } }", nil); + UIColor *value = [RCTConvert UIColor:json]; + XCTAssertNotNil(value); + +#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000 + if (@available(iOS 13.0, *)) { + id savedTraitCollection = [UITraitCollection currentTraitCollection]; + + [UITraitCollection setCurrentTraitCollection:[UITraitCollection traitCollectionWithUserInterfaceStyle:UIUserInterfaceStyleLight]]; + + XCTAssertTrue(CGColorsAreEqual([value CGColor], [[UIColor systemRedColor] CGColor])); + + [UITraitCollection setCurrentTraitCollection:[UITraitCollection traitCollectionWithUserInterfaceStyle:UIUserInterfaceStyleDark]]; + + XCTAssertTrue(CGColorsAreEqual([value CGColor], [[UIColor systemBlueColor] CGColor])); + + [UITraitCollection setCurrentTraitCollection:savedTraitCollection]; + } +#endif +} + +- (void)testGenerateFallbacks +{ + NSDictionary* semanticColors = @{ + // https://developer.apple.com/documentation/uikit/uicolor/ui_element_colors + // Label Colors + @"labelColor": @(0xFF000000), + @"secondaryLabelColor": @(0x993c3c43), + @"tertiaryLabelColor": @(0x4c3c3c43), + @"quaternaryLabelColor": @(0x2d3c3c43), + // Fill Colors + @"systemFillColor": @(0x33787880), + @"secondarySystemFillColor": @(0x28787880), + @"tertiarySystemFillColor": @(0x1e767680), + @"quaternarySystemFillColor": @(0x14747480), + // Text Colors + @"placeholderTextColor": @(0x4c3c3c43), + // Standard Content Background Colors + @"systemBackgroundColor": @(0xFFffffff), + @"secondarySystemBackgroundColor": @(0xFFf2f2f7), + @"tertiarySystemBackgroundColor": @(0xFFffffff), + // Grouped Content Background Colors + @"systemGroupedBackgroundColor": @(0xFFf2f2f7), + @"secondarySystemGroupedBackgroundColor": @(0xFFffffff), + @"tertiarySystemGroupedBackgroundColor": @(0xFFf2f2f7), + // Separator Colors + @"separatorColor": @(0x493c3c43), + @"opaqueSeparatorColor": @(0xFFc6c6c8), + // Link Color + @"linkColor": @(0xFF007aff), + // https://developer.apple.com/documentation/uikit/uicolor/standard_colors + // Adaptable Colors + @"systemBrownColor": @(0xFFa2845e), + @"systemIndigoColor": @(0xFF5856d6), + // Adaptable Gray Colors + @"systemGray2Color": @(0xFFaeaeb2), + @"systemGray3Color": @(0xFFc7c7cc), + @"systemGray4Color": @(0xFFd1d1d6), + @"systemGray5Color": @(0xFFe5e5ea), + @"systemGray6Color": @(0xFFf2f2f7), + }; + +#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000 + id savedTraitCollection = nil; + if (@available(iOS 13.0, *)) { + savedTraitCollection = [UITraitCollection currentTraitCollection]; + + [UITraitCollection setCurrentTraitCollection:[UITraitCollection traitCollectionWithUserInterfaceStyle:UIUserInterfaceStyleLight]]; + } +#endif + + for (NSString *semanticColor in semanticColors) { + id json = RCTJSONParse([NSString stringWithFormat:@"{ \"semantic\": \"%@\" }", semanticColor], nil); + UIColor *value = [RCTConvert UIColor:json]; + XCTAssertNotNil(value); + + NSNumber *fallback = [semanticColors objectForKey:semanticColor]; + NSUInteger rgbValue = [fallback unsignedIntegerValue]; + NSUInteger alpha1 = ((rgbValue & 0xFF000000) >> 24); + NSUInteger red1 = ((rgbValue & 0x00FF0000) >> 16); + NSUInteger green1 = ((rgbValue & 0x0000FF00) >> 8); + NSUInteger blue1 = ((rgbValue & 0x000000FF) >> 0); + + CGFloat rgba[4]; + RCTGetRGBAColorComponents([value CGColor], rgba); + NSUInteger red2 = rgba[0] * 255; + NSUInteger green2 = rgba[1] * 255; + NSUInteger blue2 = rgba[2] * 255; + NSUInteger alpha2 = rgba[3] * 255; + + XCTAssertEqual(red1, red2); + XCTAssertEqual(green1, green2); + XCTAssertEqual(blue1, blue2); + XCTAssertEqual(alpha1, alpha2); + } + +#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000 + if (@available(iOS 13.0, *)) { + [UITraitCollection setCurrentTraitCollection:savedTraitCollection]; + } +#endif +} + +@end diff --git a/RNTester/js/components/RNTesterTheme.js b/RNTester/js/components/RNTesterTheme.js index b78a8344fe..7bc14ea3eb 100644 --- a/RNTester/js/components/RNTesterTheme.js +++ b/RNTester/js/components/RNTesterTheme.js @@ -12,28 +12,29 @@ import * as React from 'react'; import {Appearance} from 'react-native'; +import type {ColorValue} from '../../../Libraries/StyleSheet/StyleSheetTypes'; export type RNTesterTheme = { - LabelColor: string, - SecondaryLabelColor: string, - TertiaryLabelColor: string, - QuaternaryLabelColor: string, - PlaceholderTextColor: string, - SystemBackgroundColor: string, - SecondarySystemBackgroundColor: string, - TertiarySystemBackgroundColor: string, - GroupedBackgroundColor: string, - SecondaryGroupedBackgroundColor: string, - TertiaryGroupedBackgroundColor: string, - SystemFillColor: string, - SecondarySystemFillColor: string, - TertiarySystemFillColor: string, - QuaternarySystemFillColor: string, - SeparatorColor: string, - OpaqueSeparatorColor: string, - LinkColor: string, - SystemPurpleColor: string, - ToolbarColor: string, + LabelColor: ColorValue, + SecondaryLabelColor: ColorValue, + TertiaryLabelColor: ColorValue, + QuaternaryLabelColor: ColorValue, + PlaceholderTextColor: ColorValue, + SystemBackgroundColor: ColorValue, + SecondarySystemBackgroundColor: ColorValue, + TertiarySystemBackgroundColor: ColorValue, + GroupedBackgroundColor: ColorValue, + SecondaryGroupedBackgroundColor: ColorValue, + TertiaryGroupedBackgroundColor: ColorValue, + SystemFillColor: ColorValue, + SecondarySystemFillColor: ColorValue, + TertiarySystemFillColor: ColorValue, + QuaternarySystemFillColor: ColorValue, + SeparatorColor: ColorValue, + OpaqueSeparatorColor: ColorValue, + LinkColor: ColorValue, + SystemPurpleColor: ColorValue, + ToolbarColor: ColorValue, ... }; diff --git a/RNTester/js/examples/Appearance/AppearanceExample.js b/RNTester/js/examples/Appearance/AppearanceExample.js index ab99e9213b..bc2d074b2b 100644 --- a/RNTester/js/examples/Appearance/AppearanceExample.js +++ b/RNTester/js/examples/Appearance/AppearanceExample.js @@ -191,7 +191,9 @@ exports.examples = [ paddingVertical: 2, color: theme.LabelColor, }}> - {theme[key]} + {typeof theme[key] === 'string' + ? theme[key] + : JSON.stringify(theme[key])} diff --git a/RNTester/js/examples/PlatformColor/PlatformColorExample.js b/RNTester/js/examples/PlatformColor/PlatformColorExample.js new file mode 100644 index 0000000000..8417ff6d2d --- /dev/null +++ b/RNTester/js/examples/PlatformColor/PlatformColorExample.js @@ -0,0 +1,355 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow + */ + +'use strict'; + +const React = require('react'); +const ReactNative = require('react-native'); +import Platform from '../../../../Libraries/Utilities/Platform'; +const { + ColorAndroid, + DynamicColorIOS, + PlatformColor, + StyleSheet, + Text, + View, +} = ReactNative; + +function PlatformColorsExample() { + function createTable() { + let colors = []; + if (Platform.OS === 'ios') { + colors = [ + // https://developer.apple.com/documentation/uikit/uicolor/ui_element_colors + // Label Colors + {label: 'labelColor', color: PlatformColor('labelColor')}, + { + label: 'secondaryLabelColor', + color: PlatformColor('secondaryLabelColor'), + }, + { + label: 'tertiaryLabelColor', + color: PlatformColor('tertiaryLabelColor'), + }, + { + label: 'quaternaryLabelColor', + color: PlatformColor('quaternaryLabelColor'), + }, + // Fill Colors + {label: 'systemFillColor', color: PlatformColor('systemFillColor')}, + { + label: 'secondarySystemFillColor', + color: PlatformColor('secondarySystemFillColor'), + }, + { + label: 'tertiarySystemFillColor', + color: PlatformColor('tertiarySystemFillColor'), + }, + { + label: 'quaternarySystemFillColor', + color: PlatformColor('quaternarySystemFillColor'), + }, + // Text Colors + { + label: 'placeholderTextColor', + color: PlatformColor('placeholderTextColor'), + }, + // Standard Content Background Colors + { + label: 'systemBackgroundColor', + color: PlatformColor('systemBackgroundColor'), + }, + { + label: 'secondarySystemBackgroundColor', + color: PlatformColor('secondarySystemBackgroundColor'), + }, + { + label: 'tertiarySystemBackgroundColor', + color: PlatformColor('tertiarySystemBackgroundColor'), + }, + // Grouped Content Background Colors + { + label: 'systemGroupedBackgroundColor', + color: PlatformColor('systemGroupedBackgroundColor'), + }, + { + label: 'secondarySystemGroupedBackgroundColor', + color: PlatformColor('secondarySystemGroupedBackgroundColor'), + }, + { + label: 'tertiarySystemGroupedBackgroundColor', + color: PlatformColor('tertiarySystemGroupedBackgroundColor'), + }, + // Separator Colors + {label: 'separatorColor', color: PlatformColor('separatorColor')}, + { + label: 'opaqueSeparatorColor', + color: PlatformColor('opaqueSeparatorColor'), + }, + // Link Color + {label: 'linkColor', color: PlatformColor('linkColor')}, + // Nonadaptable Colors + {label: 'darkTextColor', color: PlatformColor('darkTextColor')}, + {label: 'lightTextColor', color: PlatformColor('lightTextColor')}, + // https://developer.apple.com/documentation/uikit/uicolor/standard_colors + // Adaptable Colors + {label: 'systemBlueColor', color: PlatformColor('systemBlueColor')}, + {label: 'systemBrownColor', color: PlatformColor('systemBrownColor')}, + {label: 'systemGreenColor', color: PlatformColor('systemGreenColor')}, + {label: 'systemIndigoColor', color: PlatformColor('systemIndigoColor')}, + {label: 'systemOrangeColor', color: PlatformColor('systemOrangeColor')}, + {label: 'systemPinkColor', color: PlatformColor('systemPinkColor')}, + {label: 'systemPurpleColor', color: PlatformColor('systemPurpleColor')}, + {label: 'systemRedColor', color: PlatformColor('systemRedColor')}, + {label: 'systemTealColor', color: PlatformColor('systemTealColor')}, + {label: 'systemYellowColor', color: PlatformColor('systemYellowColor')}, + // Adaptable Gray Colors + {label: 'systemGrayColor', color: PlatformColor('systemGrayColor')}, + {label: 'systemGray2Color', color: PlatformColor('systemGray2Color')}, + {label: 'systemGray3Color', color: PlatformColor('systemGray3Color')}, + {label: 'systemGray4Color', color: PlatformColor('systemGray4Color')}, + {label: 'systemGray5Color', color: PlatformColor('systemGray5Color')}, + {label: 'systemGray6Color', color: PlatformColor('systemGray6Color')}, + ]; + } else if (Platform.OS === 'android') { + colors = [ + {label: '?attr/colorAccent', color: PlatformColor('?attr/colorAccent')}, + { + label: '?attr/colorBackgroundFloating', + color: PlatformColor('?attr/colorBackgroundFloating'), + }, + { + label: '?attr/colorButtonNormal', + color: PlatformColor('?attr/colorButtonNormal'), + }, + { + label: '?attr/colorControlActivated', + color: PlatformColor('?attr/colorControlActivated'), + }, + { + label: '?attr/colorControlHighlight', + color: PlatformColor('?attr/colorControlHighlight'), + }, + { + label: '?attr/colorControlNormal', + color: PlatformColor('?attr/colorControlNormal'), + }, + { + label: '?android:colorError', + color: PlatformColor('?android:colorError'), + }, + { + label: '?android:attr/colorError', + color: PlatformColor('?android:attr/colorError'), + }, + { + label: '?attr/colorPrimary', + color: PlatformColor('?attr/colorPrimary'), + }, + {label: '?colorPrimaryDark', color: PlatformColor('?colorPrimaryDark')}, + { + label: '@android:color/holo_purple', + color: PlatformColor('@android:color/holo_purple'), + }, + { + label: '@android:color/holo_green_light', + color: PlatformColor('@android:color/holo_green_light'), + }, + { + label: '@color/catalyst_redbox_background', + color: PlatformColor('@color/catalyst_redbox_background'), + }, + { + label: '@color/catalyst_logbox_background', + color: PlatformColor('@color/catalyst_logbox_background'), + }, + ]; + } + + let table = []; + for (let color of colors) { + table.push( + + {color.label} + + , + ); + } + return table; + } + + return {createTable()}; +} + +function FallbackColorsExample() { + let color = {}; + if (Platform.OS === 'ios') { + color = { + label: "PlatformColor('bogus', 'systemGreenColor')", + color: PlatformColor('bogus', 'systemGreenColor'), + }; + } else if (Platform.OS === 'android') { + color = { + label: "PlatformColor('bogus', '@color/catalyst_redbox_background')", + color: PlatformColor('bogus', '@color/catalyst_redbox_background'), + }; + } else { + throw 'Unexpected Platform.OS: ' + Platform.OS; + } + + return ( + + + {color.label} + + + + ); +} + +function DynamicColorsExample() { + return Platform.OS === 'ios' ? ( + + + + DynamicColorIOS({'{\n'} + {' '}light: 'red', dark: 'blue'{'\n'} + {'}'}) + + + + + + DynamicColorIOS({'{\n'} + {' '}light: PlatformColor('systemBlueColor'),{'\n'} + {' '}dark: PlatformColor('systemRedColor'),{'\n'} + {'}'}) + + + + + ) : ( + Not applicable on this platform + ); +} + +function AndroidColorsExample() { + return Platform.OS === 'android' ? ( + + + ColorAndroid('?attr/colorAccent') + + + + ) : ( + Not applicable on this platform + ); +} + +function VariantColorsExample() { + return ( + + + + {Platform.OS === 'ios' + ? "DynamicColorIOS({light: 'red', dark: 'blue'})" + : "ColorAndroid('?attr/colorAccent')"} + + + + + ); +} + +const styles = StyleSheet.create({ + column: {flex: 1, flexDirection: 'column'}, + row: {flex: 0.75, flexDirection: 'row'}, + labelCell: { + flex: 1, + alignItems: 'stretch', + ...Platform.select({ + ios: {color: PlatformColor('labelColor')}, + default: {color: 'black'}, + }), + }, + colorCell: {flex: 0.25, alignItems: 'stretch'}, +}); + +exports.title = 'PlatformColor'; +exports.description = + 'Examples that show how PlatformColors may be used in an app.'; +exports.examples = [ + { + title: 'Platform Colors', + render(): React.Element { + return ; + }, + }, + { + title: 'Fallback Colors', + render(): React.Element { + return ; + }, + }, + { + title: 'iOS Dynamic Colors', + render(): React.Element { + return ; + }, + }, + { + title: 'Android Colors', + render(): React.Element { + return ; + }, + }, + { + title: 'Variant Colors', + render(): React.Element { + return ; + }, + }, +]; diff --git a/RNTester/js/utils/RNTesterList.android.js b/RNTester/js/utils/RNTesterList.android.js index d4300da1d8..1de9d42a00 100644 --- a/RNTester/js/utils/RNTesterList.android.js +++ b/RNTester/js/utils/RNTesterList.android.js @@ -200,6 +200,10 @@ const APIExamples: Array = [ key: 'PermissionsExampleAndroid', module: require('../examples/PermissionsAndroid/PermissionsExample'), }, + { + key: 'PlatformColorExample', + module: require('../examples/PlatformColor/PlatformColorExample'), + }, { key: 'PointerEventsExample', module: require('../examples/PointerEvents/PointerEventsExample'), diff --git a/RNTester/js/utils/RNTesterList.ios.js b/RNTester/js/utils/RNTesterList.ios.js index 31c9751754..a3e6e4f71d 100644 --- a/RNTester/js/utils/RNTesterList.ios.js +++ b/RNTester/js/utils/RNTesterList.ios.js @@ -279,6 +279,11 @@ const APIExamples: Array = [ module: require('../examples/PanResponder/PanResponderExample'), supportsTVOS: false, }, + { + key: 'PlatformColorExample', + module: require('../examples/PlatformColor/PlatformColorExample'), + supportsTVOS: true, + }, { key: 'PointerEventsExample', module: require('../examples/PointerEvents/PointerEventsExample'), diff --git a/React/Base/RCTConvert.m b/React/Base/RCTConvert.m index e8b7194af2..9e0791cb8f 100644 --- a/React/Base/RCTConvert.m +++ b/React/Base/RCTConvert.m @@ -506,6 +506,207 @@ RCT_CGSTRUCT_CONVERTER(CGAffineTransform, (@[ @"a", @"b", @"c", @"d", @"tx", @"ty" ])) +static NSString *const RCTFallback = @"fallback"; +static NSString *const RCTFallbackARGB = @"fallback-argb"; +static NSString *const RCTSelector = @"selector"; +static NSString *const RCTIndex = @"index"; + +/** The following dictionary defines the react-native semantic colors for ios. + * If the value for a given name is empty then the name itself + * is used as the UIColor selector. + * If the RCTSelector key is present then that value is used for a selector instead + * of the key name. + * If the given selector is not available on the running OS version then + * the RCTFallback selector is used instead. + * If the RCTIndex key is present then object returned from UIColor is an + * NSArray and the object at index RCTIndex is to be used. + */ +static NSDictionary* RCTSemanticColorsMap() +{ + static NSDictionary *colorMap = nil; + if (colorMap == nil) { + colorMap = @{ + // https://developer.apple.com/documentation/uikit/uicolor/ui_element_colors + // Label Colors + @"labelColor": @{ // iOS 13.0 + RCTFallbackARGB: @(0xFF000000) // fallback for iOS<=12: RGBA returned by this semantic color in light mode on iOS 13 + }, + @"secondaryLabelColor": @{ // iOS 13.0 + RCTFallbackARGB: @(0x993c3c43) + }, + @"tertiaryLabelColor": @{ // iOS 13.0 + RCTFallbackARGB: @(0x4c3c3c43) + }, + @"quaternaryLabelColor": @{ // iOS 13.0 + RCTFallbackARGB: @(0x2d3c3c43) + }, + // Fill Colors + @"systemFillColor": @{ // iOS 13.0 + RCTFallbackARGB: @(0x33787880) + }, + @"secondarySystemFillColor": @{ // iOS 13.0 + RCTFallbackARGB: @(0x28787880) + }, + @"tertiarySystemFillColor": @{ // iOS 13.0 + RCTFallbackARGB: @(0x1e767680) + }, + @"quaternarySystemFillColor": @{ // iOS 13.0 + RCTFallbackARGB: @(0x14747480) + }, + // Text Colors + @"placeholderTextColor": @{ // iOS 13.0 + RCTFallbackARGB: @(0x4c3c3c43) + }, + // Standard Content Background Colors + @"systemBackgroundColor": @{ // iOS 13.0 + RCTFallbackARGB: @(0xFFffffff) + }, + @"secondarySystemBackgroundColor": @{ // iOS 13.0 + RCTFallbackARGB: @(0xFFf2f2f7) + }, + @"tertiarySystemBackgroundColor": @{ // iOS 13.0 + RCTFallbackARGB: @(0xFFffffff) + }, + // Grouped Content Background Colors + @"systemGroupedBackgroundColor": @{ // iOS 13.0 + RCTFallbackARGB: @(0xFFf2f2f7) + }, + @"secondarySystemGroupedBackgroundColor": @{ // iOS 13.0 + RCTFallbackARGB: @(0xFFffffff) + }, + @"tertiarySystemGroupedBackgroundColor": @{ // iOS 13.0 + RCTFallbackARGB: @(0xFFf2f2f7) + }, + // Separator Colors + @"separatorColor": @{ // iOS 13.0 + RCTFallbackARGB: @(0x493c3c43) + }, + @"opaqueSeparatorColor": @{ // iOS 13.0 + RCTFallbackARGB: @(0xFFc6c6c8) + }, + // Link Color + @"linkColor": @{ // iOS 13.0 + RCTFallbackARGB: @(0xFF007aff) + }, + // Nonadaptable Colors + @"darkTextColor": @{}, + @"lightTextColor": @{}, + // https://developer.apple.com/documentation/uikit/uicolor/standard_colors + // Adaptable Colors + @"systemBlueColor": @{}, + @"systemBrownColor": @{ // iOS 13.0 + RCTFallbackARGB: @(0xFFa2845e) + }, + @"systemGreenColor": @{}, + @"systemIndigoColor": @{ // iOS 13.0 + RCTFallbackARGB: @(0xFF5856d6) + }, + @"systemOrangeColor": @{}, + @"systemPinkColor": @{}, + @"systemPurpleColor": @{}, + @"systemRedColor": @{}, + @"systemTealColor": @{}, + @"systemYellowColor": @{}, + // Adaptable Gray Colors + @"systemGrayColor": @{}, + @"systemGray2Color": @{ // iOS 13.0 + RCTFallbackARGB: @(0xFFaeaeb2) + }, + @"systemGray3Color": @{ // iOS 13.0 + RCTFallbackARGB: @(0xFFc7c7cc) + }, + @"systemGray4Color": @{ // iOS 13.0 + RCTFallbackARGB: @(0xFFd1d1d6) + }, + @"systemGray5Color": @{ // iOS 13.0 + RCTFallbackARGB: @(0xFFe5e5ea) + }, + @"systemGray6Color": @{ // iOS 13.0 + RCTFallbackARGB: @(0xFFf2f2f7) + }, +#if DEBUG + // The follow exist for Unit Tests + @"unitTestFallbackColor": @{ + RCTFallback: @"gridColor" + }, + @"unitTestFallbackColorIOS": @{ + RCTFallback: @"blueColor" + }, + @"unitTestFallbackColorEven": @{ + RCTSelector: @"unitTestFallbackColorEven", + RCTIndex: @0, + RCTFallback: @"controlAlternatingRowBackgroundColors" + }, + @"unitTestFallbackColorOdd": @{ + RCTSelector: @"unitTestFallbackColorOdd", + RCTIndex: @1, + RCTFallback: @"controlAlternatingRowBackgroundColors" + }, +#endif + }; + } + return colorMap; +} + +/** Returns a UIColor based on a semantic color name. + * Returns nil if the semantic color name is invalid. + */ +static UIColor *RCTColorFromSemanticColorName(NSString *semanticColorName) +{ + NSDictionary *colorMap = RCTSemanticColorsMap(); + UIColor *color = nil; + NSDictionary *colorInfo = colorMap[semanticColorName]; + if (colorInfo) { + NSString *semanticColorSelector = colorInfo[RCTSelector]; + if (semanticColorSelector == nil) { + semanticColorSelector = semanticColorName; + } + SEL selector = NSSelectorFromString(semanticColorSelector); + if (![UIColor respondsToSelector:selector]) { + NSNumber *fallbackRGB = colorInfo[RCTFallbackARGB]; + if (fallbackRGB != nil) { + RCTAssert([fallbackRGB isKindOfClass:[NSNumber class]], @"fallback ARGB is not a number"); + return [RCTConvert UIColor:fallbackRGB]; + } + semanticColorSelector = colorInfo[RCTFallback]; + selector = NSSelectorFromString(semanticColorSelector); + } + RCTAssert ([UIColor respondsToSelector:selector], @"RCTUIColor does not respond to a semantic color selector."); + Class klass = [UIColor class]; + IMP imp = [klass methodForSelector:selector]; + id (*getSemanticColorObject)(id, SEL) = (void *)imp; + id colorObject = getSemanticColorObject(klass, selector); + if ([colorObject isKindOfClass:[UIColor class]]) { + color = colorObject; + } else if ([colorObject isKindOfClass:[NSArray class]]) { + NSArray *colors = colorObject; + NSNumber *index = colorInfo[RCTIndex]; + RCTAssert(index, @"index should not be null"); + color = colors[[index unsignedIntegerValue]]; + } else { + RCTAssert(false, @"selector return an unknown object type"); + } + } + return color; +} + +/** Returns an alphabetically sorted comma seperated list of the valid semantic color names + */ +static NSString *RCTSemanticColorNames() +{ + NSMutableString *names = [[NSMutableString alloc] init]; + NSDictionary *colorMap = RCTSemanticColorsMap(); + NSArray *allKeys = [[[colorMap allKeys] mutableCopy] sortedArrayUsingSelector:@selector(localizedCaseInsensitiveCompare:)]; + + for(id key in allKeys) { + if ([names length]) { + [names appendString:@", "]; + } + [names appendString:key]; + } + return names; +} + + (UIColor *)UIColor:(id)json { if (!json) { @@ -525,6 +726,56 @@ RCT_CGSTRUCT_CONVERTER(CGAffineTransform, (@[ CGFloat g = ((argb >> 8) & 0xFF) / 255.0; CGFloat b = (argb & 0xFF) / 255.0; return [UIColor colorWithRed:r green:g blue:b alpha:a]; + } else if ([json isKindOfClass:[NSDictionary class]]) { + NSDictionary *dictionary = json; + id value = nil; + if ((value = [dictionary objectForKey:@"semantic"])) { + if ([value isKindOfClass:[NSString class]]) { + NSString *semanticName = value; + UIColor *color = RCTColorFromSemanticColorName(semanticName); + if (color == nil) { + RCTLogConvertError(json, [@"a UIColor. Expected one of the following values: " stringByAppendingString:RCTSemanticColorNames()]); + } + return color; + } else if ([value isKindOfClass:[NSArray class]]) { + for (id name in value) { + UIColor *color = RCTColorFromSemanticColorName(name); + if (color != nil) { + return color; + } + } + RCTLogConvertError(json, [@"a UIColor. None of the names in the array were one of the following values: " stringByAppendingString:RCTSemanticColorNames()]); + return nil; + } + RCTLogConvertError(json, @"a UIColor. Expected either a single name or an array of names but got something else."); + return nil; + } else if ((value = [dictionary objectForKey:@"dynamic"])) { + NSDictionary *appearances = value; + id light = [appearances objectForKey:@"light"]; + UIColor *lightColor = [RCTConvert UIColor:light]; + id dark = [appearances objectForKey:@"dark"]; + UIColor *darkColor = [RCTConvert UIColor:dark]; + if (lightColor != nil && darkColor != nil) { +#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000 + if (@available(iOS 13.0, *)) { + UIColor *color = [UIColor colorWithDynamicProvider:^UIColor * _Nonnull(UITraitCollection * _Nonnull collection) { + return collection.userInterfaceStyle == UIUserInterfaceStyleDark ? darkColor : lightColor; + }]; + return color; + } else { +#endif + return lightColor; +#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000 + } +#endif + } else { + RCTLogConvertError(json, @"a UIColor. Expected an iOS dynamic appearance aware color."); + return nil; + } + } else { + RCTLogConvertError(json, @"a UIColor. Expected an iOS semantic color or dynamic appearance aware color."); + return nil; + } } else { RCTLogConvertError(json, @"a UIColor. Did you forget to call processColor() on the JS side?"); return nil; diff --git a/React/Base/RCTUtils.h b/React/Base/RCTUtils.h index c9a832d561..9159d3d820 100644 --- a/React/Base/RCTUtils.h +++ b/React/Base/RCTUtils.h @@ -141,6 +141,9 @@ RCT_EXTERN UIImage *__nullable RCTImageFromLocalBundleAssetURL(NSURL *imageURL); // Creates a new, unique temporary file path with the specified extension RCT_EXTERN NSString *__nullable RCTTempFilePath(NSString *__nullable extension, NSError **error); +// Get RGBA components of CGColor +RCT_EXTERN void RCTGetRGBAColorComponents(CGColorRef color, CGFloat rgba[_Nonnull 4]); + // Converts a CGColor to a hex string RCT_EXTERN NSString *RCTColorToHexString(CGColorRef color); diff --git a/React/Base/RCTUtils.m b/React/Base/RCTUtils.m index 7a5ef4e172..c5fd2d8e1c 100644 --- a/React/Base/RCTUtils.m +++ b/React/Base/RCTUtils.m @@ -832,7 +832,7 @@ RCT_EXTERN NSString *__nullable RCTTempFilePath(NSString *extension, NSError **e return [directory stringByAppendingPathComponent:filename]; } -static void RCTGetRGBAColorComponents(CGColorRef color, CGFloat rgba[4]) +RCT_EXTERN void RCTGetRGBAColorComponents(CGColorRef color, CGFloat rgba[4]) { CGColorSpaceModel model = CGColorSpaceGetModel(CGColorGetColorSpace(color)); const CGFloat *components = CGColorGetComponents(color); diff --git a/React/Views/RCTView.m b/React/Views/RCTView.m index 6936549fa6..a4f992712d 100644 --- a/React/Views/RCTView.m +++ b/React/Views/RCTView.m @@ -608,6 +608,17 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:unused) } } +- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { + [super traitCollectionDidChange: previousTraitCollection]; +#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000 + if (@available(iOS 13.0, *)) { + if ([self.traitCollection hasDifferentColorAppearanceComparedToTraitCollection:previousTraitCollection]) { + [self.layer setNeedsDisplay]; + } + } +#endif +} + #pragma mark - Borders - (UIColor *)backgroundColor @@ -783,11 +794,22 @@ static CGFloat RCTDefaultIfNegativeTo(CGFloat defaultValue, CGFloat x) { // solve this, we'll need to add a container view inside the main view to // correctly clip the subviews. + CGColorRef backgroundColor; +#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000 + if (@available(iOS 13.0, *)) { + backgroundColor = [_backgroundColor resolvedColorWithTraitCollection:self.traitCollection].CGColor; + } else { + backgroundColor = _backgroundColor.CGColor; + } +#else + backgroundColor = _backgroundColor.CGColor; +#endif + if (useIOSBorderRendering) { layer.cornerRadius = cornerRadii.topLeft; layer.borderColor = borderColors.left; layer.borderWidth = borderInsets.left; - layer.backgroundColor = _backgroundColor.CGColor; + layer.backgroundColor = backgroundColor; layer.contents = nil; layer.needsDisplayOnBoundsChange = NO; layer.mask = nil; @@ -799,7 +821,7 @@ static CGFloat RCTDefaultIfNegativeTo(CGFloat defaultValue, CGFloat x) { cornerRadii, borderInsets, borderColors, - _backgroundColor.CGColor, + backgroundColor, self.clipsToBounds); layer.backgroundColor = NULL; diff --git a/ReactAndroid/build.gradle b/ReactAndroid/build.gradle index 78a0a0d90f..595bfafb74 100644 --- a/ReactAndroid/build.gradle +++ b/ReactAndroid/build.gradle @@ -441,6 +441,7 @@ dependencies { api("com.facebook.yoga:proguard-annotations:1.14.1") api("javax.inject:javax.inject:1") api("androidx.appcompat:appcompat:1.0.2") + api("androidx.swiperefreshlayout:swiperefreshlayout:1.0.0") api("com.facebook.fresco:fresco:${FRESCO_VERSION}") api("com.facebook.fresco:imagepipeline-okhttp3:${FRESCO_VERSION}") api("com.facebook.soloader:soloader:${SO_LOADER_VERSION}") diff --git a/ReactAndroid/src/main/java/com/facebook/react/bridge/ColorPropConverter.java b/ReactAndroid/src/main/java/com/facebook/react/bridge/ColorPropConverter.java new file mode 100644 index 0000000000..5ac8ddceb6 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/bridge/ColorPropConverter.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.bridge; + +import android.content.Context; +import android.content.res.Resources; +import android.util.TypedValue; +import androidx.core.content.res.ResourcesCompat; + +public class ColorPropConverter { + private static final String JSON_KEY = "resource_paths"; + private static final String PREFIX_RESOURCE = "@"; + private static final String PREFIX_ATTR = "?"; + private static final String PACKAGE_DELIMITER = ":"; + private static final String PATH_DELIMITER = "/"; + private static final String ATTR = "attr"; + private static final String ATTR_SEGMENT = "attr/"; + + public static Integer getColor(Object value, Context context) { + if (value == null) { + return null; + } + + if (value instanceof Double) { + return ((Double) value).intValue(); + } + + if (context == null) { + throw new RuntimeException("Context may not be null."); + } + + if (value instanceof ReadableMap) { + ReadableMap map = (ReadableMap) value; + ReadableArray resourcePaths = map.getArray(JSON_KEY); + + if (resourcePaths == null) { + throw new JSApplicationCausedNativeException( + "ColorValue: The `" + JSON_KEY + "` must be an array of color resource path strings."); + } + + for (int i = 0; i < resourcePaths.size(); i++) { + String resourcePath = resourcePaths.getString(i); + + if (resourcePath == null || resourcePath.isEmpty()) { + continue; + } + + boolean isResource = resourcePath.startsWith(PREFIX_RESOURCE); + boolean isThemeAttribute = resourcePath.startsWith(PREFIX_ATTR); + + resourcePath = resourcePath.substring(1); + + try { + if (isResource) { + return resolveResource(context, resourcePath); + } else if (isThemeAttribute) { + return resolveThemeAttribute(context, resourcePath); + } + } catch (Resources.NotFoundException exception) { + // The resource could not be found so do nothing to allow the for loop to continue and + // try the next fallback resource in the array. If none of the fallbacks are + // found then the exception immediately after the for loop will be thrown. + } + } + + throw new JSApplicationCausedNativeException( + "ColorValue: None of the paths in the `" + + JSON_KEY + + "` array resolved to a color resource."); + } + + throw new JSApplicationCausedNativeException( + "ColorValue: the value must be a number or Object."); + } + + private static int resolveResource(Context context, String resourcePath) { + String[] pathTokens = resourcePath.split(PACKAGE_DELIMITER); + + String packageName = context.getPackageName(); + String resource = resourcePath; + + if (pathTokens.length > 1) { + packageName = pathTokens[0]; + resource = pathTokens[1]; + } + + String[] resourceTokens = resource.split(PATH_DELIMITER); + String resourceType = resourceTokens[0]; + String resourceName = resourceTokens[1]; + + int resourceId = context.getResources().getIdentifier(resourceName, resourceType, packageName); + + return ResourcesCompat.getColor(context.getResources(), resourceId, context.getTheme()); + } + + private static int resolveThemeAttribute(Context context, String resourcePath) { + String path = resourcePath.replaceAll(ATTR_SEGMENT, ""); + String[] pathTokens = path.split(PACKAGE_DELIMITER); + + String packageName = context.getPackageName(); + String resourceName = path; + + if (pathTokens.length > 1) { + packageName = pathTokens[0]; + resourceName = pathTokens[1]; + } + + int resourceId = context.getResources().getIdentifier(resourceName, ATTR, packageName); + + TypedValue outValue = new TypedValue(); + Resources.Theme theme = context.getTheme(); + + if (theme.resolveAttribute(resourceId, outValue, true)) { + return outValue.data; + } + + throw new Resources.NotFoundException(); + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerDelegate.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerDelegate.java index d89882c480..b1c531f8f6 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerDelegate.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerDelegate.java @@ -9,6 +9,7 @@ package com.facebook.react.uimanager; import android.view.View; import androidx.annotation.Nullable; +import com.facebook.react.bridge.ColorPropConverter; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; import com.facebook.yoga.YogaConstants; @@ -47,7 +48,8 @@ public abstract class BaseViewManagerDelegate( uiViewClassName: string, ) => HostComponent { diff --git a/packages/react-native-codegen/src/parsers/flow/components/__test_fixtures__/fixtures.js b/packages/react-native-codegen/src/parsers/flow/components/__test_fixtures__/fixtures.js index d4a9f2efbe..5a6e24d43d 100644 --- a/packages/react-native-codegen/src/parsers/flow/components/__test_fixtures__/fixtures.js +++ b/packages/react-native-codegen/src/parsers/flow/components/__test_fixtures__/fixtures.js @@ -275,6 +275,12 @@ type ModuleProps = $ReadOnly<{| color_array_optional_value: ?ColorArrayValue, color_array_optional_both?: ?ColorArrayValue, + // ProcessedColorValue props + processed_color_required: ProcessedColorValue, + processed_color_optional_key?: ProcessedColorValue, + processed_color_optional_value: ?ProcessedColorValue, + processed_color_optional_both?: ?ProcessedColorValue, + // PointValue props point_required: PointValue, point_optional_key?: PointValue, @@ -310,7 +316,7 @@ const codegenNativeComponent = require('codegenNativeComponent'); import type {Int32, Double, Float, WithDefault} from 'CodegenTypes'; import type {ImageSource} from 'ImageSource'; -import type {ColorValue, PointValue, EdgeInsetsValue} from 'StyleSheetTypes'; +import type {ColorValue, PointValue, ProcessColorValue, EdgeInsetsValue} from 'StyleSheetTypes'; import type {ViewProps} from 'ViewPropTypes'; import type {HostComponent} from 'react-native'; @@ -508,6 +514,12 @@ type ModuleProps = $ReadOnly<{| color_optional_value: $ReadOnly<{|prop: ?ColorValue|}>, color_optional_both: $ReadOnly<{|prop?: ?ColorValue|}>, + // ProcessedColorValue props + processed_color_required: $ReadOnly<{|prop: ProcessedColorValue|}>, + processed_color_optional_key: $ReadOnly<{|prop?: ProcessedColorValue|}>, + processed_color_optional_value: $ReadOnly<{|prop: ?ProcessedColorValue|}>, + processed_color_optional_both: $ReadOnly<{|prop?: ?ProcessedColorValue|}>, + // PointValue props point_required: $ReadOnly<{|prop: PointValue|}>, point_optional_key: $ReadOnly<{|prop?: PointValue|}>, diff --git a/packages/react-native-codegen/src/parsers/flow/components/__tests__/__snapshots__/component-parser-test.js.snap b/packages/react-native-codegen/src/parsers/flow/components/__tests__/__snapshots__/component-parser-test.js.snap index 712aca2919..5fdbb373ab 100644 --- a/packages/react-native-codegen/src/parsers/flow/components/__tests__/__snapshots__/component-parser-test.js.snap +++ b/packages/react-native-codegen/src/parsers/flow/components/__tests__/__snapshots__/component-parser-test.js.snap @@ -456,6 +456,38 @@ Object { "type": "ArrayTypeAnnotation", }, }, + Object { + "name": "processed_color_required", + "optional": false, + "typeAnnotation": Object { + "name": "ColorPrimitive", + "type": "NativePrimitiveTypeAnnotation", + }, + }, + Object { + "name": "processed_color_optional_key", + "optional": true, + "typeAnnotation": Object { + "name": "ColorPrimitive", + "type": "NativePrimitiveTypeAnnotation", + }, + }, + Object { + "name": "processed_color_optional_value", + "optional": true, + "typeAnnotation": Object { + "name": "ColorPrimitive", + "type": "NativePrimitiveTypeAnnotation", + }, + }, + Object { + "name": "processed_color_optional_both", + "optional": true, + "typeAnnotation": Object { + "name": "ColorPrimitive", + "type": "NativePrimitiveTypeAnnotation", + }, + }, Object { "name": "point_required", "optional": false, @@ -5480,6 +5512,74 @@ Object { "type": "ObjectTypeAnnotation", }, }, + Object { + "name": "processed_color_required", + "optional": false, + "typeAnnotation": Object { + "properties": Array [ + Object { + "name": "prop", + "optional": false, + "typeAnnotation": Object { + "name": "ColorPrimitive", + "type": "NativePrimitiveTypeAnnotation", + }, + }, + ], + "type": "ObjectTypeAnnotation", + }, + }, + Object { + "name": "processed_color_optional_key", + "optional": false, + "typeAnnotation": Object { + "properties": Array [ + Object { + "name": "prop", + "optional": true, + "typeAnnotation": Object { + "name": "ColorPrimitive", + "type": "NativePrimitiveTypeAnnotation", + }, + }, + ], + "type": "ObjectTypeAnnotation", + }, + }, + Object { + "name": "processed_color_optional_value", + "optional": false, + "typeAnnotation": Object { + "properties": Array [ + Object { + "name": "prop", + "optional": true, + "typeAnnotation": Object { + "name": "ColorPrimitive", + "type": "NativePrimitiveTypeAnnotation", + }, + }, + ], + "type": "ObjectTypeAnnotation", + }, + }, + Object { + "name": "processed_color_optional_both", + "optional": false, + "typeAnnotation": Object { + "properties": Array [ + Object { + "name": "prop", + "optional": true, + "typeAnnotation": Object { + "name": "ColorPrimitive", + "type": "NativePrimitiveTypeAnnotation", + }, + }, + ], + "type": "ObjectTypeAnnotation", + }, + }, Object { "name": "point_required", "optional": false, diff --git a/packages/react-native-codegen/src/parsers/flow/components/props.js b/packages/react-native-codegen/src/parsers/flow/components/props.js index 6531a2c9b4..404289cc8a 100644 --- a/packages/react-native-codegen/src/parsers/flow/components/props.js +++ b/packages/react-native-codegen/src/parsers/flow/components/props.js @@ -94,6 +94,7 @@ function getTypeAnnotationForArray(name, typeAnnotation, defaultValue, types) { name: 'ImageSourcePrimitive', }; case 'ColorValue': + case 'ProcessedColorValue': return { type: 'NativePrimitiveTypeAnnotation', name: 'ColorPrimitive', @@ -217,6 +218,7 @@ function getTypeAnnotation( name: 'ImageSourcePrimitive', }; case 'ColorValue': + case 'ProcessedColorValue': return { type: 'NativePrimitiveTypeAnnotation', name: 'ColorPrimitive',