diff --git a/docsite/api/intro.md b/docsite/api/intro.md index 06c07fd7618..68454cec6d6 100644 --- a/docsite/api/intro.md +++ b/docsite/api/intro.md @@ -7,4 +7,4 @@ slug: / Welcome to the React Native macOS API reference documentation. This section covers macOS-specific props and events that extend the standard React Native components. -Most of the additional functionality out of React Native macOS directly is in the form of additional props and callback events implemented on ``, to provide macOS and desktop specific behavior +Most of the additional functionality out of React Native macOS directly is in the form of additional props and callback events implemented on ``, to provide macOS and desktop specific behavior. We also have some additional APIs, like platform specific colors. diff --git a/docsite/api/platform-color.md b/docsite/api/platform-color.md new file mode 100644 index 00000000000..486db9a4191 --- /dev/null +++ b/docsite/api/platform-color.md @@ -0,0 +1,50 @@ +--- +sidebar_label: 'Platform Colors' +sidebar_position: 2 +--- + +# Platform Colors + +React Native macOS extends the core `PlatformColor` API with helpers that map directly to AppKit system colors. These helpers make it easier to adopt macOS appearance and accessibility behaviors without writing native code. + +## `DynamicColorMacOS` + +`DynamicColorMacOS` creates a color that automatically adapts to light, dark, and high-contrast appearances on macOS. + +:::note +`DynamicColorIOS` works on macOS too, they are essentially equivalent +::: + +| Option | Description | +| -------------------- | --------------------------------------------------------------- | +| `light` | Color used in the standard light appearance. | +| `dark` | Color used in the standard dark appearance. | +| `highContrastLight` | Optional color for high-contrast light mode. Defaults to `light`.| +| `highContrastDark` | Optional color for high-contrast dark mode. Defaults to `dark`. | + +## `ColorWithSystemEffectMacOS` + +`ColorWithSystemEffectMacOS(color, effect)` wraps an existing color so AppKit can apply control state effects such as pressed, disabled, or rollover. + +| Parameter | Description | +| --------- | ----------- | +| `color` | A string produced by `PlatformColor`, `DynamicColorMacOS`, or a CSS color string. | +| `effect` | One of `none`, `pressed`, `deepPressed`, `disabled`, or `rollover`. | + +```javascript +import { + ColorWithSystemEffectMacOS, + DynamicColorMacOS, + PlatformColor, + StyleSheet, +} from 'react-native'; + +const styles = StyleSheet.create({ + buttonPressed: { + backgroundColor: ColorWithSystemEffectMacOS( + PlatformColor('controlColor'), + 'pressed', + ), + }, +}); +``` diff --git a/docsite/sidebarsApi.ts b/docsite/sidebarsApi.ts index aa2ca6fcd88..df07cc406dd 100644 --- a/docsite/sidebarsApi.ts +++ b/docsite/sidebarsApi.ts @@ -3,6 +3,7 @@ import type {SidebarsConfig} from '@docusaurus/plugin-content-docs'; const sidebars: SidebarsConfig = { apiSidebar: [ 'intro', + 'platform-color', 'view-props', 'view-events', ], diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.h b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.h index 8deffedcb1d..bd8720e6670 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.h +++ b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.h @@ -20,9 +20,19 @@ struct DynamicColor { int32_t highContrastDarkColor = 0; }; +#if TARGET_OS_OSX // [macOS +struct ColorWithSystemEffect { + int32_t color = 0; + std::string effect; +}; +#endif // macOS] + struct Color { Color(int32_t color); Color(const DynamicColor& dynamicColor); +#if TARGET_OS_OSX // [macOS + Color(const ColorWithSystemEffect& colorWithSystemEffect); +#endif // macOS] Color(const ColorComponents& components); Color() : uiColor_(nullptr){}; int32_t getColor() const; diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm index 632e1c7e0db..290b18e1128 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm +++ b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm @@ -20,6 +20,33 @@ namespace facebook::react { +#if TARGET_OS_OSX // [macOS +RCTUIColor *_Nullable UIColorFromColorWithSystemEffect( + RCTUIColor *baseColor, + const std::string &systemEffectString) +{ + if (baseColor == nil) { + return nil; + } + + NSColor *colorWithEffect = baseColor; + if (!systemEffectString.empty()) { + if (systemEffectString == "none") { + colorWithEffect = [baseColor colorWithSystemEffect:NSColorSystemEffectNone]; + } else if (systemEffectString == "pressed") { + colorWithEffect = [baseColor colorWithSystemEffect:NSColorSystemEffectPressed]; + } else if (systemEffectString == "deepPressed") { + colorWithEffect = [baseColor colorWithSystemEffect:NSColorSystemEffectDeepPressed]; + } else if (systemEffectString == "disabled") { + colorWithEffect = [baseColor colorWithSystemEffect:NSColorSystemEffectDisabled]; + } else if (systemEffectString == "rollover") { + colorWithEffect = [baseColor colorWithSystemEffect:NSColorSystemEffectRollover]; + } + } + return colorWithEffect; +} +#endif // macOS] + namespace { bool UIColorIsP3ColorSpace(const std::shared_ptr &uiColor) @@ -120,7 +147,20 @@ int32_t ColorFromColorComponents(const facebook::react::ColorComponents &compone int32_t ColorFromUIColor(RCTPlatformColor *color) // [macOS] { CGFloat rgba[4]; +#if !TARGET_OS_OSX // [macOS] [color getRed:&rgba[0] green:&rgba[1] blue:&rgba[2] alpha:&rgba[3]]; +#else // [macOS + // Resolve dynamic/semantic colors against the current effective appearance + // so that dark mode colors are correctly extracted. + [[NSApp effectiveAppearance] performAsCurrentDrawingAppearance:^{ + NSColor *resolvedColor = [color colorUsingColorSpace:[NSColorSpace sRGBColorSpace]]; + if (resolvedColor) { + [resolvedColor getRed:&rgba[0] green:&rgba[1] blue:&rgba[2] alpha:&rgba[3]]; + } else { + [color getRed:&rgba[0] green:&rgba[1] blue:&rgba[2] alpha:&rgba[3]]; + } + }]; +#endif // macOS] return ColorFromColorComponents({(float)rgba[0], (float)rgba[1], (float)rgba[2], (float)rgba[3]}); } @@ -170,9 +210,7 @@ int32_t ColorFromUIColor(const std::shared_ptr &uiColor) return 0; } -#if TARGET_OS_OSX // [macOS] - return ColorFromUIColor(uiColor); -#else // [macOS +#if !TARGET_OS_OSX // [macOS] static UITraitCollection *darkModeTraitCollection = [UITraitCollection traitCollectionWithUserInterfaceStyle:UIUserInterfaceStyleDark]; auto darkColor = ColorFromUIColorForSpecificTraitCollection(uiColor, darkModeTraitCollection); @@ -202,6 +240,32 @@ int32_t ColorFromUIColor(const std::shared_ptr &uiColor) darkAccessibilityContrastColor, lightAccessibilityContrastColor, UIColorIsP3ColorSpace(uiColor)); +#else // [macOS + // Hash both light and dark appearance colors to properly distinguish + // dynamic colors that change with appearance. + RCTPlatformColor *color = (RCTPlatformColor *)unwrapManagedObject(uiColor); + __block int32_t darkColor = 0; + __block int32_t lightColor = 0; + + [[NSAppearance appearanceNamed:NSAppearanceNameDarkAqua] performAsCurrentDrawingAppearance:^{ + NSColor *resolved = [color colorUsingColorSpace:[NSColorSpace sRGBColorSpace]]; + if (resolved) { + CGFloat rgba[4]; + [resolved getRed:&rgba[0] green:&rgba[1] blue:&rgba[2] alpha:&rgba[3]]; + darkColor = ColorFromColorComponents({(float)rgba[0], (float)rgba[1], (float)rgba[2], (float)rgba[3]}); + } + }]; + + [[NSAppearance appearanceNamed:NSAppearanceNameAqua] performAsCurrentDrawingAppearance:^{ + NSColor *resolved = [color colorUsingColorSpace:[NSColorSpace sRGBColorSpace]]; + if (resolved) { + CGFloat rgba[4]; + [resolved getRed:&rgba[0] green:&rgba[1] blue:&rgba[2] alpha:&rgba[3]]; + lightColor = ColorFromColorComponents({(float)rgba[0], (float)rgba[1], (float)rgba[2], (float)rgba[3]}); + } + }]; + + return facebook::react::hash_combine(darkColor, lightColor); #endif // macOS] } @@ -224,6 +288,21 @@ int32_t ColorFromUIColor(const std::shared_ptr &uiColor) 0); } +#if TARGET_OS_OSX // [macOS +Color::Color(const ColorWithSystemEffect &colorWithSystemEffect) +{ + RCTUIColor *baseColor = UIColorFromInt32(colorWithSystemEffect.color); + RCTUIColor *colorWithEffect = + UIColorFromColorWithSystemEffect(baseColor, colorWithSystemEffect.effect); + if (colorWithEffect != nil) { + uiColor_ = wrapManagedObject(colorWithEffect); + } + uiColorHashValue_ = facebook::react::hash_combine( + colorWithSystemEffect.color, + std::hash{}(colorWithSystemEffect.effect)); +} +#endif // macOS] + Color::Color(const ColorComponents &components) { uiColor_ = wrapManagedObject(UIColorFromComponentsColor(components)); diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/PlatformColorParser.mm b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/PlatformColorParser.mm index 7cb04798066..859bc9e291e 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/PlatformColorParser.mm +++ b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/PlatformColorParser.mm @@ -62,6 +62,24 @@ SharedColor parsePlatformColor(const ContextContainer &contextContainer, int32_t items.at("dynamic").hasType>()) { auto dynamicItems = (std::unordered_map)items.at("dynamic"); return RCTPlatformColorComponentsFromDynamicItems(contextContainer, surfaceId, dynamicItems); +#if TARGET_OS_OSX // [macOS + } else if ( + items.find("colorWithSystemEffect") != items.end() && + items.at("colorWithSystemEffect").hasType>()) { + auto colorWithSystemEffectItems = + (std::unordered_map)items.at("colorWithSystemEffect"); + if (colorWithSystemEffectItems.find("baseColor") != colorWithSystemEffectItems.end() && + colorWithSystemEffectItems.find("systemEffect") != colorWithSystemEffectItems.end() && + colorWithSystemEffectItems.at("systemEffect").hasType()) { + SharedColor baseColorShared{}; + fromRawValue(contextContainer, surfaceId, colorWithSystemEffectItems.at("baseColor"), baseColorShared); + if (baseColorShared) { + std::string systemEffect = (std::string)colorWithSystemEffectItems.at("systemEffect"); + auto baseColor = (*baseColorShared).getColor(); + return SharedColor(Color(ColorWithSystemEffect{baseColor, systemEffect})); + } + } +#endif // macOS] } } diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.mm b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.mm index 7eaac5801fd..b9b76daa27f 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.mm @@ -13,17 +13,24 @@ #import #include +#include // [macOS] NS_ASSUME_NONNULL_BEGIN static NSString *const kColorSuffix = @"Color"; static NSString *const kFallbackARGBKey = @"fallback-argb"; +#if TARGET_OS_OSX // [macOS +static NSString *const kFallbackKey = @"fallback"; +static NSString *const kSelectorKey = @"selector"; +static NSString *const kIndexKey = @"index"; +#endif // macOS] static NSDictionary *_PlatformColorSelectorsDict() { static NSDictionary *dict; static dispatch_once_t once_token; dispatch_once(&once_token, ^(void) { +#if !TARGET_OS_OSX // [macOS] dict = @{ // https://developer.apple.com/documentation/uikit/uicolor/ui_element_colors // Label Colors @@ -130,6 +137,105 @@ kFallbackARGBKey : @(0x00000000) // iOS 13.0 }, }; +#else // [macOS + NSMutableDictionary *map = [@{ + // https://developer.apple.com/documentation/appkit/nscolor/ui_element_colors + // Label Colors + @"labelColor": @{}, + @"secondaryLabelColor": @{}, + @"tertiaryLabelColor": @{}, + @"quaternaryLabelColor": @{}, + // Text Colors + @"textColor": @{}, + @"placeholderTextColor": @{}, + @"selectedTextColor": @{}, + @"textBackgroundColor": @{}, + @"selectedTextBackgroundColor": @{}, + @"keyboardFocusIndicatorColor": @{}, + @"unemphasizedSelectedTextColor": @{ + kFallbackKey: @"selectedTextColor" + }, + @"unemphasizedSelectedTextBackgroundColor": @{ + kFallbackKey: @"textBackgroundColor" + }, + // Content Colors + @"linkColor": @{}, + @"separatorColor": @{ + kFallbackKey: @"gridColor" + }, + @"selectedContentBackgroundColor": @{ + kFallbackKey: @"alternateSelectedControlColor" + }, + @"unemphasizedSelectedContentBackgroundColor": @{ + kFallbackKey: @"secondarySelectedControlColor" + }, + // Menu Colors + @"selectedMenuItemTextColor": @{}, + // Table Colors + @"gridColor": @{}, + @"headerTextColor": @{}, + @"alternatingEvenContentBackgroundColor": @{ + kSelectorKey: @"alternatingContentBackgroundColors", + kIndexKey: @0, + kFallbackKey: @"controlAlternatingRowBackgroundColors" + }, + @"alternatingOddContentBackgroundColor": @{ + kSelectorKey: @"alternatingContentBackgroundColors", + kIndexKey: @1, + kFallbackKey: @"controlAlternatingRowBackgroundColors" + }, + // Control Colors + @"controlAccentColor": @{ + kFallbackKey: @"controlColor" + }, + @"controlColor": @{}, + @"controlBackgroundColor": @{}, + @"controlTextColor": @{}, + @"disabledControlTextColor": @{}, + @"selectedControlColor": @{}, + @"selectedControlTextColor": @{}, + @"alternateSelectedControlTextColor": @{}, + @"scrubberTexturedBackgroundColor": @{}, + // Window Colors + @"windowBackgroundColor": @{}, + @"windowFrameTextColor": @{}, + @"underPageBackgroundColor": @{}, + // Highlights and Shadows + @"findHighlightColor": @{ + kFallbackKey: @"highlightColor" + }, + @"highlightColor": @{}, + @"shadowColor": @{}, + // https://developer.apple.com/documentation/appkit/nscolor/standard_colors + // Standard Colors + @"systemBlueColor": @{}, + @"systemBrownColor": @{}, + @"systemGrayColor": @{}, + @"systemGreenColor": @{}, + @"systemOrangeColor": @{}, + @"systemPinkColor": @{}, + @"systemPurpleColor": @{}, + @"systemRedColor": @{}, + @"systemYellowColor": @{}, + // Transparent Color + @"clearColor" : @{}, + } mutableCopy]; + + NSMutableDictionary *aliases = [NSMutableDictionary new]; + for (NSString *objcSelector in map) { + NSMutableDictionary *entry = [map[objcSelector] mutableCopy]; + if ([entry objectForKey:kSelectorKey] == nil) { + entry[kSelectorKey] = objcSelector; + } + if ([objcSelector hasSuffix:kColorSuffix]) { + NSString *swiftSelector = [objcSelector substringToIndex:[objcSelector length] - [kColorSuffix length]]; + aliases[swiftSelector] = entry; + } + } + [map addEntriesFromDictionary:aliases]; + + dict = [map copy]; +#endif // macOS] }); return dict; } @@ -154,6 +260,7 @@ NSDictionary *platformColorSelectorsDict = _PlatformColorSelectorsDict(); NSDictionary *colorInfo = platformColorSelectorsDict[platformColorString]; if (colorInfo) { +#if !TARGET_OS_OSX // [macOS] SEL objcColorSelector = NSSelectorFromString([platformColorString stringByAppendingString:kColorSuffix]); if (![RCTPlatformColor respondsToSelector:objcColorSelector]) { // [macOS] NSNumber *fallbackRGB = colorInfo[kFallbackARGBKey]; @@ -169,6 +276,43 @@ return colorObject; } } +#else // [macOS + NSString *selectorName = colorInfo[kSelectorKey]; + if (selectorName == nil) { + selectorName = [platformColorString stringByAppendingString:kColorSuffix]; + } + + SEL objcColorSelector = NSSelectorFromString(selectorName); + if (![RCTPlatformColor respondsToSelector:objcColorSelector]) { + NSNumber *fallbackRGB = colorInfo[kFallbackARGBKey]; + if (fallbackRGB) { + return _UIColorFromHexValue(fallbackRGB); + } + NSString *fallbackColorName = colorInfo[kFallbackKey]; + if (fallbackColorName) { + return _UIColorFromSemanticString(fallbackColorName); + } + } else { + Class colorClass = [RCTPlatformColor class]; + IMP imp = [colorClass methodForSelector:objcColorSelector]; + id (*getColor)(id, SEL) = ((id(*)(id, SEL))imp); + id colorObject = getColor(colorClass, objcColorSelector); + if ([colorObject isKindOfClass:[NSArray class]]) { + NSNumber *index = colorInfo[kIndexKey]; + if (index != nil) { + NSArray *colors = colorObject; + NSUInteger idx = [index unsignedIntegerValue]; + if (idx < colors.count) { + colorObject = colors[idx]; + } + } + } + + if ([colorObject isKindOfClass:[RCTPlatformColor class]]) { + return colorObject; + } + } +#endif // macOS] } return nil; } @@ -183,10 +327,18 @@ static inline facebook::react::ColorComponents _ColorComponentsFromUIColor(RCTPlatformColor *color) // [macOS] { CGFloat rgba[4]; -#if TARGET_OS_OSX // [macOS - color = [color colorUsingColorSpace:[NSColorSpace genericRGBColorSpace]]; -#endif // macOS] +#if !TARGET_OS_OSX // [macOS] [color getRed:&rgba[0] green:&rgba[1] blue:&rgba[2] alpha:&rgba[3]]; +#else // [macOS + // Resolve dynamic/semantic colors against the current effective appearance + // so that dark mode colors are correctly extracted. + NSAppearance *previousAppearance = NSAppearance.currentAppearance; + NSAppearance.currentAppearance = [NSApp effectiveAppearance]; + NSColor *resolvedColor = [color colorUsingColorSpace:[NSColorSpace sRGBColorSpace]]; + NSAppearance.currentAppearance = previousAppearance; + NSColor *finalColor = resolvedColor ?: [color colorUsingColorSpace:[NSColorSpace genericRGBColorSpace]]; + [finalColor getRed:&rgba[0] green:&rgba[1] blue:&rgba[2] alpha:&rgba[3]]; +#endif // macOS] return {(float)rgba[0], (float)rgba[1], (float)rgba[2], (float)rgba[3]}; }