Defining dynamic colors in Swift

在大多数情况下,可以说现代iOS和Mac应用在light模式或dark模式下都能很好地适应用户运行的设备,这通常要求我们在UI中使用更动态的颜色。

虽然苹果使用Xcodeasset目录来系统声明这种动态颜色是相当直接的,但有时我们可能想要在Swift代码中inline定义颜色。让我们发现一些方法来做到这一点,使用SwiftUI或UIKit。

Using system colors

也许确保我们的颜色适应各种用户偏好的最简单的方法是尽可能使用作为UIKit和SwiftUI的一部分的预定义颜色。所有SwiftUI的内置颜色默认都是自适应的,对于所有以system为前缀的UIColor api也是如此:

// SwiftUI
label.foregroundColor(.orange)

// UIKit
label.textColor = .systemOrange

虽然上面的标签总是有一个橙色的文本颜色,但具体使用的橙色阴影会根据用户的设备是使用深色还是浅色模式,以及是否启用了某些可访问性设置(如增加对比度)而变化。

SwiftUI和UIKit也提供了一套更抽象的颜色,然后在运行时解析为特定的、与上下文相关的颜色。 例如,当使用SwiftUI时,主色和次色在处理文本时通常特别有用,因为它们会让我们的文本颜色与系统中使用的颜色相匹配:

struct ArticleListItem: View {
    var article: Article
    
    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text(article.title)
                .font(.headline)
                .foregroundColor(.primary)
            Text(article.description)
                .foregroundColor(.secondary)
        }
        .padding()
    }
}
avatar

UIKit包含了一组更全面的上下文颜色,这些颜色与某些系统组件使用的默认颜色关联更大。 因此,在使用UIColor时,SwiftUI的原色和次色的等效值被称为label和secondaryLabel:

label.textColor = .label
detailLabel.textColor = .secondaryLabel
view.backgroundColor = .systemBackground

Custom colors

虽然系统提供的颜色绝对是一个很好的起点,但我们可能还想在每个项目中使用一些完全自定义的颜色,我们可能还需要使这些颜色适应用户当前的配色方案。

一种方法是让我们的UI代码观察颜色方案何时发生变化,然后更新任何需要调整的自定义颜色。 例如在使用SwiftUI时:

struct ArticleListItem: View {
    var article: Article
    @Environment(\.colorScheme) private var colorScheme
    
    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text(article.title)
                .font(.headline)
                .foregroundColor(titleColor)
            Text(article.description)
                .foregroundColor(.secondary)
        }
        .padding()
    }
    
    private var titleColor: Color {
        switch colorScheme {
        case .light:
            return Color(white: 0.2)
        case .dark:
            return Color(white: 0.8)
        @unknown default:
            return Color(white: 0.2)
        }
    }
}

当使用UIKit时,我们可以通过覆盖一个视图或视图控制器中的traitCollectionDidChange(_😃方法来执行同样的观察。然后我们可以打开传入的特征集合的userInterfaceStyle。

然而,尽管上述技术确实有效,但如果我们需要在许多不同的视图中执行相同的观察,那么事情很快就会变得非常混乱和重复。在使用SwiftUI时,解决这个问题的一种方法是创建一个可重用的view modifier,让我们为明暗模式指定单独的前景色,然后让我们的修改器在内部观察当前的颜色方案——像这样:

struct AdaptiveForegroundColorModifier: ViewModifier {
    var lightModeColor: Color
    var darkModeColor: Color
    
    @Environment(\.colorScheme) private var colorScheme
    
    func body(content: Content) -> some View {
        content.foregroundColor(resolvedColor)
    }
    
    private var resolvedColor: Color {
        switch colorScheme {
        case .light:
            return lightModeColor
        case .dark:
            return darkModeColor
        @unknown default:
            return lightModeColor
        }
    }
}

extension View {
    func foregroundColor(
        light lightModeColor: Color,
        dark darkModeColor: Color
    ) -> some View {
        modifier(AdaptiveForegroundColorModifier(
            lightModeColor: lightModeColor, 
            darkModeColor: darkModeColor
        ))
    }
}

有了上面的修饰符,我们现在可以轻松地指定我们的自适应前景颜色内联在我们的视图中,而不需要任何观察或在每个调用站点的其他复杂性:

struct ArticleListItem: View {
    var article: Article
    
    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text(article.title)
                .font(.headline)
                .foregroundColor(
                    light: Color(white: 0.2),
                    dark: Color(white: 0.8)
                )
            Text(article.description)
                .foregroundColor(.secondary)
        }
        .padding()
    }
}

如果我们只是想与颜色在一组有限的方面那上述技术是非常有用的,但是如果我们想要定制各种颜色——前景,背景,着色,抚摸和填充形状等等,那么我们可能要想出一个更通用的解决方案。

为此,我们可以转而使用UIColor,它提供了一种用动态闭包初始化颜色的方法,当当前活动的UITraitCollection被更改时,系统将调用这个动态闭包。这反过来使我们能够实现一个自定义初始化器——就像我们上面使用的模式一样——让我们指定单独的light模式和dark模式颜色,然后根据当前trait集合的userInterfaceStyle来解析:

extension UIColor {
    convenience init(
        light lightModeColor: @escaping @autoclosure () -> UIColor,
        dark darkModeColor: @escaping @autoclosure () -> UIColor
     ) {
        self.init { traitCollection in
            switch traitCollection.userInterfaceStyle {
            case .light:
                return lightModeColor()
            case .dark:
                return darkModeColor()
            @unknown default:
                return lightModeColor()
            }
        }
    }
}

上述解决方案的一个很大的好处是,它可以很容易地扩展到考虑其他类型的特征(如各种可访问性设置),因为我们传递的UITraitCollection实例包含了更多的信息,而不仅仅是使用的颜色方案。

真正了不起的是,SwiftUI的Color和UIColor可以很容易地连接起来——这意味着我们也可以用很少的额外代码使上述解决方案完全兼容SwiftUI:

extension Color {
    init(
        light lightModeColor: @escaping @autoclosure () -> Color,
        dark darkModeColor: @escaping @autoclosure () -> Color
    ) {
        self.init(UIColor(
            light: UIColor(lightModeColor()),
            dark: UIColor(darkModeColor())
        ))
    }
}

注意,在iOS 15 SDK中,上面的Color初始化器已经被弃用,取而代之的是一个名为init(uiColor:)的新版本,其工作方式完全相同。

在上面的地方,我们现在可以创建自适应颜色和UIColor实例,只要这样做:

struct ArticleListItem: View {
    var article: Article
    
    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text(article.title)
                .font(.headline)
                .foregroundColor(Color(
                    light: Color(white: 0.2),
                    dark: Color(white: 0.8)
                ))
            Text(article.description)
                .foregroundColor(.secondary)
        }
        .padding()
    }
}

如果我们想要更进一步,我们甚至可以将颜色定义从视图中完全抽象出来,将它们移到静态属性中,与作为系统一部分的主要、次要和其他动态颜色具有相同的作用:

extension Color {
    static var title: Self {
        Self(light: Color(white: 0.2),
             dark: Color(white: 0.8))
    }
}

以上绝对是我在构建应用程序时更喜欢使用的架构。通过将每种颜色关联到标题、背景、appTint等属性中,我发现保持应用程序的颜色一致和组织良好要容易得多。

Conclusion

一开始,定义自定义颜色似乎是一个简单的问题,但随着运行应用程序的执行环境变得越来越动态,我们必须让颜色适应这些由于不同的环境可能会继续增加的复杂性。