Search code examples
iosswiftuiuikit

Color assets use device appearance variant instead of the one set runtime


In certain cases the app fails to use the correct asset appearances when manipulating the interface style programmatically in the app.

I've implemented dark mode in a SwiftUI app defining appearances for my assets - colors and images. The app looks for the device theme on launch, checking the colorScheme in the environment:

@Environment(\.colorScheme) private var colorScheme: ColorScheme

Within the app users have the option to override this, choosing from light or dark mode with a switch. This setting is saved for future launches, and it's used to set the theme like so:

@MainActor
class ThemeManager: NSObject, ObservableObject {
...
    func setTheme(_ theme: Theme) {
        (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.windows.first!.overrideUserInterfaceStyle = theme.interfaceStyle
    }
...
}

Which works perfectly well, as long as I don't need to re-render a CALayer, that has colored sublayer components. My CALayer is first drawn part of the onAppear() lifecycle method of a view and all colors are using the correct appearance set by the user.

However, any time this layer has to be re-drawn while the view is still alive, the colors are appearing in the theme of the device instead of the one set in the app.

When the view is dismissed and the layer is killed, then reopened and rendered again, everything is back to normal.

Examples of problematic color attributes:

// Setting stroke color of CAShapeLayer
let wallLayer = CAShapeLayer()
wallLayer.strokeColor = theme.wallColor

// Setting background color of
let shapeLayer = CAShapeLayer()
shapeLayer.fillColor = theme.floorColor

theme.color is pointing to Color assets with Any, Light and Dark appearances.

Every shape layer is added as sublayer to a CALayer, which is then displayed in a UIView, wrapped in a UIViewRepresentable for display in SwiftUI views.


Solution

  • UIKit Layers - CALayer / CAShapeLayer / CAGradientLayer / etc - do note use UIColor ... they use CGColor.

    Based on the little snippet of code you posted, I assume your theme.wallColor returns a CGColor ... which does not have dark/light mode variants.

    I also assume that, in your UIView wrapped in a UIViewRepresentable, you have implemented override func layoutSubviews() to update your layers and paths.

    Add this to update the color(s):

    override func layoutSubviews() {
        super.layoutSubviews()
        
        wallLayer.strokeColor = theme.wallColor
        shapeLayer.fillColor = theme.floorColor
    }
    

    Now, when the dark/light mode trait changes (or any other time layoutSubviews() is called) the colors will be updated with the current value returned by the theme.

    Note that this is not related to using Color Assets ... the same thing will happen if you've used layer.fillColor = UIColor.systemBackground.cgColor -- for example.