Skip to content

Color(_:bundle:) does not resolve .colorset nested in asset-catalog group folders on Android #427

@Jeehut

Description

@Jeehut

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions