Environmentとは
@Environment は SwiftUI で
アプリ全体で共有されている設定情報(環境値)を、各Viewから読み取るための仕組みです。
ここで重要なのは、
-
@Environment自体は「値を変更する仕組みではない」 - すでにどこかで設定された値を「読む」ためのもの
という点です。
SwiftUI にはあらかじめ多くの環境値(EnvironmentValues)が用意されており、
代表的なものとして以下があります。
- ダークモード・ライトモードの表示スタイル
- アクセシビリティの設定
- レイアウトやフォントに関する環境情報
まだまだありますが、これらは アプリ全体で共通して扱われる設定情報 であり、
SwiftUI が自動的に View 階層全体へ流してくれます。
SwiftUI では、このような 「どの View からも参照したい設定」を
@Environment を通して受け取る設計が推奨されています。
本記事では、
「表示モード(ライト / ダーク)を切り替えるアプリ」を例に、
@Environment がどのように使われるのかを確認します。
ポイントは次の2つです。
- 表示モードの 切り替え自体 は
preferredColorSchemeで行う - 各 View では
@Environmentを使って 現在の状態を読む
という役割分担になっています。
今回は、シンプルな ToDo アプリに
ライトモード・ダークモードの切り替え機能 を追加していきます。
前提条件
- Xcodeがインストールされていること
- SwiftUI の基本的な書き方(View /
@State)が分かること
1. Todoアプリ
まずは、テーマ切替なしの状態で
ToDo の追加・完了切替・削除ができるだけの、シンプルなアプリを作ります。
ここでは以下の点だけを押さえれば大丈夫です。
-
TodoItemは ToDo を表すシンプルなデータモデル -
@Stateで ToDo の配列を管理している - 追加・完了切替・削除ができる最低限の構成
テーマ切替とは直接関係ありませんが、
後から見た目を切り替えたときの変化が分かりやすい ため、
このアプリをベースにしています。
import SwiftUI
struct TodoItem: Identifiable, Hashable {
let id = UUID()
var title: String
var isDone: Bool
}
@main
struct TodoSampleApp: App {
var body: some Scene {
WindowGroup {
TodoListView()
}
}
}
struct TodoListView: View {
@State private var todos: [TodoItem] = []
@State private var newTitle: String = ""
var body: some View {
NavigationStack {
VStack(spacing: 12) {
inputArea
List {
ForEach(todos) { todo in
HStack {
Image(systemName: todo.isDone ? "checkmark.circle.fill" : "circle")
.foregroundStyle(todo.isDone ? .green : .gray)
Text(todo.title)
.strikethrough(todo.isDone)
.foregroundStyle(todo.isDone ? .secondary : .primary)
Spacer()
}
.contentShape(Rectangle())
.onTapGesture {
toggleDone(todo)
}
}
.onDelete(perform: delete)
}
}
.padding(.top, 8)
.navigationTitle("ToDo")
}
}
private var inputArea: some View {
HStack(spacing: 8) {
TextField("ToDoを入力", text: $newTitle)
.textFieldStyle(.roundedBorder)
Button("追加") {
addTodo()
}
.disabled(newTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.padding(.horizontal)
}
private func addTodo() {
let text = newTitle.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
todos.insert(.init(title: text, isDone: false), at: 0)
newTitle = ""
}
private func toggleDone(_ todo: TodoItem) {
guard let index = todos.firstIndex(of: todo) else { return }
todos[index].isDone.toggle()
}
private func delete(at offsets: IndexSet) {
todos.remove(atOffsets: offsets)
}
}
ここまでで、ToDoアプリとして最低限動く状態になりました。
Step 1. テーマ(ライト / ダーク)を表す enum を追加する
まずは、アプリ内で扱う「テーマ」を enum として定義します。
enum は、
関連する定数(固定された値)を名前付きでまとめて定義するための型 で、
コードの可読性・保守性を高め、エラーを防ぐために使われます。
enum を使うことで、
- 設定の選択肢を限定できる
- 文字列の打ち間違いを防げる
- switch 文で安全に分岐できる
といったメリットがあります。
今回は、アプリの表示テーマとして
「システムに従う / ライト / ダーク」の3種類を enum で表現します。
import SwiftUI
enum AppTheme: String, CaseIterable, Identifiable {
case system
case light
case dark
var id: String { rawValue }
var title: String {
switch self {
case .system: return "システムに従う" // nil の場合、SwiftUI がシステム設定に従う
case .light: return "ライト"
case .dark: return "ダーク"
}
}
var preferredColorScheme: ColorScheme? {
switch self {
case .system: return nil
case .light: return .light
case .dark: return .dark
}
}
}
この enum では、
-
title:設定画面に表示する文字列 -
preferredColorScheme:SwiftUI に渡すための値
をそれぞれ定義しています。
preferredColorScheme に nil を渡すと
「iPhoneのシステム設定(ライト/ダーク)に従う」挙動になります。
Step 2. Appのルートでテーマを保持し、アプリ全体に反映する
テーマは 特定のViewだけでなく、アプリ全体に影響させたい状態 です。
そのため、各Viewで @State として持つのではなく、App のルートで一元管理します。
ここで重要なのは、
アプリ全体の見た目を実際に切り替えているのは
.preferredColorScheme(...) であるという点です。
こうすることで、
- どの画面を開いても同じテーマが適用される
- 画面遷移をまたいでも状態が保たれる
という構成になります。
TodoSampleApp を次のように変更します。
@main
struct TodoSampleApp: App {
@AppStorage("app_theme") private var themeRawValue: String = AppTheme.system.rawValue
private var theme: AppTheme {
AppTheme(rawValue: themeRawValue) ?? .system
}
var body: some Scene {
WindowGroup {
TodoListView(
theme: theme,
onChangeTheme: { newTheme in
themeRawValue = newTheme.rawValue
}
)
// ここが肝:アプリ全体の見た目に適用する
.preferredColorScheme(theme.preferredColorScheme)
}
}
}
@AppStorage を使うことで、アプリを再起動しても選択したテーマが保持されます。
内部的には UserDefaults に保存されるため、アプリ再起動後も同じ設定が復元されます。
Step 3. 設定画面(テーマ選択)を追加する
次に「テーマ設定画面」を追加します。
import SwiftUI
struct ThemeSettingView: View {
let currentTheme: AppTheme
let onSelect: (AppTheme) -> Void
var body: some View {
NavigationStack {
List {
ForEach(AppTheme.allCases) { theme in
Button {
onSelect(theme)
} label: {
HStack {
Text(theme.title)
Spacer()
if theme == currentTheme {
Image(systemName: "checkmark")
}
}
}
}
}
.navigationTitle("テーマ設定")
}
}
}
Step 4. ToDo画面から設定画面を開けるようにする
ここで初めて @Environment が登場します。
@Environment(\.colorScheme) private var colorScheme
@Environment(\.colorScheme) を使うことで、
現在アプリに適用されている表示モード(ライト / ダーク) を
View 側で簡単に取得できます。
重要なのは、
-
@Environmentは 状態を変更しない - すでに
.preferredColorSchemeなどによって決まった結果をView 側で読み取るための仕組み
だという点です。
TodoListView を次のように変更します(差分が多いので全体を載せます)。
struct TodoListView: View {
@Environment(\.colorScheme) private var colorScheme
let theme: AppTheme
let onChangeTheme: (AppTheme) -> Void
@State private var todos: [TodoItem] = []
@State private var newTitle: String = ""
@State private var showSettings: Bool = false
var body: some View {
NavigationStack {
VStack(spacing: 12) {
inputArea
List {
Section {
ForEach(todos) { todo in
HStack {
Image(systemName: todo.isDone ? "checkmark.circle.fill" : "circle")
.foregroundStyle(todo.isDone ? .green : .gray)
Text(todo.title)
.strikethrough(todo.isDone)
.foregroundStyle(todo.isDone ? .secondary : .primary)
Spacer()
}
.contentShape(Rectangle())
.onTapGesture {
toggleDone(todo)
}
}
.onDelete(perform: delete)
} header: {
VStack(alignment: .leading, spacing: 4) {
Text("選択中: \(theme.title)")
Text("現在の表示: \(colorScheme == .dark ? "ダーク" : "ライト")")
.font(.caption)
.foregroundStyle(.secondary)
}
.textCase(nil)
}
}
}
.padding(.top, 8)
.navigationTitle("ToDo")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showSettings = true
} label: {
Image(systemName: "gearshape")
}
}
}
}
.sheet(isPresented: $showSettings) {
ThemeSettingView(currentTheme: theme) { selected in
onChangeTheme(selected)
showSettings = false
}
}
}
private var inputArea: some View {
HStack(spacing: 8) {
TextField("ToDoを入力", text: $newTitle)
.textFieldStyle(.roundedBorder)
Button("追加") {
addTodo()
}
.disabled(newTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.padding(.horizontal)
}
private func addTodo() {
let text = newTitle.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
todos.insert(.init(title: text, isDone: false), at: 0)
newTitle = ""
}
private func toggleDone(_ todo: TodoItem) {
guard let index = todos.firstIndex(of: todo) else { return }
todos[index].isDone.toggle()
}
private func delete(at offsets: IndexSet) {
todos.remove(atOffsets: offsets)
}
}
例:
まとめ
-
@Environmentは アプリ全体で共有されている設定を読む仕組み - 表示モードの切り替え自体は
.preferredColorScheme(...)で行う -
@Environment(\.colorScheme)を使うと、現在の状態を簡単に取得できる -
@AppStorageと組み合わせると、設定を永続化できて実用的
おわりに
@Environment は「どこかで設定された共通状態を、どの View からでも安全に参照できる」
という役割を持っています。
「変更する仕組み」ではなく「読むための仕組み」として理解すると、
他のプロパティラッパーとの違いも整理しやすくなります。
参考