diff --git a/packages/react-native/React/Base/RCTConvert.mm b/packages/react-native/React/Base/RCTConvert.mm index 9b7dd699ce6b..992a19fd947d 100644 --- a/packages/react-native/React/Base/RCTConvert.mm +++ b/packages/react-native/React/Base/RCTConvert.mm @@ -944,6 +944,74 @@ + (RCTColorSpace)RCTColorSpaceFromString:(NSString *)colorSpace return RCTGetDefaultColorSpace(); } +// Parses a raw hex color string (#RGB, #RGBA, #RRGGBB, #RRGGBBAA — alpha last, as +// in CSS) into a UIColor, or nil if it is not a supported hex color. Used only as +// a PlatformColor fallback when every semantic name misses. (On the iOS New +// Architecture the fallback is parsed by the shared CSS color parser, which also +// accepts rgb()/rgba() and named colors; this legacy path handles the common hex +// forms.) +static UIColor *RCTFallbackUIColorFromHexString(NSString *colorString) +{ + if (![colorString isKindOfClass:[NSString class]]) { + return nil; + } + NSString *hex = [colorString stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if (![hex hasPrefix:@"#"]) { + return nil; + } + hex = [hex substringFromIndex:1]; + NSUInteger length = hex.length; + // Expand shorthand #RGB / #RGBA to #RRGGBB / #RRGGBBAA. + if (length == 3 || length == 4) { + NSMutableString *expanded = [NSMutableString stringWithCapacity:length * 2]; + for (NSUInteger i = 0; i < length; i++) { + unichar c = [hex characterAtIndex:i]; + [expanded appendFormat:@"%C%C", c, c]; + } + hex = expanded; + length = hex.length; + } + if (length != 6 && length != 8) { + return nil; + } + // Initialized once: the inverted hex-digit set is immutable, so there is no + // need to reallocate it on every fallback parse. + static NSCharacterSet *const nonHex = + [[NSCharacterSet characterSetWithCharactersInString:@"0123456789abcdefABCDEF"] invertedSet]; + if ([hex rangeOfCharacterFromSet:nonHex].location != NSNotFound) { + return nil; + } + unsigned int value = 0; + if (![[NSScanner scannerWithString:hex] scanHexInt:&value]) { + return nil; + } + CGFloat r = NAN; + CGFloat g = NAN; + CGFloat b = NAN; + CGFloat a = NAN; + if (length == 6) { + r = ((value >> 16) & 0xFF) / 255.0; + g = ((value >> 8) & 0xFF) / 255.0; + b = (value & 0xFF) / 255.0; + a = 1.0; + } else { + r = ((value >> 24) & 0xFF) / 255.0; + g = ((value >> 16) & 0xFF) / 255.0; + b = ((value >> 8) & 0xFF) / 255.0; + a = (value & 0xFF) / 255.0; + } + return [UIColor colorWithRed:r green:g blue:b alpha:a]; +} + +static UIColor *RCTFallbackUIColorFromColorDictionary(NSDictionary *dictionary) +{ + id fallback = [dictionary objectForKey:@"fallback"]; + if ([fallback isKindOfClass:[NSString class]]) { + return RCTFallbackUIColorFromHexString(fallback); + } + return nil; +} + + (UIColor *)UIColor:(id)json { if (!json) { @@ -983,6 +1051,10 @@ + (UIColor *)UIColor:(id)json } color = RCTColorFromSemanticColorName(semanticName); if (color == nil) { + UIColor *fallbackColor = RCTFallbackUIColorFromColorDictionary(dictionary); + if (fallbackColor != nil) { + return fallbackColor; + } RCTLogConvertError( json, [@"a UIColor. Expected one of the following values: " stringByAppendingString:RCTSemanticColorNames()]); @@ -999,6 +1071,10 @@ + (UIColor *)UIColor:(id)json return color; } } + UIColor *fallbackColor = RCTFallbackUIColorFromColorDictionary(dictionary); + if (fallbackColor != nil) { + return fallbackColor; + } RCTLogConvertError( json, [@"a UIColor. None of the names in the array were one of the following values: " 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 b9535f14e363..778b7fa71f11 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 @@ -28,6 +28,11 @@ struct Color { int32_t getColor() const; std::size_t getUIColorHash() const; + // Returns UndefinedColor (a Color whose underlying UIColor is null) when no + // semantic name resolves, so callers can distinguish a genuine miss from a name + // that resolves to a transparent color. getColor() still yields 0 for it, but + // callers that reach into the underlying UIColor (e.g. -CGColor) must null-check + // getUIColor() first. static Color createSemanticColor(std::vector &semanticItems); std::shared_ptr getUIColor() 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 93ecb7e33190..cdf776338106 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 @@ -237,7 +237,14 @@ int32_t ColorFromUIColor(const std::shared_ptr &uiColor) Color Color::createSemanticColor(std::vector &semanticItems) { - auto semanticColor = RCTPlatformColorFromSemanticItems(semanticItems); + UIColor *semanticColor = RCTPlatformColorFromSemanticItemsOrNil(semanticItems); + if (semanticColor == nil) { + // No semantic name resolved. Return the undefined-color sentinel (a null + // underlying UIColor) so the caller can tell a genuine miss apart from a name + // that legitimately resolves to a transparent color; getColor() still yields + // 0 (transparent) for it, preserving the previous render on a miss. + return HostPlatformColor::UndefinedColor; + } return Color(wrapManagedObject(semanticColor)); } 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 377783f0f195..71dfd3e345f8 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 @@ -8,9 +8,13 @@ #import "PlatformColorParser.h" #import +#import +#import +#import #import #import #import +#import #import #import @@ -51,13 +55,48 @@ return SharedColor(color); } +// Parses a raw color string (e.g. "#f00", "#ff0000", "#ff000080", "rgba(...)") +// using the shared CSS color parser. Returns std::nullopt when the string is not +// a parseable color, so a fallback that parses to a transparent color (e.g. +// "transparent" or "rgba(0,0,0,0)") is still honored rather than being mistaken +// for a parse failure. +static std::optional fallbackColorFromString(const std::string &fallbackString) +{ + auto cssColor = parseCSSProperty(fallbackString); + if (std::holds_alternative(cssColor)) { + const auto &c = std::get(cssColor); + return colorFromRGBA(c.r, c.g, c.b, c.a); + } + return std::nullopt; +} + SharedColor parsePlatformColor(const ContextContainer &contextContainer, int32_t surfaceId, const RawValue &value) { if (value.hasType>()) { auto items = (std::unordered_map)value; if (items.find("semantic") != items.end() && items.at("semantic").hasType>()) { auto semanticItems = (std::vector)items.at("semantic"); - return SharedColor(Color::createSemanticColor(semanticItems)); + auto semanticColor = SharedColor(Color::createSemanticColor(semanticItems)); + // createSemanticColor yields the undefined-color sentinel (a null underlying + // UIColor) only when no semantic name resolves. That explicit miss signal is + // distinct from a name that legitimately resolves to a transparent color, so + // the fallback is applied only on a true miss. + bool semanticResolved = ((*semanticColor).getUIColor() != nullptr); + if (!semanticResolved) { + if (items.find("fallback") != items.end() && items.at("fallback").hasType()) { + auto fallbackColor = fallbackColorFromString((std::string)items.at("fallback")); + // has_value() (not getColor() != 0) so a fallback that parses to a + // transparent color is honored rather than rejected. + if (fallbackColor.has_value()) { + return *fallbackColor; + } + } + // A miss with no usable fallback renders transparent, as before. Return a + // real clearColor rather than the sentinel so the null-UIColor sentinel + // never escapes this function. + return clearColor(); + } + return semanticColor; } else if ( items.find("dynamic") != items.end() && items.at("dynamic").hasType>()) { diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.h b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.h index f487564c043d..54ba5c26ab44 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.h +++ b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.h @@ -15,6 +15,18 @@ struct ColorComponents; struct Color; } // namespace facebook::react +NS_ASSUME_NONNULL_BEGIN + facebook::react::ColorComponents RCTPlatformColorComponentsFromSemanticItems(std::vector &semanticItems); UIColor *RCTPlatformColorFromSemanticItems(std::vector &semanticItems); +// Like RCTPlatformColorFromSemanticItems, but returns nil (instead of collapsing +// to clearColor) when no name resolves, so callers can tell a genuine miss apart +// from a name that legitimately resolves to a transparent color. +UIColor *_Nullable RCTPlatformColorFromSemanticItemsOrNil(std::vector &semanticItems); +// Precondition: `color` must be a resolved color, never the undefined-color +// sentinel (a null underlying UIColor) that Color::createSemanticColor returns on +// a miss — callers resolve that to a concrete color first — so the result is +// non-null and stays _Nonnull under this region. UIColor *RCTPlatformColorFromColor(const facebook::react::Color &color); + +NS_ASSUME_NONNULL_END 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 4a0a53e2b504..9dfcb336fd3e 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 @@ -192,7 +192,7 @@ return _ColorComponentsFromUIColor(RCTPlatformColorFromSemanticItems(semanticItems)); } -UIColor *RCTPlatformColorFromSemanticItems(std::vector &semanticItems) +UIColor *_Nullable RCTPlatformColorFromSemanticItemsOrNil(std::vector &semanticItems) { for (const auto &semanticCString : semanticItems) { NSString *semanticNSString = _NSStringFromCString(semanticCString); @@ -206,7 +206,18 @@ } } - return UIColor.clearColor; + return nil; +} + +UIColor *RCTPlatformColorFromSemanticItems(std::vector &semanticItems) +{ + // Legacy contract: callers that assume a non-null UIColor (e.g. + // RCTPlatformColorComponentsFromSemanticItems, which reads the result with + // -getRed:...) still get clearColor when no name resolves. Callers that must + // distinguish a miss from a name that resolves to a transparent color use the + // OrNil variant instead. + UIColor *uiColor = RCTPlatformColorFromSemanticItemsOrNil(semanticItems); + return uiColor != nil ? uiColor : UIColor.clearColor; } UIColor *RCTPlatformColorFromColor(const facebook::react::Color &color)