【SwiftUI】ダークモードに対応する

はじめに

今回はSwiftUIでダークモードに対応するやり方について紹介したいと思います。

自作アプリでやってみたところ意外とあっさりできたので、「まだ対応してないよー」という方がいましたら、この記事が契機となれば幸いです。

※当記事では「ダークモードとはなんぞや?」という点に関しては説明しておりません。端的に流れを追って、アプリに対応させるための参考となる手順のみを記載しております。

開発環境:Xcode 12.4 / Swift 5.3.2、 対応OS:iOS13+

Color Setを用意する

まずはじめに、XcodeのAsset CatalogにColor Setを作成します。

Asset Catalogは皆さんご存知のとおり、アプリアイコンやその他の画像、色などのリソースを管理するためのもので、ここに色リソースであるColor Setを追加することで、それだけでライトモード時、ダークモード時でそれぞれ色の出し分けを行ってくれるリソースを作成することができます。

画面で見ていきましょう。


まず、プロジェクトにColors.xcassetsファイルを追加しています。

プロジェクト作成時についてくるAssets.xcassetsにColor Setを作成してももちろんいいのですが、色が多くなるとアプリアイコンなど他のリソースとごっちゃになって見づらくなるので、それ専用に作成したほうが管理がしやすいかと思い、こうしています。


Colors.xcassetsをクリックして開いたら、白い枠の中で右クリック、もしくは画面下(画像では途切れて見えていませんが)にある「+」ボタンをクリックして、Color Setを選択します。


Viewの背景色用のColor Setとして「Background」を作りました(既に他にもいくつか作られていますが)。
これをクリックし、赤枠のAppearancesから「Any, Dark」を選択します。

ちなみに、選択肢の中には「Any, Light, Dark」もありますが、勉強不足にもこの場合のAnyがどういう条件で当てはまるのかがわからないため、当記事では取り扱いません💦

また、Appearancesの上下にあるDevicesやHigh Contrastで、デバイスやハイコントラス設定のON/OFF(設定アプリの「アクセシビリティ > 画面表示とテキストサイズ > コントラストを上げる」で切り替えが可能)によっても色の出し分けを行うことができます。
アプリ要件により必要な場合はチェックをして、それぞれ色の指定を行ってください。


Any Appearanceの枠をクリックし、ライトモード時(ダークモード以外の時)の色を設定します。


Dark Appearanceの枠をクリックし、ダークモード時の色を設定します。

これで1つのColor Setが作成できました。
これをUIのコード中で使用することで、ライトまたはダークで処理分岐などする必要もなく色の出し分けが可能になります。

ここではサンプルとして、↑のような感じでColor Setを作成しました。

※今回の記事では扱いませんが、Asset CatalogではImage Set(画像)やSymbol Image Set(SVG画像)を作成することもでき、Color Set同様にモードで出し分けする設定ができますので、必要であれば試してみてください。

Color Setを定数化する

作成したColor Setをコードで使うには以下のようにします。

// Using the Color Set as UIColor
let color = UIColor(named: "Background")!

// Using the Color Set as SwiftUI.Color
let color = Color("Background")

それぞれイニシャライザにColor Setのリソース名を渡すだけです。
UIColorはinit?とオプショナルなイニシャライザとなっているので、末尾に ! をつけています。

ちなみに、Color Setを使わずコードで動的な色を作成する場合は以下のようにします。

// Make a dynamic color
let uiColor = UIColor { (traitCollection) -> UIColor in
    switch traitCollection.userInterfaceStyle {
        case .dark:
            return .black
        default:
            return .white
    }
}

// Color has currently no direct initializer, so use the color made by init(dynamicProvider:) of UIColor such as above example
let color = Color(uiColor)

上記のとおり、UIColorまたはColorのイニシャライザにColor Setのリソース名を渡すことでこれを使用することができますが、登場してくるViewのたびにこれをやっていたら書くのが面倒ですし、リソース名に変更が生じた場合に修正が大変になるので、Extensionを使って定数化します↓
(これは絶対ではないので、他に効率的なやり方があればそうしてください。というか教えて下さい🙈)

import UIKit
import SwiftUI

extension UIColor {
    static let navigationBarTint = UIColor(named: "NavigationBarTint")!
    static let navigationBarTitle = UIColor(named: "NavigationBarTitle")!
}

extension Color {
    static let background = Color("Background")
    static let text = Color("Text")
    static let button = Color("Button")
    static let buttonLabel = Color("ButtonLabel")
    static let redOrGreen = Color("RedOrGreen")
    static let navigationBarTitle = Color(UIColor.navigationBarTitle)
}

UIを作る

色の用意ができたらViewを作ります。

ここではサンプルとして、以下のようなViewを用意しました↓

struct ContentView: View {

    static let colorSchemeTitle: [ColorScheme: String] = [
        .light: "LIGHT",
        .dark: "DARK",
    ]

    // The environment value "colorScheme" is used to identify what current mode is.
    @Environment(\.colorScheme) private var colorScheme: ColorScheme

    @State private var isPresented: Bool = false

    var body: some View {
        NavigationView {
            VStack(alignment: .center, spacing: 16) {
                Spacer()

                HStack(alignment: .center, spacing: 8) {
                    Text("Current color scheme is")
                    Text(Self.colorSchemeTitle[colorScheme]!)
                        .padding(4)
                        .background(Color.text.opacity(0.2))
                }
                .foregroundColor(.text)

                Button {
                    self.isPresented.toggle()
                } label: {
                    Text("Show Alert")
                        .foregroundColor(.buttonLabel)
                        .padding()
                        .background(Color.button)
                }
                .alert(isPresented: self.$isPresented) {
                    Alert(title: Text("Hello, Dark Mode!"), dismissButton: .default(Text("OK")))
                }

                Spacer()
            }
            .navigationBarTitle("Title", displayMode: .inline)
            .navigationBarItems(trailing: Image(systemName: "questionmark.circle").foregroundColor(.navigationBarTitle))
        }
    }
}

Viewを作ったら、あとは.foregroundColor(.text).background(Color.button)のように、いつものように使うだけです。

また、NavigationViewにも色を付けるため、UINavigationBar.appearance()を使って設定を行います(AppDelegateなど、どこか任意の場所に書いてください)↓

let appearance = UINavigationBar.appearance()
appearance.barTintColor = .navigationBarTint
appearance.backgroundColor = .navigationBarTint
appearance.titleTextAttributes = [
    .foregroundColor: UIColor.navigationBarTitle,
]

appearanceを使いたくない場合は、独自にナビゲーションバーをカスタマイズする必要があります。
当サイトの以下の記事が参考になるかと思いますので、よければ合わせてご覧ください。
【SwiftUI】ナビゲーションバーを完全カスタマイズ

これを実際に動かした時の画面は以下のようになります↓

ライトモード時

ダークモード時

素晴らしい 👍

外観モードを固定する

ここまででダークモード対応はほとんど完成と言ってもいいのですが、やはりアプリにおいては「システムの設定に関わらずライトもしくはダークに外観を固定したい」という要件が出てくるかと思います。

これを満たす一番手っ取り早い方法は、UIWindowのoverrideUserInterfaceStyleプロパティを書き換えることだと思います。

SceneDelegateにwindowを作る箇所がありますので、そこでセットします↓

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

        // Create the SwiftUI view that provides the window contents.
        let contentView = ContentView()

        // Use a UIHostingController as window root view controller.
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.overrideUserInterfaceStyle = .dark // <- Here!
            window.rootViewController = UIHostingController(rootView: contentView)
            self.window = window
            window.makeKeyAndVisible()
        }
    }

    (以下略)

UIWindow, UIViewController, UIViewはそれぞれ、overrideUserInterfaceStyleというプロパティを持っています。これにUIUserInterfaceStyle列挙型のいずれかの値(light, dark, unspecified)をセットすると、それ自身と子のUI要素に対し外観モードを指定することができます。

上記例ではdarkをセットしていますので、ここで作られるwindowとその上に乗っかるUI要素はすべて、システムの設定に関わらずダークモードで固定表示となります。

unspecifiedはoverrideUserInterfaceStyleプロパティのデフォルトの値で、「指定なし = システムの設定に合わせる」となります。

ここでは例のためdarkを直値でセットしていますが、これをUserDefaultsなどに保持したユーザが選択した値を入れるようにすれば、ユーザの好みで「システムに合わせる」「ライトモード固定」「ダークモード固定」が実現できます。

設定を変えたらすぐ反映させたい

外観モードの固定化はできたけど、上記のコードではアプリを再起動しないと設定が反映されません。

やはりアプリに置いては、設定画面等でユーザが選んだそのタイミングですぐ外観が変わるようにしたいものです。

「じゃあどうしよう?」

「Combineで監視&更新でしょ!」

ということで、次のようにしてみます。
(これに関しては人やアプリの要件によりいろいろ書き方があると思いますので、あくまで一例としてご覧ください)

import UIKit
import SwiftUI

class ContentViewModel: ObservableObject {
    @Published var userInterfaceStyle: UIUserInterfaceStyle = .unspecified
}

↑ViewModelを作りーの、

import UIKit
import SwiftUI
import Combine

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    @ObservedObject private var model = ContentViewModel()

    private var cancellables = Set<AnyCancellable>()

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

        // Create the SwiftUI view that provides the window contents.
        let contentView = ContentView()
            .environmentObject(model)

        // Use a UIHostingController as window root view controller.
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: contentView)
            self.window = window
            window.makeKeyAndVisible()
        }

        // Observe the userInterfaceStyle property changes to switch the appearance mode.
        model.$userInterfaceStyle.sink { [weak self] style in
            self?.window?.overrideUserInterfaceStyle = style
        }
        .store(in: &cancellables)
    }

    (以下略)

↑作ったViewModelをContentViewに渡しーの、ViewModelのuserInterfaceStyleをsinkしーの、windowのoverrideUserInterfaceStyleを即時反映させるようにしーの・・・、

struct ContentView: View {

    static let userInterfaceStyleTitle: [UIUserInterfaceStyle: String] = [
        .unspecified: "UNSPECIFIED",
        .light: "LIGHT",
        .dark: "DARK",
    ]

    static let colorSchemeTitle: [ColorScheme: String] = [
        .light: "LIGHT",
        .dark: "DARK",
    ]

    // The environment value "colorScheme" is used to identify what current mode is.
    @Environment(\.colorScheme) private var colorScheme: ColorScheme

    @EnvironmentObject private var model: ContentViewModel

    @State private var isPresented: Bool = false

    var body: some View {
        NavigationView {
            VStack(alignment: .center, spacing: 16) {
                Spacer()

                HStack(alignment: .center, spacing: 8) {
                    Text("Current ui style is")
                    Text(Self.userInterfaceStyleTitle[model.userInterfaceStyle]!)
                        .padding(4)
                        .background(Color.text.opacity(0.2))
                }
                .foregroundColor(.text)

                HStack(alignment: .center, spacing: 8) {
                    Text("Current color scheme is")
                    Text(Self.colorSchemeTitle[colorScheme]!)
                        .padding(4)
                        .background(Color.text.opacity(0.2))
                }
                .foregroundColor(.text)

                /**
                 * Appearance Mode Changer
                 */
                HStack(alignment: .center, spacing: 8) {
                    Button {
                        self.model.userInterfaceStyle = .light
                    } label: {
                        Text("Light")
                            .foregroundColor(.buttonLabel)
                            .padding()
                            .background(Color.button)
                    }

                    Button {
                        self.model.userInterfaceStyle = .dark
                    } label: {
                        Text("Dark")
                            .foregroundColor(.buttonLabel)
                            .padding()
                            .background(Color.button)
                    }

                    Button {
                        self.model.userInterfaceStyle = .unspecified
                    } label: {
                        Text("Unspecified")
                            .foregroundColor(.buttonLabel)
                            .padding()
                            .background(Color.button)
                    }
                }

                Button {
                    self.isPresented.toggle()
                } label: {
                    Text("Show Alert")
                        .foregroundColor(.buttonLabel)
                        .padding()
                        .background(Color.button)
                }
                .alert(isPresented: self.$isPresented) {
                    Alert(title: Text("Hello, Dark Mode!"), dismissButton: .default(Text("OK")))
                }

                Spacer()
            }
            .navigationBarTitle("Title", displayMode: .inline)
            .navigationBarItems(trailing: Image(systemName: "questionmark.circle").foregroundColor(.navigationBarTitle))
        }
    }
}

↑ContentViewを改造しーの、出来上がりーの↓

最初のUNSPECIFIEDの状態だと、システムのモードを切り替えるとViewの外観も切り替わる。続けてDARKにすると、システムをどちらにしようとダークモードの外観固定になる。LIGHTもライトモードで同様。

バッチリですね 👏

ということで、ダークモード対応はこれにて完了となります。

部分的に外観モードを固定する

ひょっとすると、要件によってはUIのある部分だけはダークモード(またはライトモード)にしたくない、なんてことがあるかもしれません。

その場合の対応についても記載したいと思います。

まず、UIViewControllerやUIViewに関しては、上記UIWindowと同様にoverrideUserInterfaceStyleプロパティを変更することで固定化可能です。(ので、割愛します)

SwiftUIのView上でモードを指定する場合は、Viewが持つcolorSchemeメソッドまたはpreferredColorSchemeメソッドを使用します。

この2つの違いは、前者はそれを指定したViewとその子孫Viewに影響し、後者はそれを指定した先祖・祖孫すべてに伝搬し影響を与えることです。

要するに、colorScheme = UIViewのoverrideUserInterfaceStyleを変更、preferredColorScheme = UIViewController(始祖)のoverrideUserInterfaceStyleを変更とイメージしていただけるとわかりやすいかと思います。

試してみます。まずはcolorSchemeから↓

struct ContentView: View {

    var body: some View {
        NavigationView {
            VStack(alignment: .center, spacing: 16) {
                Spacer()

                Text("ContentView")
                    .foregroundColor(.text)
                    .padding(4)
                    .background(Color.text.opacity(0.2))

                // The background of this text will be colored Red or Green depends on the system setting.
                Text("Unspecified")
                    .padding()
                    .background(Color.redOrGreen)

                // The background of this text always will be colored Red.
                Text("Fixed to Light")
                    .padding()
                    .background(Color.redOrGreen)
                    .colorScheme(.light)

                // The background of this text always will be colored Green.
                Text("Fixed to Dark")
                    .padding()
                    .background(Color.redOrGreen)
                    .colorScheme(.dark)

                Spacer()
            }
            .navigationBarTitle("Title", displayMode: .inline)
            .navigationBarItems(trailing: Image(systemName: "questionmark.circle").foregroundColor(.navigationBarTitle))
        }
    }
}

上で作った「ライトモード時は赤、ダークモード時は緑」なColor Setを使って、それぞれcolorScheme無指定、colorScheme(.light)、colorScheme(.dark)の3つのTextを置いています。

これを実行して、システム設定の外観モードを切り替えたのがこちら↓

ライトモード時
ダークモード時

colorSchemeでlightもしくはdarkを指定したViewだけ変わっていないことが確認できます。

続いてpreferredColorScheme↓

struct ContentView: View {

    @State private var isPresented: Bool = false

    var body: some View {
        NavigationView {
            VStack(alignment: .center, spacing: 16) {
                Spacer()

                Text("ContentView")
                    .foregroundColor(.text)
                    .padding(4)
                    .background(Color.text.opacity(0.2))

                Button {
                    self.isPresented.toggle()
                } label: {
                    Text("Show Sheet")
                        .foregroundColor(.buttonLabel)
                        .padding()
                        .background(Color.button)
                }
                .sheet(isPresented: self.$isPresented) {
                    NavigationView {
                        VStack(alignment: .center, spacing: 16) {
                            Spacer()

                            Text("SubView")
                                .foregroundColor(.text)
                                .padding(4)
                                .background(Color.text.opacity(0.2))

                            Spacer()
                        }
                        .navigationBarTitle("Title", displayMode: .inline)
                    }
                }

                Spacer()
            }
            .preferredColorScheme(.light)
            .navigationBarTitle("Title", displayMode: .inline)
            .navigationBarItems(trailing: Image(systemName: "questionmark.circle").foregroundColor(.navigationBarTitle))
        }
    }
}

ボタンを押したらsheetで別画面を表示するようにしています。

元画面にpreferredColorScheme(.light)を指定し、システムの設定をダークモードにして実行するとこうなりました↓

元画面はナビゲーションバーまでちゃんとライトモードに固定されているのに対し、sheetで呼び出した別画面はpreferredColorSchemeの影響が及んでおらずダークモード表示になっています。

ちなみに、上記のUIWindowに外観モードを指定した例ではsheetを使っていませんが、ちゃんとsheetの別画面まで影響が及びます。

何はともあれ、今回はこれにて以上となります。

参考リンク

・Apple Developer Documentation – Supporting Dark Mode in Your Interface
https://developer.apple.com/documentation/xcode/supporting_dark_mode_in_your_interface

↓ダークモード周りのあれこれについて非常に詳しく書いてあります。🙇‍♂️
・FIVE STARTS – How To Adopt Dark Mode In Your iOS App 🌙
https://www.fivestars.blog/articles/ios-dark-mode-how-to/

おわりに

ダークモード対応をしてわかったこと。
それは、いかにデザイナーさんの存在がありがたいかということ。

自作アプリのダークモード対応を行って、コードの修正自体は上でも言っているように割と簡単だったのですが、何が難しいかって、単純に用意する色が2倍になるってことです。

配色を考えるのがとにかく難しい。
しかもハイコントラスト設定なんて入れたらさらに倍ですから、自分でやったらどれだけ時間がかかることやら……。

「俺、この自作アプリが売れたらデザイナーさんにお仕事を依頼するんだ!」(死亡フラグ)

なので、もしよろしければ右のリンク等から「積み録」を触ってみてください。

最後に宣伝ですみません。

それでは。