AnyViewは悪くないよ

English: [AnyView is Pretty Great, Actually](https://apurin.me/articles/anyview-is-great/)

SwiftUI 開発者のコミュニティでは、[`AnyView`](https://developer.apple.com/documentation/swiftui/anyview/) の評判が良くありません。

コードの臭いと呼ばれることも聞いたことがあるし、一般的に全力で避けるべきものとして扱われています。Apple 自体もよく知られている WWDC 2021 のセッション動画 [Demystify SwiftUI](https://developer.apple.com/wwdc21/10022) で `AnyView` は「邪悪な敵」であることを言及しています。

同じ役割を果たしているのに、Combine の [`AnyPublisher`](https://developer.apple.com/documentation/combine/anypublisher/) や SwiftUI の仲間の [`AnyShape`](https://developer.apple.com/documentation/swiftui/anyshape/), [`AnyLayout`](https://developer.apple.com/documentation/swiftui/anylayout/), [`AnyTransition`](https://developer.apple.com/documentation/swiftui/anytransition/) なら一切そいう悪い評判がついていないのはなぜでしょう。

`AnyView` の目的は内部の `View` の型情報を消去することだけで、それを達成したいなら遠慮なく使ってもいいことを主張したいです。

## `AnyView` と SwiftUI 内部

実際には SwiftUI 内部で `AnyView` が広く使用されています。

プッシュナビゲーションの [NavigationStack](https://developer.apple.com/documentation/swiftui/navigationstack/) とモーダル表示の  [.sheet](https://developer.apple.com/documentation/swiftui/view/sheet(ispresented:ondismiss:content:)) の簡単な例を実行してみましょう。

```swift
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` に展開されることが分かります。

![swiftui-navigation-anyview.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/652001/4d42b318-b302-3fd7-803a-49af67683850.png)

それでまだ納得がいかないかと思うので、次に皆好きな Xcode Previews の仕組みを見てみましょう。

実は、SwiftUI プレビューに関していうと、静的の型付けは単なる幻想に過ぎません。次のコードの実行結果を見てください。

```swift
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」になります。

<img width=512 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/652001/3d8df5ed-79a0-ce15-4fa0-2ec79fe7ae30.png>

この違いが発生する理由は、`AnyView` が隠されたアンダースコア付き属性 [`@_typeEraser`](https://github.com/apple/swift/blob/main/docs/ReferenceGuides/UnderscoredAttributes.md#_typeeraserproto) を使って `View` プロトコルの指定型消去構造体として定義されています。長い話では、`some View` で不透明の型 (opaque type) の戻り値を返す関数と計算型プロパティが `dynamic` キーワードを使用して定義されたら実際の戻り値が `AnyView` になります。そして、[dynamic replacements](https://github.com/apple/swift/blob/main/docs/ReferenceGuides/UnderscoredAttributes.md#_dynamicreplacementfor-targetfunclabel) でホットリロードを可能にするため全てのコードに `dynamic` を付けるのは、まさにプレビューが裏側でやっている処理です。

```swift
dynamic var text: some View {
  Text("Hello")    
}

type(of: text) // いつも AnyView
```

## `AnyView` が何をするのか？

さて、`AnyView` の実際の動きに戻って [`@ViewBuilder`](https://developer.apple.com/documentation/swiftui/viewbuilder) との違いを検証しましょう。

```swift
struct CounterView: View {
  @State var i = 0
  
  var body: some View {
    Button("Increment: \(i)") {
      i += 1
    }
    .foregroundColor(.white)
    .padding()
  }
}
```

ここは、タップする度に表示している数字をインクレメントする `CounterView` を実装しています。次にそれ表示するビューを実装して紫色かミント色の背景色のカウンターを切り替えるボタンを置きます。

```swift
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))
    }
  }
}
```

:::note warn
厳密に言えば、`content` ごと分岐で切り替える必要がないが、`AnyView` の動作に焦点を当てたいからあえてこのように実装しています。
:::

主なポイントは `content` の中身です。`flag` の値に応じて2個のビューの中からいずれかを返しています。どちらもほぼ一緒で唯一の違いは背景色です。条件分岐の構造にしたこどで、それらが2個の別々のビューであるという意図を表現しています。したがって、切り替えるときに削除と挿入のトランジションが発生します。

<img width=256 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/652001/3ccba257-d9f3-9103-674d-616a16d4869b.gif>

では、`AnyView` を使用して書いた場合はどうでしょう。

```swift
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>>` になっています。

<img width=256 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/652001/77097fcf-76f4-956b-233b-07941072db45.gif>

ここでいくつか重要な観察があります。

1. `@ViewBuilder` の動作を再現するには、例えばそれぞれのビューに `.id(0)` と `.id(1)` のモディファイアをつけて **明示的なアイデンティティ (Explicit identity)** を与えるとできます。
2. SwiftUI は、`AnyView` の中身を管理していて状態の更新でもし同じアイデンティティを持つビューが返されたら再構築しないで更新をちゃんと適応するのに十分賢いです。
3. 今回は異なった動作になったしまったのは、`AnyView` で包まれているビューの型がたまたま一致したからだけで、ほんの少しでも違っていたら、例えば1個のビューの背景は `Color` じゃなくて `Gradient` だったら、 **中身の** 構造的なアイデンティティが変わるからトランジションも復活します。
4. 別々のビューを返すという意図だったら、`flag` の分岐ごとを `AnyView` で包むとそれを難なく表現できます。(その際はまず `@ViewBuilder` でビューを構築してからその結果を包むイメージです。)

試しに明示的なアイデンティティを与えた時の動作も見てみましょう。

```swift
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)
    )
  }
}
```

<img width=256 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/652001/1003a4e2-b132-3925-b43e-2bd588cd72f8.gif>

予想通り、トランジションも戻ってきました。ボタンを連打してアニメーションが完了した前に切り替えたというエッジケースも、正しく前の状態を復帰させて表示するように対応されています。

注目して欲しいのは、`AnyView` を使ってもアイデンティティの維持・変更、アニメーション、トランジション、状態処理には全く問題がないことです。[パーフォマンス](https://nalexn.github.io/anyview-vs-group/) に関しても、悪影響がありません。

`AnyView` で失われるのは唯一、それを返す関数の構造情報だけです。ただし、それがデメリットかというとそうでもなく型消去から求められている本来の機能です。

## `AnyView` は使っていいよ

`AnyView` ができることは `@ViewBuilder` で表現するものだけで限らないからそれを比較しただけでは足りません。戻り値を合わせる機能はともかく、実際にビューの型を消去する必要が出たら `AnyView` 以外に適切なものがありません。

例えば、次のように遷移先をなんらかの形で保持しないといけない `Router` 型はそうです。

```swift
struct Router<Destination: Hashable> {
  var routes: [Destination: () -> AnyView]
}
```

引数でビューの中身を受け取ってコンテナビューを返す関数のプロトコルもいい例になります。

```swift
protocol ListFactory {
  // associatedtype ListView
  // func makeList<Content: View>(@ViewBuilder content: () -> Content) -> ListView
  // Swift にはジェネリクスのプロトコルがなく、上記のように定義したら 
  // ListView の具体的な型が準拠した際に決まらないといけないので実質準拠する型は書けない。
  
  func makeList<Content: View>(@ViewBuilder content: () -> Content) -> AnyView 
}
```

---

まとめていうと、`AnyView` はかなり素晴らしいものです！ 実に堅牢な作りになっていて本来の機能以外には副作用はありません。貴重なツールなので、やりたいことにマッチするなら遠慮せずに使うといいでしょう。