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

Aperta Adatta ai principianti
#427 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
2/5
Tempo stimato
1-3 ore
Idoneità per principianti
76/100
Tipo di issue
Bug
Chiarezza
Specificata chiaramente
Stato di attività
Tranquilla
Stack tecnologico
android, swift
Ambito
frontend, mobile

Direzione di ricerca

Inizia in Sources/SkipUI/SkipUI/System/Assets.swift, in assetContentsURLs(name:bundle:), e confronta la corrispondenza dei percorsi delle risorse con i percorsi dei colorset annidati descritti nell’issue. Usa il reproducer fornito per colorset annidati e di primo livello per verificare che entrambi i layout vengano risolti su Android nelle modalità chiara e scura, mentre il fallback esistente non viene più usato per i colorset annidati.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

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.

Lingua principale
Swift
Stelle
331
Fork
89
Merge medio
2g 13h
PR unite (30g)
26

Preparare l'ambiente

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di skiptools/skip-ui

Tutte le issue di skiptools/skip-ui

Issue simili

Altre issue su Swift

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.