Color(_:bundle:) does not resolve .colorset nested in asset-catalog group folders on Android
Nobody has claimed this yet.
- Dominant language
- Swift
- Stars
- 330
- Forks
- 76
- Avg merge
- 3h 6m
- Merged PRs (30d)
- 1
Description
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.resourcesIndexreturns paths with a different shape for.imagesetvs.colorset.- The skipstone transpile flattens
.imagesetresources but preserves.colorsetnesting.
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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in Sources/SkipUI/SkipUI/System/Assets.swift at assetContentsURLs(name:bundle:) and compare its resource path matching with the nested colorset paths described in the issue. Use the provided nested and top-level colorset reproducer to verify that both layouts resolve on Android in light and dark modes, while the existing fallback is no longer used for nested colorsets.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- android, swift
- Domain
- frontend, mobile
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100