7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【SwiftUI】@Environmentでアプリ全体のテーマを切り替える

Last updated at Posted at 2025-12-26

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 からでも安全に参照できる」
という役割を持っています。

「変更する仕組み」ではなく「読むための仕組み」として理解すると、
他のプロパティラッパーとの違いも整理しやすくなります。

参考

7
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?