Skip to content

Commit 0b4fc42

Browse files
committed
fix(ios): replace UIColor light-dark() approach with LightDarkForeground ShapeStyle
The UIColor(dynamicProvider:) approach does not produce adaptive colors in WidgetKit. Replace with LightDarkForeground: ShapeStyle whose resolve(in: EnvironmentValues) is called by SwiftUI's rendering engine at draw time with the correct dark/light context. - JSColorParser: add parseLightDarkComponents() returning both Color values as a pair; parseLightDark() private fallback (UIColor) kept for non-text contexts (borders, backgrounds); remove findTopLevelComma() in favour of splitLightDark() - TextStyle: add lightDarkColors field; add LightDarkForeground: ShapeStyle - StyleConverter.parseText: route light-dark() strings to lightDarkColors instead of the flat color field - VoltraText: switch from foregroundColor to foregroundStyle(LightDarkForeground(...)) when lightDarkColors is set
1 parent 68d4f64 commit 0b4fc42

4 files changed

Lines changed: 72 additions & 34 deletions

File tree

packages/voltra/ios/ui/Style/JSColorParser.swift

Lines changed: 45 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ enum JSColorParser {
3333
return parseHSL(trimmed)
3434
}
3535

36-
// 4. light-dark() — CSS Color Level 4 adaptive color
37-
if trimmed.hasPrefix("light-dark") {
36+
// 4. light-dark() — adaptive color, resolved natively by UIKit trait system
37+
if trimmed.hasPrefix("light-dark(") {
3838
return parseLightDark(trimmed)
3939
}
4040

@@ -97,45 +97,60 @@ enum JSColorParser {
9797

9898
// MARK: - light-dark() Parser
9999

100-
/// Parses `light-dark(<lightColor>, <darkColor>)` into an adaptive Color that
101-
/// automatically responds to the system color scheme via UITraitCollection.
102-
private static func parseLightDark(_ string: String) -> Color? {
103-
guard let function = parseFunctionCall(string, allowedNames: ["light-dark"]) else { return nil }
100+
/// Splits a `light-dark(<light>, <dark>)` string into its two component strings.
101+
private static func splitLightDark(_ trimmed: String) -> (lightStr: String, darkStr: String)? {
102+
let prefix = "light-dark("
103+
guard trimmed.hasPrefix(prefix) else { return nil }
104+
let inner = String(trimmed.dropFirst(prefix.count))
105+
guard inner.hasSuffix(")") else { return nil }
106+
let body = String(inner.dropLast())
104107

105-
// Split on the first top-level comma — arguments may themselves contain commas
106-
// (e.g. rgb(255, 0, 0)), so we must count parenthesis depth.
107-
guard let splitIndex = findTopLevelComma(in: function.arguments) else { return nil }
108+
var depth = 0
109+
var commaIndex: String.Index? = nil
110+
for idx in body.indices {
111+
switch body[idx] {
112+
case "(": depth += 1
113+
case ")": depth -= 1
114+
case "," where depth == 0 && commaIndex == nil: commaIndex = idx
115+
default: break
116+
}
117+
}
108118

109-
let lightString = String(function.arguments[..<splitIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
110-
let darkString = String(function.arguments[function.arguments.index(after: splitIndex)...]).trimmingCharacters(in: .whitespacesAndNewlines)
119+
guard let comma = commaIndex else { return nil }
120+
return (
121+
lightStr: String(body[..<comma]).trimmingCharacters(in: .whitespacesAndNewlines),
122+
darkStr: String(body[body.index(after: comma)...]).trimmingCharacters(in: .whitespacesAndNewlines)
123+
)
124+
}
111125

112-
guard let lightColor = parse(lightString), let darkColor = parse(darkString) else { return nil }
126+
/// Returns both Color values for a `light-dark()` string so the caller can resolve
127+
/// via `LightDarkForeground: ShapeStyle` at draw time.
128+
static func parseLightDarkComponents(_ value: Any?) -> (light: Color, dark: Color)? {
129+
guard let string = value as? String else { return nil }
130+
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
131+
guard let split = splitLightDark(trimmed) else { return nil }
132+
guard let lightColor = parse(split.lightStr),
133+
let darkColor = parse(split.darkStr) else { return nil }
134+
return (light: lightColor, dark: darkColor)
135+
}
113136

137+
/// Fallback used by `parse()` for non-text contexts (e.g. borders, backgrounds).
138+
/// Uses UIColor dynamic provider — best-effort; prefer parseLightDarkComponents()
139+
/// + LightDarkForeground: ShapeStyle for text foreground colors.
140+
private static func parseLightDark(_ string: String) -> Color? {
141+
guard let split = splitLightDark(string),
142+
let lightC = parseColorComponents(split.lightStr),
143+
let darkC = parseColorComponents(split.darkStr) else { return nil }
114144
#if canImport(UIKit)
115145
return Color(uiColor: UIColor { traitCollection in
116-
traitCollection.userInterfaceStyle == .dark
117-
? UIColor(darkColor)
118-
: UIColor(lightColor)
146+
let c = traitCollection.userInterfaceStyle == .dark ? darkC : lightC
147+
return UIColor(red: c.red, green: c.green, blue: c.blue, alpha: c.alpha)
119148
})
120149
#else
121-
return lightColor
150+
return Color(.sRGB, red: lightC.red, green: lightC.green, blue: lightC.blue, opacity: lightC.alpha)
122151
#endif
123152
}
124153

125-
/// Returns the index of the first comma that is not nested inside parentheses.
126-
private static func findTopLevelComma(in string: String) -> String.Index? {
127-
var depth = 0
128-
for index in string.indices {
129-
switch string[index] {
130-
case "(": depth += 1
131-
case ")": depth -= 1
132-
case "," where depth == 0: return index
133-
default: break
134-
}
135-
}
136-
return nil
137-
}
138-
139154
/// Parse named color strings
140155
private static func parseNamedColor(_ name: String) -> Color? {
141156
switch name {

packages/voltra/ios/ui/Style/StyleConverter.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,14 @@ enum StyleConverter {
153153
private static func parseText(_ js: [String: Any]) -> TextStyle {
154154
var style = TextStyle()
155155

156-
if let color = JSColorParser.parse(js["color"]) {
156+
let colorValue = js["color"]
157+
if let colorStr = colorValue as? String,
158+
colorStr.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().hasPrefix("light-dark("),
159+
let components = JSColorParser.parseLightDarkComponents(colorStr) {
160+
style.lightDarkColors = components
161+
} else if let color = JSColorParser.parse(colorValue) {
157162
style.color = color
158-
style.usesPrimaryColorInReducedPresentation = JSColorParser.shouldUsePrimaryColorInReducedPresentation(js["color"])
163+
style.usesPrimaryColorInReducedPresentation = JSColorParser.shouldUsePrimaryColorInReducedPresentation(colorValue)
159164
}
160165

161166
if let size = JSStyleParser.number(js["fontSize"]) {

packages/voltra/ios/ui/Style/TextStyle.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import SwiftUI
22

33
struct TextStyle {
44
var color: Color = .primary
5+
var lightDarkColors: (light: Color, dark: Color)? = nil
56
var usesPrimaryColorInReducedPresentation = false
67
var fontSize: CGFloat = 17
78
var fontWeight: Font.Weight = .regular
@@ -15,6 +16,19 @@ struct TextStyle {
1516
var fontVariant: Set<FontVariant> = []
1617
}
1718

19+
/// A ShapeStyle whose resolve(in:) is called by SwiftUI's rendering engine at draw time,
20+
/// not during body evaluation. This is the correct hook for adaptive colors in WidgetKit
21+
/// because the rendering engine passes the correct dark/light environment to resolve(in:)
22+
/// even though @Environment(\.colorScheme) in body always reads as .light.
23+
struct LightDarkForeground: ShapeStyle {
24+
let light: Color
25+
let dark: Color
26+
27+
func resolve(in environment: EnvironmentValues) -> some ShapeStyle {
28+
environment.colorScheme == .dark ? dark : light
29+
}
30+
}
31+
1832
struct TextStyleModifier: ViewModifier {
1933
let style: TextStyle
2034
@Environment(\.voltraEnvironment) private var voltraEnvironment

packages/voltra/ios/ui/Views/VoltraText.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,13 @@ public struct VoltraText: VoltraView {
7474
.kerning(textStyle.letterSpacing)
7575
.underline(textStyle.decoration == .underline || textStyle.decoration == .underlineLineThrough)
7676
.strikethrough(textStyle.decoration == .lineThrough || textStyle.decoration == .underlineLineThrough)
77-
// These technically work on View, but good to keep close
7877
.font(font)
79-
.foregroundColor(resolvedColor)
78+
.foregroundStyle({
79+
if let ld = textStyle.lightDarkColors {
80+
return AnyShapeStyle(LightDarkForeground(light: ld.light, dark: ld.dark))
81+
}
82+
return AnyShapeStyle(resolvedColor)
83+
}())
8084
.multilineTextAlignment(alignment)
8185
.lineSpacing(textStyle.lineSpacing)
8286
.voltraIfLet(params.numberOfLines) { view, numberOfLines in

0 commit comments

Comments
 (0)