English: AnyView is Pretty Great, Actually
SwiftUI 開発者のコミュニティでは、AnyView
の評判が良くありません。
コードの臭いと呼ばれることも聞いたことがあるし、一般的に全力で避けるべきものとして扱われています。Apple 自体もよく知られている WWDC 2021 のセッション動画 Demystify SwiftUI で AnyView
は「邪悪な敵」であることを言及しています。
同じ役割を果たしているのに、Combine の AnyPublisher
や SwiftUI の仲間の AnyShape
, AnyLayout
, AnyTransition
なら一切そいう悪い評判がついていないのはなぜでしょう。
AnyView
の目的は内部の View
の型情報を消去することだけで、それを達成したいなら遠慮なく使ってもいいことを主張したいです。
AnyView
と SwiftUI 内部
実際には SwiftUI 内部で AnyView
が広く使用されています。
プッシュナビゲーションの NavigationStack とモーダル表示の .sheet の簡単な例を実行してみましょう。
struct ContentView: View {
@State var isPresented = false
var body: some View {
NavigationStack {
List {
NavigationLink("Screen 1", value: "Screen 1")
}
.navigationDestination(for: String.self) { string in
Button("Screen 2") {
isPresented.toggle()
}
.sheet(isPresented: $isPresented) {
Text("Screen 2")
}
.navigationTitle(string)
}
}
}
}
Xcode の Debug View Hierarchy 機能でビュー階層を確認すると、ナビゲーション先もモーダル内容もそれぞれ AnyView
を表示している UIHostingController
に展開されることが分かります。
それでまだ納得がいかないかと思うので、次に皆好きな Xcode Previews の仕組みを見てみましょう。
実は、SwiftUI プレビューに関していうと、静的の型付けは単なる幻想に過ぎません。次のコードの実行結果を見てください。
struct ContentView: View {
var body: some View {
Text(verbatim: "\(type(of: ChildView().body))")
}
}
struct ChildView: View {
var body: some View {
Text("I am text")
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
シミュレータあるいは実機で実行すると「Text」が表示されます。プレビューでは「AnyView」になります。
この違いが発生する理由は、AnyView
が隠されたアンダースコア付き属性 @_typeEraser
を使って View
プロトコルの指定型消去構造体として定義されています。長い話では、some View
で不透明の型 (opaque type) の戻り値を返す関数と計算型プロパティが dynamic
キーワードを使用して定義されたら実際の戻り値が AnyView
になります。そして、dynamic replacements でホットリロードを可能にするため全てのコードに dynamic
を付けるのは、まさにプレビューが裏側でやっている処理です。
dynamic var text: some View {
Text("Hello")
}
type(of: text) // いつも AnyView
AnyView
が何をするのか?
さて、AnyView
の実際の動きに戻って @ViewBuilder
との違いを検証しましょう。
struct CounterView: View {
@State var i = 0
var body: some View {
Button("Increment: \(i)") {
i += 1
}
.foregroundColor(.white)
.padding()
}
}
ここは、タップする度に表示している数字をインクレメントする CounterView
を実装しています。次にそれ表示するビューを実装して紫色かミント色の背景色のカウンターを切り替えるボタンを置きます。
struct ContentView: View {
@State var flag = true
var body: some View {
VStack {
content
Button("Toggle") {
withAnimation(.default.speed(1/5)) {
flag.toggle()
}
}
}
}
@ViewBuilder var content: some View {
if flag {
CounterView()
.background(Color.purple)
.transition(.scale.combined(with: .opacity))
} else {
CounterView()
.background(Color.mint)
.transition(.scale.combined(with: .opacity))
}
}
}
厳密に言えば、content
ごと分岐で切り替える必要がないが、AnyView
の動作に焦点を当てたいからあえてこのように実装しています。
主なポイントは content
の中身です。flag
の値に応じて2個のビューの中からいずれかを返しています。どちらもほぼ一緒で唯一の違いは背景色です。条件分岐の構造にしたこどで、それらが2個の別々のビューであるという意図を表現しています。したがって、切り替えるときに削除と挿入のトランジションが発生します。
では、AnyView
を使用して書いた場合はどうでしょう。
var content: AnyView {
if flag {
return AnyView(
CounterView()
.background(Color.purple)
.transition(.scale.combined(with: .opacity))
)
} else {
return AnyView(
CounterView()
.background(Color.mint)
.transition(.scale.combined(with: .opacity))
)
}
}
今度は関数が条件分岐の構造になっているという情報が戻り値に反映されません。さらに、AnyView
に包まれているビューの実際の型は全く一緒なので、それらの 構造的なアイデンティティ (Structural Identity) が一致している、つまり同じビューであると SwiftUI が判断します。今度は切り替えるときにトランジションが発生せず、カウンターの状態を保持したまま背景色だけアニメーションされます。
実際の型は
ModifiedContent<ModifiedContent<CounterView, _BackgroundStyleModifier<Color>>, _TraitWritingModifier<TransitionTraitKey>>
になっています。
ここでいくつか重要な観察があります。
-
@ViewBuilder
の動作を再現するには、例えばそれぞれのビューに.id(0)
と.id(1)
のモディファイアをつけて 明示的なアイデンティティ (Explicit identity) を与えるとできます。 - SwiftUI は、
AnyView
の中身を管理していて状態の更新でもし同じアイデンティティを持つビューが返されたら再構築しないで更新をちゃんと適応するのに十分賢いです。 - 今回は異なった動作になったしまったのは、
AnyView
で包まれているビューの型がたまたま一致したからだけで、ほんの少しでも違っていたら、例えば1個のビューの背景はColor
じゃなくてGradient
だったら、 中身の 構造的なアイデンティティが変わるからトランジションも復活します。 - 別々のビューを返すという意図だったら、
flag
の分岐ごとをAnyView
で包むとそれを難なく表現できます。(その際はまず@ViewBuilder
でビューを構築してからその結果を包むイメージです。)
試しに明示的なアイデンティティを与えた時の動作も見てみましょう。
var content: AnyView {
if flag {
return AnyView(
CounterView()
.background(Color.purple)
.transition(.scale.combined(with: .opacity))
.id(0)
)
} else {
return AnyView(
CounterView()
.background(Color.mint)
.transition(.scale.combined(with: .opacity))
.id(1)
)
}
}
予想通り、トランジションも戻ってきました。ボタンを連打してアニメーションが完了した前に切り替えたというエッジケースも、正しく前の状態を復帰させて表示するように対応されています。
注目して欲しいのは、AnyView
を使ってもアイデンティティの維持・変更、アニメーション、トランジション、状態処理には全く問題がないことです。パーフォマンス に関しても、悪影響がありません。
AnyView
で失われるのは唯一、それを返す関数の構造情報だけです。ただし、それがデメリットかというとそうでもなく型消去から求められている本来の機能です。
AnyView
は使っていいよ
AnyView
ができることは @ViewBuilder
で表現するものだけで限らないからそれを比較しただけでは足りません。戻り値を合わせる機能はともかく、実際にビューの型を消去する必要が出たら AnyView
以外に適切なものがありません。
例えば、次のように遷移先をなんらかの形で保持しないといけない Router
型はそうです。
struct Router<Destination: Hashable> {
var routes: [Destination: () -> AnyView]
}
引数でビューの中身を受け取ってコンテナビューを返す関数のプロトコルもいい例になります。
protocol ListFactory {
// associatedtype ListView
// func makeList<Content: View>(@ViewBuilder content: () -> Content) -> ListView
// Swift にはジェネリクスのプロトコルがなく、上記のように定義したら
// ListView の具体的な型が準拠した際に決まらないといけないので実質準拠する型は書けない。
func makeList<Content: View>(@ViewBuilder content: () -> Content) -> AnyView
}
まとめていうと、AnyView
はかなり素晴らしいものです! 実に堅牢な作りになっていて本来の機能以外には副作用はありません。貴重なツールなので、やりたいことにマッチするなら遠慮せずに使うといいでしょう。