skiptools / skiptools/skip-ui

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

Open Beginner friendly
#427 0 comments 0 reactions 0 assignees View on GitHub

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.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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.