LoginSignup
21
12

AnyViewは悪くないよ

Last updated at Posted at 2023-08-24

English: AnyView is Pretty Great, Actually

SwiftUI 開発者のコミュニティでは、AnyView の評判が良くありません。

コードの臭いと呼ばれることも聞いたことがあるし、一般的に全力で避けるべきものとして扱われています。Apple 自体もよく知られている WWDC 2021 のセッション動画 Demystify SwiftUIAnyView は「邪悪な敵」であることを言及しています。

同じ役割を果たしているのに、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 に展開されることが分かります。

swiftui-navigation-anyview.png

それでまだ納得がいかないかと思うので、次に皆好きな 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>> になっています。

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

  1. @ViewBuilder の動作を再現するには、例えばそれぞれのビューに .id(0).id(1) のモディファイアをつけて 明示的なアイデンティティ (Explicit identity) を与えるとできます。
  2. SwiftUI は、AnyView の中身を管理していて状態の更新でもし同じアイデンティティを持つビューが返されたら再構築しないで更新をちゃんと適応するのに十分賢いです。
  3. 今回は異なった動作になったしまったのは、AnyView で包まれているビューの型がたまたま一致したからだけで、ほんの少しでも違っていたら、例えば1個のビューの背景は Color じゃなくて Gradient だったら、 中身の 構造的なアイデンティティが変わるからトランジションも復活します。
  4. 別々のビューを返すという意図だったら、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 はかなり素晴らしいものです! 実に堅牢な作りになっていて本来の機能以外には副作用はありません。貴重なツールなので、やりたいことにマッチするなら遠慮せずに使うといいでしょう。

21
12
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
21
12