Summary
Color(_:bundle:) (and likely Image(_:bundle:)) on Android does not resolve .colorset (.imageset) resources nested in asset-catalog group folders — only top-level <Bundle>.xcassets/<Name>.<type>/Contents.json paths are found. Nested-group paths like <Bundle>.xcassets/Colors/<Name>.colorset/Contents.json fall through to the Color.gray fallback. iOS handles both layouts identically via Apple's native asset lookup. Apple's asset catalogs explicitly support arbitrary group folder nesting, so this is a parity gap on Android.
Environment
| Package |
Version |
skip |
1.8.14 |
skip-fuse |
1.0.2 |
skip-fuse-ui |
1.15.0 |
skip-ui |
1.53.1 |
skip-foundation |
1.4.0 |
skip-model |
1.7.3 |
Module mode: native. Xcode 17E192, Swift 6.3. Android emulator medium_phone (1080×2400, Android 16). iOS Simulator iPhone 17, iOS 26.4.
Reproducer
Two pairs of test colorsets — one nested in a Colors/ group, one flat:
App/Sources/SpotCrown/Resources/Module.xcassets/
├── Colors/ # nested group
│ ├── Contents.json
│ ├── TestBackground.colorset/Contents.json # Light: white / Dark: black
│ └── TestInk.colorset/Contents.json # Light: black / Dark: white
├── TestFlatBackground.colorset/Contents.json # Light: white / Dark: black (top-level)
└── TestFlatInk.colorset/Contents.json # Light: black / Dark: white (top-level)
Each Contents.json carries the canonical "Any Appearance" + "Dark" appearances: [{ appearance: "luminosity", value: "dark" }] schema. Values are inverted (white ↔ black) so failure-to-flip is visually obvious.
A SwiftUI view splits the screen and renders both pairs:
private static let testBackground = Color("TestBackground", bundle: .module)
private static let testInk = Color("TestInk", bundle: .module)
private static let testFlatBackground = Color("TestFlatBackground", bundle: .module)
private static let testFlatInk = Color("TestFlatInk", bundle: .module)
var body: some View {
VStack(spacing: 0) {
ZStack {
Rectangle().fill(Self.testBackground)
VStack {
Text("Nested group").foregroundStyle(Self.testInk)
Text("`Module.xcassets/Colors/Test*.colorset`").foregroundStyle(Self.testInk)
}
}
ZStack {
Rectangle().fill(Self.testFlatBackground)
VStack {
Text("Flat top-level").foregroundStyle(Self.testFlatInk)
Text("`Module.xcassets/TestFlat*.colorset`").foregroundStyle(Self.testFlatInk)
}
}
}
}
Observed behaviour
| Platform |
Scheme |
Top (nested-group) |
Bottom (flat top-level) |
| iOS Sim Light |
Light |
✅ white bg, black text |
✅ white bg, black text |
| iOS Sim Dark |
Dark |
✅ black bg, white text |
✅ black bg, white text |
Android night no |
Light |
❌ grey + invisible text |
✅ white bg, black text |
Android night yes |
Dark |
❌ grey + invisible text |
✅ black bg, white text |
The grey is SkipUI's Color.gray.colorImpl() fallback at Sources/SkipUI/SkipUI/Color/Color.swift:131 when assetColorInfo(...) returns nil. The Dark appearance variant flips correctly for the flat-layout pair, confirming the variant-selection logic itself is healthy.
Root cause
The asset path lookup at Sources/SkipUI/SkipUI/System/Assets.swift:17-34:
func assetContentsURLs(name: String, bundle: Bundle) -> [URL] {
let name = name.replace(" ", "%20")
let resourceNames = bundle.resourcesIndex
var resourceURLs: [URL] = []
for resourceName in resourceNames {
let components = resourceName.split(separator: "/").map({ String($0) })
// return every *.xcassets/NAME/Contents.json
if components.first?.hasSuffix(".xcassets") == true
&& components.dropFirst().first == name
&& components.last == "Contents.json",
let contentsURL = bundle.url(forResource: resourceName, withExtension: nil) {
resourceURLs.append(contentsURL)
}
}
return resourceURLs
}
The components.dropFirst().first == name check matches:
Module.xcassets/TestBackground.colorset/Contents.json ✅ — components = ["Module.xcassets", "TestBackground.colorset", "Contents.json"], dropFirst().first == "TestBackground.colorset".
Module.xcassets/Colors/TestBackground.colorset/Contents.json ❌ — components = ["Module.xcassets", "Colors", "TestBackground.colorset", "Contents.json"], dropFirst().first == "Colors".
The skipstone transpile pipeline correctly copies the nested-group .colorset directories into the Android APK (.build/Android/app/intermediates/assets/.../<Bundle>/<Group>/<Name>.colorset/Contents.json is present), so the bug is purely in the runtime lookup.
Suggested fix
The match condition needs to accept any one-of-the-components-after-<X>.xcassets equal to name, not specifically dropFirst().first. Something like:
if components.first?.hasSuffix(".xcassets") == true
&& components.contains(name)
&& components.last == "Contents.json",
…
Or, more precisely matching Apple's spec (the colorset is the directory immediately containing Contents.json):
if components.first?.hasSuffix(".xcassets") == true
&& components.dropLast().last == name // the colorset directory holds Contents.json
&& components.last == "Contents.json",
…
The same change presumably applies to Image.swift:845 for nested-group .imageset resources, and Image.swift:867 for .symbolset. (Though see open question below.)
Open question
The SpotCrown app's production Module.xcassets/EmptyStates/EmptyGroups.imageset/Contents.json IS resolved correctly on the Android emulator (M0005 + M0007 emulator screenshots verified). That contradicts the analysis above for .imageset. Possible explanations:
Image.swift's lookup uses a different code path I haven't traced.
bundle.resourcesIndex returns paths with a different shape for .imageset vs .colorset.
- The skipstone transpile flattens
.imageset resources but preserves .colorset nesting.
Happy to file a follow-up issue once the maintainer-side answer is known. The narrow scope here is .colorset lookup; if the same fix-once-applies-elsewhere applies to other types, all the better.
Workaround in use
For SpotCrown's M0008 ThemeSystem Mission, the 14 production colorsets will land directly at the top level of Module.xcassets/, not in a Colors/ group. The two test colorsets in Module.xcassets/Colors/ and the two sibling test colorsets at top level stay in place as a forever-reference reproducer so we can re-test against future Skip releases. Once this issue is fixed, the production colorsets can move into a group for organisational clarity.
Cross-reference
This finding precedent — documenting an Android Skip Fuse limitation + the Plan-A workaround + an upstream issue — follows the M0007 / skip-fuse-ui #106 / #107 / #108 pattern (PhotosPicker / UIGraphicsImageRenderer / CIFilter QR encoder bridge gaps). The full project-side dossier lives at https://github.com/FlineDev/SpotCrown in KantaiKit/Research/Skip-Fuse-Theme-Adapter.md and includes the 4 reproducer screenshots.
Summary
Color(_:bundle:)(and likelyImage(_:bundle:)) on Android does not resolve.colorset(.imageset) resources nested in asset-catalog group folders — only top-level<Bundle>.xcassets/<Name>.<type>/Contents.jsonpaths are found. Nested-group paths like<Bundle>.xcassets/Colors/<Name>.colorset/Contents.jsonfall through to theColor.grayfallback. iOS handles both layouts identically via Apple's native asset lookup. Apple's asset catalogs explicitly support arbitrary group folder nesting, so this is a parity gap on Android.Environment
skipskip-fuseskip-fuse-uiskip-uiskip-foundationskip-modelModule mode:
native. Xcode 17E192, Swift 6.3. Android emulatormedium_phone(1080×2400, Android 16). iOS Simulator iPhone 17, iOS 26.4.Reproducer
Two pairs of test colorsets — one nested in a
Colors/group, one flat:Each
Contents.jsoncarries the canonical "Any Appearance" + "Dark"appearances: [{ appearance: "luminosity", value: "dark" }]schema. Values are inverted (white ↔ black) so failure-to-flip is visually obvious.A SwiftUI view splits the screen and renders both pairs:
Observed behaviour
night nonight yesThe grey is SkipUI's
Color.gray.colorImpl()fallback atSources/SkipUI/SkipUI/Color/Color.swift:131whenassetColorInfo(...)returnsnil. The Dark appearance variant flips correctly for the flat-layout pair, confirming the variant-selection logic itself is healthy.Root cause
The asset path lookup at
Sources/SkipUI/SkipUI/System/Assets.swift:17-34:The
components.dropFirst().first == namecheck matches:Module.xcassets/TestBackground.colorset/Contents.json✅ —components = ["Module.xcassets", "TestBackground.colorset", "Contents.json"],dropFirst().first == "TestBackground.colorset".Module.xcassets/Colors/TestBackground.colorset/Contents.json❌ —components = ["Module.xcassets", "Colors", "TestBackground.colorset", "Contents.json"],dropFirst().first == "Colors".The skipstone transpile pipeline correctly copies the nested-group
.colorsetdirectories into the Android APK (.build/Android/app/intermediates/assets/.../<Bundle>/<Group>/<Name>.colorset/Contents.jsonis present), so the bug is purely in the runtime lookup.Suggested fix
The match condition needs to accept any one-of-the-
components-after-<X>.xcassetsequal toname, not specificallydropFirst().first. Something like:Or, more precisely matching Apple's spec (the colorset is the directory immediately containing
Contents.json):The same change presumably applies to
Image.swift:845for nested-group.imagesetresources, andImage.swift:867for.symbolset. (Though see open question below.)Open question
The SpotCrown app's production
Module.xcassets/EmptyStates/EmptyGroups.imageset/Contents.jsonIS resolved correctly on the Android emulator (M0005 + M0007 emulator screenshots verified). That contradicts the analysis above for.imageset. Possible explanations:Image.swift's lookup uses a different code path I haven't traced.bundle.resourcesIndexreturns paths with a different shape for.imagesetvs.colorset..imagesetresources but preserves.colorsetnesting.Happy to file a follow-up issue once the maintainer-side answer is known. The narrow scope here is
.colorsetlookup; if the same fix-once-applies-elsewhere applies to other types, all the better.Workaround in use
For SpotCrown's M0008 ThemeSystem Mission, the 14 production colorsets will land directly at the top level of
Module.xcassets/, not in aColors/group. The two test colorsets inModule.xcassets/Colors/and the two sibling test colorsets at top level stay in place as a forever-reference reproducer so we can re-test against future Skip releases. Once this issue is fixed, the production colorsets can move into a group for organisational clarity.Cross-reference
This finding precedent — documenting an Android Skip Fuse limitation + the Plan-A workaround + an upstream issue — follows the M0007 /
skip-fuse-ui#106 / #107 / #108 pattern (PhotosPicker / UIGraphicsImageRenderer / CIFilter QR encoder bridge gaps). The full project-side dossier lives at https://github.com/FlineDev/SpotCrown inKantaiKit/Research/Skip-Fuse-Theme-Adapter.mdand includes the 4 reproducer screenshots.