Logo
Overview

SwiftUIでアプリ全体のテーマを一括で切り替えたいなら`preferredColorScheme`を使ってはいけない件

December 23, 2022
2 min read

ColorScheme について

iOS アプリはダークモードとライトモードがあって、それが切り替えられます。なんですけど SwiftUI と UIKit で微妙に設定方法が違うのでそれについての備忘録です。

SwiftUI

preferredColorSchemecolorSchemeを View に対してくっつけることで有効化できます。

var body: some Scene {
WindowGroup {
ContentView()
.preferredColorScheme(.dark)
}
}

もしくは、

var body: some Scene {
WindowGroup {
ContentView()
.colorScheme(.dark)
}
}

ということになります。どちらも引数にColorSchemeを取ります。これは.dark.lightが指定できます。

が、現在は.colorSchemeは非推奨で.preferredColorSchemeの利用が推奨されています。じゃあこれは何が違うのかというと、colorSchemeは子 View にしか伝播しませんが、preferredColorSchemeは親にも伝播します。なので基本的にはアプリ内のどこか一箇所だけで使えば良いです。

ただし、注意点として.sheet.fullScreenCoverで表示した別 View から Toggle の値を切り替えると親 View には即座に反映されますが、自身には反映されません。これだと挙動としておかしいので、このままだといけないわけですね。

UIKit

UIKit の場合はUIUserInterfaceStyleで ColorScheme を指定します。ちなみにUIUserInterfaceStyleColorSchemeは全く互換性がありません。なんでこんなややこしいことにしたのかは謎です。

UIWindowUIViewControllerにはoverrideUserInterfaceStyleというプロパティが存在するのでこれを上書きしてしまえば指定した ColorScheme を反映されることができます。

一般的には指定されたUIViewControllerのテーマを変更するだけなのですがUIWindowsSceneに対して実行すれば全てのテーマを一気に変更することができます。

効果の範囲

子 View というのはとどのつまりNavigationLinkで遷移した先の View のことです。ややこしいのですが.sheet.fullScreenCoverは子 View ではなく別の View 扱いなのですが、親 View にも伝播するpreferredColorSchemeであれば反映されます。

colorSchemepreferredColorSchemeoverrideUserInterfaceStyle
子 View(NavigationLink)有効有効有効
親 View-有効有効
.sheet-有効*有効
.fullScreenCover-有効*有効
.present--有効
.confirmationDialog--有効
.alert--有効

*がついている箇所は呼び出した View 内で Toggle の値を切り替えても自身に即座に反映されない

つまり、上記のコードを利用してContentViewpreferredColorSchemeをつけたとしても.alertconfirmationDialogそしてUIHostingControllerUIViewControllerを使った別 View の描画方法には効かないことになります。

UIHostingControllerが効かないのはまあいいとして、.confirmationDialog.alertに対して効かないのは結構大きな問題だと思うんですけれど。

で、なんで効かないのかというと.confirmationDialog.alertは内部的にはUIAlertControllerを利用しているためだと思われます。なので SwiftUI で変更する方法ではダメなわけです。

なので一括でアプリ内の全てのテーマを変更したければUIWindowに対してoverrideUserInterfaceStyleを実行すれば良いです。

UIWindow

ルートのUIWindowを取得するコードは以下の通り。最近この辺りは Deprecated になっているものが多いので、特に理由もなく以下のコードをコピペすればよいです。

extension UIApplication {
public var window: UIWindow? {
UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first?.windows.first
}
}

これでアプリが利用しているUIWindowのうち、ルートのものがとってこれます。なので、

@main
struct DemoApp: App {
@AppStorage("APP.CONFIG.DARKMODE") var isDarkMode: Bool = false
var body: some Scene {
WindowGroup {
ContentView()
.onChange(of: isDarkMode, perform: { newValue in
UIApplication.shared.window?.overrideUserInterfaceStyle = newValue ? .dark : .light
})
}
}
}

なので例えば上のように@main内でUIApplication.shared.window?.overrideUserInterfaceStyle = isDarkMode ? .dark : .lightとすればContentView以下の全ての View でテーマの変更が有効になります。データを保存しておくためにどちらのテーマを利用しているかは@AppStorageに保存しておきましょう。

このとき.preferredColorSchemeを同時に使うとこちらの設定が優先されて.sheet.fullScreenCover内で Toggle を切り替えたときに変更が効かなくなります。ただこれだと、Toggle を切り替えたときにしかテーマの切り替えが効かなくなるので起動時にも反映されるようにします。

@main
struct DemoApp: App {
@AppStorage("APP.CONFIG.DARKMODE") var colorScheme: UIUserInterfaceStyle = .dark
var body: some Scene {
WindowGroup {
ContentView()
.onChange(of: isDarkMode, perform: { newValue in
UIApplication.shared.window?.overrideUserInterfaceStyle = newValue
})
.onAppear(perform: {
UIApplication.shared.window?.overrideUserInterfaceStyle = colorScheme
})
}
}
}
/// AppStorageにUIUserInterfaceStyleを突っ込めるようにする
extension UIUserInterfaceStyle: Codable {}
/// テーマを切り替えるToggle
struct ThemeToggle: View {
@AppStorage("APP.CONFIG.DARKMODE") var colorScheme: UIUserInterfaceStyle = .dark
var body: some View {
Toggle(isOn: Binding(get: {
colorScheme == .dark
}, set: { newValue in
colorScheme = newValue ? .dark : .light
}),
label: {
Text("DarkMode")
})
}
}

みたいな感じにすればこのトグルをどこに設置しても切り替えれば即座にアプリの全ての View のテーマが切り替わります。.preferredColorSchemeなんて使う必要なかったんだよなあ、うん。