この記事はSwiftUI Advent Calendar 2024への寄稿です。
はじめに
私は以前の記事で、@Environment(\.keyPath)
を激推ししていました、その時に紹介した高度の使い方の一つに、KeyPathの連結というテクニックも紹介していました:
struct ContentView: View {
@Environment(\.rect.midX) var midX: CGFloat
// ...
}
ここだけ不便かも…?
ところがこの時、人によっては一つだけ不便を感じることもあるでしょう、そう、プレビューの時です。この場合、.environment(\.rect.midX, cgFloatValue)
で注入できないのです。
#Preview {
ContentView()
.environment(\.rect.midX, cgFloatValue) // Error: Cannot convert value of type 'KeyPath<EnvironmentValues, CGFloat>' to expected argument type 'WritableKeyPath<EnvironmentValues, CGFloat>'
}
だからこの場合は素直に .environment(\.rect, cgRectValue)
で注入するしかないかとみなさん思うではないでしょうか。
#Preview {
ContentView()
.environment(\.rect, cgRectValue)
正確にいうと、そのまま .environment
注入できないのは \.rect.midX
だからではなく、EnvironmentValues
に対してセッターかないからです。つまり、\.rect
の場合でも、セッターがあれば問題ないですが、セッターがなかったら \.rect.midX
と同じく注入できないです:
extension EnvironmentValues {
@Entry var rect: CGRect = .zero // ← `EnvironmentValues` に対してセッターを持つ可変の `rect` という Stored Property を持たせた場合
}
#Preview {
ContentView()
.environment(\.rect, someCGRectValue) // `\.rect` を注入できます
}
extension EnvironmentValues {
var rect: CGRect { .zero } // `EnvironmentValues` に対してゲッターしかない Computed Property を持たせた場合
}
#Preview {
ContentView()
.environment(\.rect, someCGRectValue) // `\.rect` を注入できません
}
extension EnvironmentValues {
let rect: CGRect = .zero // `EnvironmentValues` に対してそもそもセッターがない定数 Property を持たせた場合も
}
#Preview {
ContentView()
.environment(\.rect, someCGRectValue) // `\.rect` を注入できません
}
改善の試み
まあ CGRect
くらいならまだそんなに複雑な型ではないし、イニシャライザーもそこまで面倒ではないからなんとかできますが、実際の開発ではより複雑な依存も絶対登場するから、可能ならやはりそのまま midX
だけ注入したい、みたいな時ありますよね。
ところが、実はもしこの @Environment(\.rect.midX)
のプロパティーを作る時に private
とかでさえ隠していなければ、ContentView
タイピングした時、midX
という引数を受け取るイニシャライザーも登場しているのお気づきでしょうか
そう、見ての通り、実は .environment
で注入する以外に、直接 midX
をイニシャライズ時に設定する方法もあります。
ところが、スクショ見る限り、この midX
は通常の CGFloat
ではなく、Environment<CGFloat>
という謎の型を受け取りますね…これはなんでしょう?
実はこの Environment
は、まさに我々がいつも使っているあの @Environment
の実態で、そのジェネリクスの <CGFloat>
はこの Environment
が出力するものの型です。そう、今回の場合はその \.rect.midX
の型です
そして試しに .init
を入力して Environment
を作ってみると、このようなサジェスチョンが出てきますね:
なるほど、EnvironmentValues
から CGFloat
への KeyPath
を使ってイニシャライズできるっぽいですね。そうです、実は我々がいつも書いてる @Environment(\.keyPath)
のこの \.keyPath
の部分が、まさにここのイニシャライザーに使われている KeyPath
です。というわけで、勘のいい人ならもう気づいたかもしれません:そう、プレビュー向けの midX
を作っちゃえばいいです!
private extension EnvironmentValues {
var midX: CGFloat { 100 }
}
#Preview {
ContentView(midX: .init(\.midX))
}
ただしこのように直接イニシャライザーで Environment
を生成するのは、実際のプロダクトコードでやってはいけません。公式ドキュメントではそもそも「Don’t call this initializer directly」と警告しており、通常はこのような使い方を想定されていません。これはあくまでプロダクトデータに影響せず、ただ Environment
の内容、すなわち EnvironmentValues
から取りたいプロパティーをプレビュー向けに書き換えているだけです。
TIPS
ただ、実際のプレビューでは、たくさんのパターンを確認したいことも多いと思いますので、個人的には愚直な midX
のような命名はあまりおすすめではなく、代わりに実際に代入したい値をベースに命名する方がいろんなパターンを作りやすいのでおすすめです:
private extension EnvironmentValues {
var cgFloat0: CGFloat { 0 }
var cgFloat0_5: CGFloat { 0.5 }
var cgFloat100: CGFloat { 100 }
}
#Preview {
ContentView(midX: .init(\.cgFloat0))
ContentView(midX: .init(\.cgFloat0_5))
ContentView(midX: .init(\.cgFloat100))
}
更に複数のビューでプレビュー用のデータを共有したい場合は、こんな感じでプレビュー用の型を作るのもアリかもですね、これなら EnvironmentValues
が汚れる心配もせずに済みます
struct PreviewValues {
var cgFloat0: CGFloat { 0 }
var cgFloat0_5: CGFloat { 0.5 }
var cgFloat100: CGFloat { 100 }
}
extension EnvironmentValues {
var preview: PreviewValues { .init() }
}
#Preview {
ContentView(midX: .init(\.preview.cgFloat0))
ContentView(midX: .init(\.preview.cgFloat0_5))
ContentView(midX: .init(\.preview.cgFloat100))
}
あとがき
いかがでしたでしょうか。まあ確かに直接値を入れられず、どうしても一回 EnvironmentValues
を拡張しないといけないのは多少の手間がかかります。ただこれで場合によっては面倒な複雑なオブジェクトを作るのが免れることも多いので、これで @Environment(\.keyPath)
の魅力をさらに感じられたら幸いです。これからもぜひこの @Environment(\.kePath)
を活用して、素敵なSwiftUIライフを送ってください!