iOS13暗黑模式适配

990 阅读11分钟

在 iOS 13.0 及更高版本中,用户可以选择启用深色外观(通常称为“暗色模式”)。

在暗色模式下,系统和应用会使用较暗的颜色来呈现界面,从而为用户在低光环境下提供更舒适的视觉体验。用户可以通过“设置”将暗色模式设为默认界面样式,甚至可以让设备根据环境光自动切换到暗色模式。暗色模式不仅仅是现代应用的一项视觉改进,更是为用户提供了更好的夜间使用体验和节能效果的关键功能。随着越来越多的用户倾向于启用暗色模式,开发者必须考虑如何在应用中无缝集成这一功能,同时保证在不同设备和系统版本上的一致性体验。

在支持暗色模式的过程中,开发者需要注意色彩和图像的选择,确保在浅色和深色模式下的可读性和美观性。同时,调试工具和动态更新方案的使用也是不可忽视的部分,这些技术手段能够帮助开发者更轻松地实现和优化暗色模式支持。

通过本文的介绍和示例代码,开发者可以深入理解如何在项目中高效地实现暗色模式,并根据自己的应用需求灵活调整实现方式。无论是新开发的应用,还是现有应用的改造,暗色模式的支持不仅是对用户体验的尊重,更是顺应现代应用设计趋势的明智之举。希望本文能为您的开发工作提供有益的参考,助您打造出色的暗色模式体验。

本文将详细描述如何在带有或不带有故事板的第三方应用程序中支持暗色模式,重点介绍实用的调试工具,并探讨如何在应用程序内部高效地实现暗色模式的动态更新。本文中的所有代码示例都可以在DarkMode项目中找到。这个小框架是我研究的成果,也是进行暗黑模式实验的地方。请随意打开它并使用示例。

注意:在本文中我将讨论 UIKit,而不是 SwiftUI。我研究的主要目标是实际用途和向后兼容性。

执行

暗色模式的实现基于特征集合。当用户更改系统外观时,系统会自动通知所有窗口和视图重新绘制其内容。UIKit 的大部分控件都原生支持暗色模式,无需额外的代码逻辑。接下来,让我们从颜色外观的配置开始。

颜色外观

为了在暗色模式下配置应用的颜色外观,您可以使用 Asset Catalog。这非常简单,只需创建一个新的颜色集,并为其配置浅色和深色两种模式下的颜色。如果您的应用需要支持高对比度模式,您还可以为每种颜色添加高对比度的颜色变体:

在故事板中,这些颜色在颜色选择期间可以在“命名颜色”部分中使用。在代码中,您只需通过给定的名称初始化颜色即可:

let view = UIView()
view.backdroundColor = UIColor(named: "Color")

注意:我建议我们使用代码生成工具,以防止重命名或重构后出现愚蠢的崩溃。

如果出于某种原因您不想使用 Asset Catalog,您可以直接通过UIColor.init(dynamicProvider:)初始化程序配置颜色。它根据特征集合属性返回不同的颜色。我添加了一个扩展来减少 SDK 版本检查:

import UIKit

public extension UIColor {

    /// Creates a color object that generates its color data dynamically using the specified colors. For early SDKs creates light color.
    /// - Parameters:
    ///   - light: The color for light mode.
    ///   - dark: The color for dark mode.
    convenience init(light: UIColor, dark: UIColor) {
        if #available(iOS 13.0, tvOS 13.0, *) {
            self.init { traitCollection in
                if traitCollection.userInterfaceStyle == .dark {
                    return dark
                }
                return light
            }
        }
        else {
            self.init(cgColor: light.cgColor)
        }
    }
}

顺便说一下,iOS 有一些默认颜色可以自动适应当前的特征环境:

let view = UIView()
view.backdroundColor = .systemRed

图像外观

在支持暗色模式的过程中,图像资源的管理同样至关重要。在浅色和深色模式下,某些图像(如图标和背景)可能需要使用不同的版本,以确保在不同背景下的可见性和美观性。通过使用 Asset Catalog,开发者可以方便地为每张图像配置浅色和深色版本,并让系统根据当前的界面样式自动选择最合适的图像。这样不仅简化了代码逻辑,还减少了手动切换图像的复杂度。

如果图像是从外部资源(如文件系统或网络)加载的,则建议使用自定义的图像资产扩展,以便根据用户界面样式动态选择合适的图像。通过这种方式,应用可以在运行时实时响应系统的界面样式变化,实现更为灵活的图像管理和展示。

Asset Catalog 中的相同逻辑也适用于图像:

在代码中照常使用它:

let imageView = UIImageView()
imageView.image = UIImage(named: "Image")

如果您想在运行时创建图像,例如从文件系统或服务器加载,则必须使用图像资产。此外,我还添加了一个扩展,使用两个图像初始化资产以获得不同的外观:

import UIKit

public extension UIImageAsset {

    /// 创建一个图像资产,并根据浅色和深色模式注册图像。
    /// - Parameters:
    ///   - lightModeImage: 在浅色模式下使用的图像。
    ///   - darkModeImage: 在深色模式下使用的图像。
    convenience init(lightModeImage: UIImage?, darkModeImage: UIImage?) {
        self.init()
        register(lightModeImage: lightModeImage, darkModeImage: darkModeImage)
    }

    /// 分别为浅色和深色模式注册图像。
    /// - Parameters:
    ///   - lightModeImage: 浅色模式下的图像。
    ///   - darkModeImage: 深色模式下的图像。
    func register(lightModeImage: UIImage?, darkModeImage: UIImage?) {
        register(lightModeImage, for: .light)
        register(darkModeImage, for: .dark)
    }

    /// 为指定的特征集合注册图像。
    /// - Parameters:
    ///   - image: 要注册的图像。
    ///   - traitCollection: 要与图像关联的特征集合。
    func register(_ image: UIImage?, for traitCollection: UITraitCollection) {
        guard let image = image else { return }
        register(image, with: traitCollection)
    }

    /// 返回最符合当前特征集合的图像。在早期 SDK 中会返回浅色模式的图像。
    func image() -> UIImage {
        if #available(iOS 13.0, tvOS 13.0, *) {
            return image(with: .current)
        }
        return image(with: .light)
    }
}

图层配置

与 UIView 不同,CALayer 并不会自动更新其颜色以适应暗色模式。因此,开发者需要手动处理这些图层的外观更改。在 UIView 或 UIViewController 中实现 traitCollectionDidChange(_:) 方法,可以检测到界面样式的改变,然后根据当前的样式手动更新图层的属性。通过这种方法,您可以确保自定义绘制的元素或动画在暗色模式下仍然保持正确的外观和一致的体验。

此外,考虑到特征集合可能因设备方向变化或其他系统设置而多次变化,合理使用 hasDifferentColorAppearance(comparedTo:) 方法可以避免不必要的重绘和性能开销。开发者应确保每次更新图层时,都是在真正需要的情况下进行的,从而保持应用的流畅和高效。通过仔细管理这些细节,您可以确保应用的所有视觉元素都与用户的界面设置保持一致。

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    super.traitCollectionDidChange(previousTraitCollection)
    traitCollection.hasDifferentColorAppearance(comparedTo: traitCollection) {
        layer.backgroundColor = UIColor.layer.cgColor
    }
}

在上述代码中,第 3 行是关键。特征集合可能由于多种原因而发生变化,例如当 iPhone 从纵向旋转到横向时。hasDifferentColorAppearance(comparedTo:)函数可以指示指定的特征集合与当前特征集合之间是否存在影响颜色值的变化。这能帮助我们避免不必要的重复绘制。

调试暗色模式

为了测试应用中的暗色模式外观,有多种方法可供选择。如果您使用的是故事板进行布局,可以通过设备配置窗格旁的界面样式切换器预览暗色模式:

Xcode 11 的一个有用功能是 Xcode Preview。可以通过附加配置将其用于基于 UIKit 的项目:

final class ViewController: UIViewController {
}

#if canImport(SwiftUI) && DEBUG

import SwiftUI

struct ViewControllerRepresentable: UIViewRepresentable {

    func makeUIView(context: Context) -> UIView {
        let storyboard = UIStoryboard(name: "Main", bundle: nil)
        let viewController = storyboard.instantiateViewController(withIdentifier: "ViewController")
        return viewController.view
    }

    func updateUIView(_ view: UIView, context: Context) {

    }
}

@available(iOS 13.0, *)
struct ViewController_Preview: PreviewProvider {

    static var previews: some View {
        Group {
            ViewControllerRepresentable()
                .colorScheme(.light)
                .previewDisplayName("Light Mode")
            ViewControllerRepresentable()
                .colorScheme(.dark)
                .previewDisplayName("Dark Mode")
        }
    }
}

#endif

通过使用colorScheme(_:),您可以同时预览浅色和深色模式下的界面::

如果你正在调试其中一种配色方案,那么在模拟器中修复暗色外观会很方便Preferences > Developer > Dark Appearance

如果这还不足以满足您的情况,您可以通过环境覆盖在应用程序会话期间覆盖界面样式:

正如我之前所说,特征集合在应用程序会话期间可能会更改很多次。您可以启用调试日志记录,以便轻松查看在您自己的类中何时调用 traitCollectionDidChange(_:)。使用以下启动参数打开日志记录:-UITraitCollectionChangeLoggingEnabled YES

当暗模式设置更新时,您会在控制台中看到如下消息:

2019-12-16 09:12:44.819195+0600 DarkModeExample[22611:3698294] [TraitCollectionChange] Sending -traitCollectionDidChange: to <DarkModeExample.ViewController: 0x7fdb81d0b7a0>
► trait changes: { UserInterfaceStyle: Light → Dark }
► previous: <UITraitCollection: 0x600002f98900; UserInterfaceIdiom = Phone, DisplayScale = 3, DisplayGamut = P3, HorizontalSizeClass = Compact, VerticalSizeClass = Regular, UserInterfaceStyle = Light, UserInterfaceLayoutDirection = LTR, ForceTouchCapability = Available, PreferredContentSizeCategory = L, AccessibilityContrast = Normal, UserInterfaceLevel = Base>
► current: <UITraitCollection: 0x600002f98840; UserInterfaceIdiom = Phone, DisplayScale = 3, DisplayGamut = P3, HorizontalSizeClass = Compact, VerticalSizeClass = Regular, UserInterfaceStyle = Dark, UserInterfaceLayoutDirection = LTR, ForceTouchCapability = Available, PreferredContentSizeCategory = L, AccessibilityContrast = Normal, UserInterfaceLevel = Base>

这种日志输出将帮助您准确定位何时以及为何触发了 traitCollectionDidChange(_:) 方法,从而更好地调试和优化应用的暗色模式适配。

动态更新暗黑模式

遗憾的是,iOS 并不支持在应用运行时动态更新单个应用的配色方案。不过,您可以通过一种简单的方法来实现这一功能,即保存用户选择的 UIUserInterfaceStyle,并在下一个应用会话中使用它。例如,您可以将用户的选择存储在 UserDefaults 中:

public extension UserDefaults {

    var overridedUserInterfaceStyle: UIUserInterfaceStyle {
        get {
            UIUserInterfaceStyle(rawValue: integer(forKey: #function)) ?? .unspecified
        }
        set {
            set(newValue.rawValue, forKey: #function)
        }
    }
}

为了应用保存的样式,您需要为所有应用窗口覆盖它们的界面样式。这些窗口可以通过 UIApplication.shared.windows 获得。请注意,自 iOS 13 以来,iPad 应用可能支持多个窗口,因此处理时需要考虑这种情况:

import UIKit

public extension UIApplication {

     func override(_ userInterfaceStyle: UIUserInterfaceStyle) {
         if supportsMultipleScenes {
             for connectedScene in connectedScenes {
                 if let scene = connectedScene as? UIWindowScene {
                     for window in scene.windows {
                          window.overrideUserInterfaceStyle = userInterfaceStyle
                     }
                 }
             }
         }
         else {
             for window in windows {
                 window.overrideUserInterfaceStyle = userInterfaceStyle
             }
         }
     }
 }

此外,不要忘记在创建新窗口时,也要覆盖它们的界面样式。完成覆盖后,标准视图和控件会自动更新其外观以匹配当前的界面样式。

注意:您可以考虑使用方法调换来减少配置工作量,但这可能会带来意外行为,因此在实现时需谨慎。我在示例代码中尽量避免使用方法调换,但在某些情况下,它可能是一个不错的选择。

您如何看待这种方法?您是否会在应用中实现这种功能?在您的观点中,应用程序是否应允许用户自行切换暗色模式,还是应该完全依赖系统的设置?

向后兼容性

在我们公司的项目开发中,我们通常会支持两个最新的 iOS 主版本。在撰写本文时,这两个版本是 iOS 12 和 以上。为了支持这些版本,最简单的方法就是在 iOS 12 设备上默认使用浅色模式,并在 iOS 13 及以上设备上支持暗色模式。

结论

本文分享了我在支持暗色模式方面的一些经验。我希望这些内容能够帮助您顺利地将暗色模式集成到现有应用程序中,或者为您的新应用程序开发提供一些启发。随着暗色模式的普及,我相信未来的应用程序将默认支持这一功能,而无需进行大量的设计和代码调整。

最后,我想提一下网络上对我写这篇文章有帮助的有用资料。别忘了查看DarkMode项目,了解 Dark Mode 的工作原理。

苹果

WWDC 2019 - 在 iOS 上实现暗黑模式
人机界面指南 - 暗黑模式人机界面指南 -界面中支持暗黑模式的
系统颜色

文章

暗黑模式:在 Swift 中为你的应用添加支持 - SwiftLee
iOS 13 上的暗黑模式 - NSHipster
在 iOS 上采用暗黑模式并确保向后兼容性 - PSPDFKit 内部

Github

aaronbrethorst/SemanticUI:iOS 13 语义化 UI:暗黑模式、动态类型和 SF 符号
noahsark769/ColorCompatibility:使用 iOS 13+ 系统颜色,同时在 iOS <=12 上默认使用浅色

插件

Sketch 的色彩系统插件 - Product Hunt
Lights - 明暗模式 - Figma

希望这篇文章对您有所帮助,祝您在实现暗色模式的过程中顺利愉快!

作者:洞窝-立冬