※この記事は iOSDC Japan 2024 に寄稿したパンフレット記事です。
はじめに
ご存知の通り、SwiftUIはState-Drivenのアプリフレームワークです。それはすなわち「State」、つまり「状態」の管理が非常に重要だということです。ところがその状態管理に、多くの人は@Stateや@Bindingを使っていることが多いと見受けますが、@Environmentを活用している人はまだ少ないように感じます。この記事は、そんな@Environmentを布教したいと考え、執筆に至りました。
ちなみに、@Environmentといっても、実はこれは2種類あって、最初のSwiftUIから使える@Environment(\.keyPath)のものと、iOS 17から使えるようになった@Environment(ObservableType.self)のものです。本記事では敢えて前者のみ扱い、特別に言及しない限り@Environmentといえば@Environment(\.keyPath)のことを指します。
@Environment(ObservableType.self)を敢えて扱わないのはいくつか理由があります。内訳はまた本記事の最後に述べさせていただきたいですが、根本的にはこのObservableTypeの方にしかないメリットが全く感じないからです。両方使っていい場面もあれば、KeyPathを使った方がいい場面もありますが、逆にObservableTypeを使った方がいい場面が一つもない上、むしろ使い方をミスるとビルド時に検出できずランタイムエラーでクラッシュまでします。だったらもうKeyPathだけ使えばええんちゃう?と思ってしまうのです。
@Environmentとは
@Environmentは、状態を環境変数として扱うためのプロパティラッパーです。実は普段のアプリ開発で意識せずに使ってる人も多いかなと思います。例えば、@Environment(\.colorScheme)を使えば、今がダークモードかライトモードかを取得できます。また、@Environment(\.locale)を使えば、端末のロケールを取得できます。他にも色々ありますがここで割愛します。このように、@Environmentは実は非常に身近な存在なのです。
でも、@Environmentがこれだけだと思ってしまっては困ります!実はこの@Environment、うまく活用すれば非常に強力なツールになります!
@Environmentの状態伝搬のイメージ
@Environmentの活用法を紹介する前に、まずは基礎を押さえておきたいと思います。我々はどんなときに@Environmentを使いたくなりますか。@Environmentはどんな課題を解決しようとしているのか。
通常の@Stateなどの場合、状態の伝搬は明示的にイニシャライザーを通じて伝播していきます。例えば下記のようなカウンターを表示するコードを考えてみましょう。
struct CountView: View {
var count: Int
var body: some View {
Text("\(count)")
}
}
struct ContentView: View {
@State private var count = 0
var body: some View {
VStack {
CountView(count: count)
Button("Increment") {
count += 1
}
}
}
}
このコードでは、CountViewが必要とするcount状態は、親のContentViewが@Stateとして保持し、そして子のCountViewを生成する際にその値をが渡されます。このように、@Stateは親から子へと状態を伝播していくわけです。
ところが、もしContentViewが直接CountViewを持つわけではなく、間に別のビューが挟まる場合、この状態の伝搬は少し面倒になります。例えば下記のような構成を考えてみましょう。
struct ChildView: View {
var body: some View {
CountView(count: /* ここに何を書く? */) // ここでcountを渡したい
}
}
struct ContentView: View {
@State private var count = 0
var body: some View {
VStack {
ChildView() // ここでCountViewではなくChildViewを持つ
// ...
}
}
}
そうです、正攻法でやると、孫のCountViewにcountを渡すためには、間の子のChildViewもcountを持つ必要があります。
struct ChildView: View {
var count: Int // ここにcountを保持する
var body: some View {
CountView(count: count)
}
}
なんだ大したことないじゃん、と思うかもしれませんが、今回はあくまで仕組み紹介のために単純化した例です。実際の開発では、間に挟まってるビューが一つだけとは限らないし、どこにどれだけの状態を必要としているのかもわかりません。そうなると、状態の伝搬は非常に面倒になります。ここで@Environmentの登場です。
@Environmentは、状態を環境変数として自動的に伝搬してくれるプロパティラッパーです。@Environmentを使えば、直接の子供だけでなく、自分の孫、ひ孫、玄孫、……といった感じで、どこまで行っても状態が伝搬されるのです。これにより、どの子供がどの状態を必要としているのかを意識せずに、状態を伝搬させることができるのです。先ほどの@Environment(\.colorScheme)を思い出してみましょう。これがまさにアプリの大元が持っているカラースキーム状態を、アプリ内の全てのビューに伝搬させている例です。
@Environmentのメリット
筆者が思う@Environmentのメリットは、主に3つあると考えています。
-
状態の伝搬が楽
先ほども述べた通り、
@Environmentを使えば、状態の伝搬を自動で行います。これにより、間にビューを挟んでも状態を直接渡す必要がなく、コードがシンプルになります。 -
共有されるのはGetterのみ
@Environmentは、基本的にGetterのみ公開されます。これにより、状態の変更は状態を保持しているビューでのみ行われるため、状態変更のトレースが行いやすいです。 -
必ず値が存在する
もし
@Environmentで指定した状態の型がOptionalじゃなければ、値が必ずあるので、実行時に値が存在しないことによるクラッシュが発生しません。
@Environmentのデメリット
もちろん@Environmentも万能ではありません、デメリットもあります。
-
状態が必ずモジュール全体に渡って共有されます
実際のアプリ開発では、例えば特定の機能だけに関係するビューに特定の状態を共有したい、もしくは単純に特定のビューの子供にだけ特定の状態を共有したい、といったことも多々あると思います。ところが残念ながら、
@Environmentはあくまで「環境変数」の一種なので、使い出したらその状態は必ずモジュール全体がアクセス可能になってしまいます。 -
…他にあるかな?
ぶっちゃけ、上記のように狭い範囲での状態共有に不向き以外、デメリットあまりない気がしますよね…
@Environmentの基礎的な使い方
というわけで、@Environmentの得手不得手を理解したところで、実際に使い方を見ていきましょう。
カスタム@Environmentの作り方
まずは、@Environmentを使うために、カスタム@Environment環境変数を作る方法を見ていきます。@Environmentが値の読み書きできるのは、EnvironmentKeyというプロトコルを満たす型を使うことで実現しています。なので、まずはEnvironmentKeyを満たす型を作ります。今回は例としてカウンターを表示するので、countを扱うCountKeyという型を作ってみましょう。
struct CountKey: EnvironmentKey {
static var defaultValue: Int = 0
}
CountKeyは、defaultValueというプロパティを持っています。これは何も設定されてない時に返されるデフォルト値です。今回はInt型のデフォルト値を0にしています。必要に応じてここでOptional型にしたり、もしくはOptionalにせず呼ばれた時アサートを入れるなどもできます。
次に、このCountKeyを使って、@Environmentを作ります。
extension EnvironmentValues {
var count: Int {
get { self[CountKey.self] }
set { self[CountKey.self] = newValue }
}
}
EnvironmentValuesは、@Environmentで使うためのプロパティをアクセスする型です。このEnvironmentValuesに、countというプロパティを追加することで、@Environmentで\.countというKeyPathが使えるようになります。そしてこのcountは、CountKeyを使って値の書き込みと読み取りができるようになります。
上記のコードはXcode 15までの実装ですが、執筆締め切り現在の2024年6月20日では、Xcode 16 Betaからは@Entryを使ってより簡単にカスタム@Environmentを作れるようになりました。@Entryを使った\.countの実装は下記のようになります。
extension EnvironmentValues {
@Entry var count: Int = 0
}
そうです、これだけです。@Entry Macroのおかげで、EnvironmentKeyの定義やデフォルト値の設定、そしてEnvironmentValuesへプロパティー追加する時のGetterとSetterの実装がすべて自動的に行うようになったので、非常に簡単にカスタム@Environmentを作れるようになりました。またこの機能はあくまでSwiftの言語機能として追加されたものなので、Xcode 16以降のバージョンであれば、iOS 18以前でも使えて、とても便利です。
ただしあくまでBeta機能なので、正式リリースされる時には具体的な実装が変わる可能性があります。その際は公式ドキュメントを参照してください。
@Environmentを使った状態の書き込み方
\.countという@Environment用のKeyPathを作りましたので、値の書き込みは.environment(_:_:) Modifierが使えます。このModifierは2つの引数を取ります。1つ目の引数は@Environment用のKeyPathで、2つ目の引数は書き込む値です。例えば今回は\.countにカウントを書き込むので、先ほどのカウンターの表示の例を流用するとこんな風に使えます。
struct ContentView: View {
@State private var count = 0
var body: some View {
VStack {
ChildView()
// ...
}
.environment(\.count, count) // ←ここでcountを書き込む
}
}
これで、VStack以下の全てのビューに\.count環境変数をcountに設定しました。
@Environmentを使った状態の読み取り方
次は環境変数の読み取り方ですが、これは@Environment(\.keyPath)の形で使うので、\.countを読み取るなら@Environment(\.count)と書けばOKです。同じく先ほどのカウンターの表示の例を流用すると、CountViewで\.countを読み取るならこんな感じです。
struct CountView: View {
@Environment(\.count) var count : Int //←型の宣言は省略可能です
var body: some View {
Text("\(count)")
}
}
ここで注意して欲しいのは、\.count環境変数をセットしたのはContentViewで、ContentViewが直接持つ子供ChildViewはこの\.countについて一切意識していないということです。しかしCountViewはChildViewの子供なので、つまりCountViewはContentViewの孫にあたります。だからCountViewはContentViewの\.count環境変数を読み取ることができるのです。
@Environmentで指定した環境変数の型は、KeyPath指定した時点で既に決まっているため、基本的に型の宣言は任意です。筆者の場合は、型を宣言することで、コードの可読性が上がると感じているのと、そもそも他のプロパティーと違って型宣言を省略するのは気持ち悪く感じているので、基本的には型宣言をしています。
全部のコードを載せるとこんな感じです。
struct CountKey: EnvironmentKey {
static var defaultValue: Int = 0
}
extension EnvironmentValues {
var count: Int {
get { self[CountKey.self] }
set { self[CountKey.self] = newValue }
}
}
/* Xcode 16以降の場合、上記のすべての実装は下記のように簡潔にまとめられます。
extension EnvironmentValues {
@Entry var count: Int = 0
}
*/
struct ContentView: View {
@State private var count = 0
var body: some View {
VStack {
ChildView()
Button("Increment") {
count += 1
}
}
.environment(\.count, count)
}
}
struct ChildView: View {
var body: some View {
CountView()
}
}
struct CountView: View {
@Environment(\.count) var count: Int
var body: some View {
Text("\(count)")
}
}
@Environmentのより高度な使い方
ここまでで、@Environmentの基礎的な使い方を見てきました。しかし、筆者がこれだけ@Environmentを推したいのはもちろんこれだけではありません。これからは、@Environmentのより高度な使い方を見ていきましょう。
KeyPathの連結
@EnvironmentはKeyPathを受け取るってことは、当然ながらKeyPathを連結したKeyPathも使えるわけです。これは複雑な状態の塊から、必要な部分だけを取り出したいときに非常に便利です。例えばCGRectの環境変数があって、その中のmidXだけを使いたい時と考えましょう。もしこの連結を知らなかったら、こんな風に書いているのではないかと思います:
struct ContentView: View {
@Environment(\.rect) var rect: CGRect
var midX: CGFloat { rect.midX }
// ...
}
しかしKeyPathの連結を使えば、こんな風に書けます:
struct ContentView: View {
@Environment(\.rect.midX) var midX: CGFloat
// ...
}
同じ環境変数にビューヒエラルキーに応じて違う値を混在させる
@Environmentを使えば、状態を環境変数としてアプリ全体に共有することになるから、アプリ全体が一つだけの状態を共有することになると思うかもしれませんが、実は違います。@Environmentの状態はビューヒエラルキーで継承されており、すなわち途中で書き換えれば、そのビューヒエラルキー以下のビューにだけその書き換わった値が適用されるということです。
実はこの特性は、無意識のうちに使っている人も多いではないかと思います。画面遷移の後、前の画面に戻る時は@Environment(\.dismiss)を使っているではないでしょうか。そうです、この\.dismissはまさにこの特性を利用しています。何重もの画面遷移をしても、\.dismissを使えば、ちゃんと自分自身だけが消えて他のビューはそのまま残るのは、まさに自分自身が読み取った\.dismiss環境変数の値が自分自身に合わせて書き換えられたものだからです。
ではこの特性を使って、ビューヒエラルキーに応じて違う値を混在させる例を見てみましょう。
struct CopyrightKey: EnvironmentKey {
static var defaultValue: String = "@lovee"
}
extension EnvironmentValues {
var copyright: String //...省略
}
struct CopyrightView: View {
@Environment(\.copyright) var copyright: String
var body: some View {
Text(copyright)
}
}
struct ContentView: View {
@Environment(\.copyright) var copyright: String
var body: some View {
VStack {
Text(copyright)
CopyrightView()
CopyrightView()
.environment(\.copyright, "星野恵瑠")
}
.environment(\.copyright, "el-hoshino")
}
}
上記のコードで、ContentViewを表示したら、このような結果になるでしょう。
@lovee
el-hoshino
星野恵瑠
さて、なぜこうなるかを具体的にみていきましょう。
まずは最後のCopyrightViewを見ましょう。このビューに直接.environmentで\.copyrightを星野恵瑠にセットしましたので、このビューは星野恵瑠を表示します。
次にその上のCopyrightViewを見ましょう。このビューは特に何もセットされていません。なので自分自身の親、すなわちVStackを見ます。VStackには.environmentで\.copyrightをel-hoshinoに設定されているので、このビューはel-hoshinoを表示します。
最後に一番上のText(copyright)を見ましょう。このビューは直接ContentViewが持つ\.copyrightを読み取っています。ContentViewはVStackに対して\.copyrightをセットしましたが、逆にいうと自分自身には何もセットしていないし仕組み上できないので、デフォルト値の@loveeが表示されます。
このように、ビューヒエラルキーに応じて違う値を混在させることができるのが@Environmentの特性です。この特性を活かせば、幅広い表現ができるでしょう。例えば特定のビューヒエラルキーにだけダークモードだけ有効にしたい時、そのビューに.environment(\.colorScheme, .dark)をセットすれば実現できます。
実はこの.environment(_:_:)の他に、.transformEnvironment(_:transform:)というModifierもあります。このModifierを使うと、transform引数は環境変数を変換するクロージャーを受け取るので、より高度な環境変数の設定ができます。例えば特定の条件でのみ\.colorSchemeを変更したい時、このModifierを使うと便利です。
.transformEnvironment(\.colorScheme) { colorScheme in
if someCondition {
colorScheme = .dark
}
}
敢えてSetterを公開したい
メリットの章に書いた通り、@Environmentは基本的にGetterのみ共有されますが、実は見方を変えれば、「セット処理」を「変数」とみなせば、そのセット処理自身のGetterを公開することで、実質的にSetterのみ公開しているようなこともできます。何を隠そう、実は先ほどの文章でチラッと書いた@Environment(\.dismiss)もまさにこの手法を使っています。
ただし気をつけないといけないのは、「処理」のことを言ったら、「Closure」を思い浮かぶ人が多いかと思いますが、SwiftUIの仕組みの都合で、Closureのような匿名な参照型を使うと無駄なレンダリングが発生する可能性があります。そのため、Swift 5.2から導入されたCallable structで処理を書きましょう。例えばリストから選択されたIndexを、リスト内の各子ビューに設定させたい時、こんな感じで使えます。
struct SetSelectedIndexAction {
private var selectedIndex: Binding<Int?> // ←誤用を防ぐために全てのプロパティーを外部から隠す
init(selectedIndex: Binding<Int?>) {
self.selectedIndex = selectedIndex
}
func callAsFunction(_ index: Int) { // ←これで`SetSelectedIndexAction`を関数のように呼べる
selectedIndex.wrappedValue = index
}
}
struct SetSelectedIndexKey: EnvironmentKey {
static var defaultValue: SetSelectedIndexAction = .init(selectedIndex: .constant(nil))
}
extension EnvironmentValues {
var setSelectedIndex: SetSelectedIndexAction //...省略
}
struct ContentView: View {
@State private var selectedIndex: Int?
var body: some View {
VStack {
ForEach(0..<10) { i in
MyCell(index: i)
.background(selectedIndex == i ? Color.red : Color.clear)
}
}
.environment(\.setSelectedIndex, .init(selectedIndex: $selectedIndex))
}
}
struct MyCell: View {
var index: Int
@Environment(\.setSelectedIndex) var setSelectedIndex: SetSelectedIndexAction
var body: some View {
Button("Select") {
setSelectedIndex(index)
}
}
}
上記の例では、リスト内の子ビューMyCellは、実際にselectedIndexの値を知りませんが、setSelectedIndexを使ってselectedIndexを変更することができます。これにより、ContentViewが持つselectedIndexの値を、MyCellが変更できるようになります。
もちろん現実ではselectedIndexの設定は他にいくらでもやり方はありますし、そもそもアプリの設計としてこれはあまりいい例として言いにくいです。これはあくまで使い方の例として、イメージしやすいように書いたものですが、これで@Environmentの使い方の幅が広がることを感じていただければ幸いです。また他に、Callable structの代わりにBindingを使うことで、実質GetterとSetter両方公開するようなこともできます。
個人的な宣伝になってしまいますが、実はSwiftUIでToastを簡単に表示するライブラリーTardinessを作りました。そのライブラリーの中で、あらゆる画面からToastの文言をセットできるようにするために、このcallAsFunction方式の@Environmentを使いました。もし興味があれば、ぜひこちらのリポジトリーを見てみてください:https://github.com/el-hoshino/Tardiness。
あとがき
いかがでしたでしょうか。@Environmentを活用すると、いろんなことが楽にできることを感じていただけたかと思います。最後に、この記事が敢えて\.keyPathの方だけ取り上げる理由を少し説明したいと思います。
ここまで読んでわかっていただいた通り、@Environment(\.keyPath)は非常に強力でありながら、使い方もそんなに難しくないです。では@Environment(ObservableType.self)はどうでしょうか。少なくとも、筆者から見て、いくつか致命的な問題点があると言わざるを得ません。
まずは、はじめにに書いた通りランタイムエラーのリスクがあります。\.keyPathの方はデフォルト値を利用したり、そもそもOptionalにすれば、ランタイムエラーは簡単に回避できるし、仮に設定し忘れを防ぎたい場合もデフォルト値でアサートを入れれば本番アプリに影響与えずに検出できますが、ObservableType.selfの方はそうはいきません。設定し忘れたら終わりです。
次に抽象の型、つまりprotocolをそのまま使えないのも大きな問題点だと思います。具象だけしか使えないと、開発環境だったりテストの時に、オブジェクトの置き換えがだいぶ難しくなります。
そして最後にObservableType.selfは、オブジェクトのインスタンスを入れるだけでセットするので、極端な例になりますが、サブクラスを作ってしまった時、代入したインスタンスはスーパークラスのインスタンスを指しているのかサブクラスのインスタンスを指してるのか曖昧になってしまうし、もちろんそもそもこの作りが悪いのですが、これを技術的に防げないのが問題だと思います。
その点、\.keyPathの方は、そもそもKeyPathを指定して使うので、そういった曖昧性もなければ、抽象も思うがまま使えます。何より、ObservableType.selfが使える場面は、全て\.keyPathでも代替できちゃいます。じゃあ\.keyPathを使った方がいいじゃないでしょうか。
というわけで、@Environment(\.keyPath)の布教記事でした。ぜひ@Environment(\.keyPath)を活用して、SwiftUIの開発をもっと楽しんでください!
また、本記事はhttps://github.com/el-hoshino/a_practical_introduction_to_at_environment_keypathでも公開しています。締切後の修正や追記はこちらに行うかもしれませんので、興味があればぜひご覧ください。