画面遷移を一つにまとめてみる

SwiftUIで画面遷移をしようとすると、Navigationや、FullScreenCover、Sheet、Overlayなどがあります。これらを毎回別々に書いているとコード量も多くなり、画面遷移の際に整合性が取れなくなることもあります。これらを一つにまとめようとういう記事です。

ViewModifierを用いた画面遷移一つにまとめます（ViewModifier以外を用いた遷移についてはそれほど必要性を感じないため扱いません）。
通常、すべての画面遷移を冗長に書くと以下のようになります。
``` Swift
struct ParentView: View {
    @State var state: ParentViewModel = .init()
    var body: some View {
        content()
          .navigationDestination(item: $state.navi, destination: { content in
            ChildView()
          })
          .fullScreenCover(item: $state.full, content: { content in
            ChildView()
          })
          .sheet(item: $state.sheet, content: { content in
            ChildView()
          })
          .overlay(content: {
            if state.showOverlay {
              ChildView()
            }
          })
    }
    func content() -> some View {
        ...
    }
}
```

### 理想形
以下のように一つにまとめることができれば非常にスマートです。
``` Swift
struct ParentView: View {
    @State var state: ParentViewModel = .init()
    var body: some View {
        content()
           .present(item: $state.content, content: { content in
               // contentに応じたView
           }
    }
    func content() -> some View {
        ...
    }
}
```
これを考えるといくつかの方法が思い浮かびますが、SwiftUIの再描画について理解していないと予期せぬ挙動を生みかねません。

### アンチパターン 1
以下の書き方がまず思い浮かびます。
``` Swift
extension View {
  @ViewBuilder
  func present<Item: PresentationContentProtocol, Content: View>(item: Binding<Item?>, @ViewBuilder content: @escaping (Item) -> Content) -> some View {
    if let value = item.wrappedValue {
      switch value.type {
      case .navigation:
        self.navigationDestination(item: item, destination: content)
      case .fullScreenCover:
        self.fullScreenCover(item: item, content: content)
      case .sheet:
        self.sheet(item: item, content: content)
      case .overlay:
        self.overlay(alignment: .center) {
          content(value)
        }
      }
    }
  }
}

enum PresentationType: String, Hashable, Identifiable, Sendable {
  case navigation
  case fullScreenCover
  case sheet
  case overlay
  var id: String { self.rawValue }
}

protocol PresentationContentProtocol: Hashable, Identifiable, Sendable {
  var type: PresentationType { get }
}
```
実行してみると確かに画面遷移はできていますが、いくつか問題があります。まず、switch文内でselfを利用したViewModifierが分岐しています。これらのselfはすべて別々のStructural Identityとして扱われるためswitchの分岐が走るたびに毎回、self(親View）の再描画が走ります。画面遷移の際に基本的に親Viewの再描画は望ましくありませんし、予期せぬ挙動を生みかねません。これに対応すると以下になります。

### アンチパターン 2
``` Swift
 extension View {
   func present<Item: PresentationContentProtocol, Content: View>(item: Binding<Item?>, @ViewBuilder content: @escaping (Item) -> Content) -> some View {
     self.overlay(alignment: .center) {
       if let value = item.wrappedValue {
         switch value.type {
         case .navigation:
           Color.clear.navigationDestination(item: item, destination: content)
         case .fullScreenCover:
           Color.clear.fullScreenCover(item: item, content: content)
         case .sheet:
           Color.clear.sheet(item: item, content: content)
         case .overlay:
           Color.clear.overlay(alignment: .center) {
             content(value)
           }
         }
       }
     }
   }
 }

enum PresentationType: String, Hashable, Identifiable, Sendable {
  case navigation
  case fullScreenCover
  case sheet
  case overlay
  var id: String { self.rawValue }
}

protocol PresentationContentProtocol: Hashable, Identifiable, Sendable {
  var type: PresentationType { get }
}
```
この書き方はアンチパターンとまでは言えませんが少々問題があります。
overlay内のViewで遷移先の分岐処理を記述することで、self(親View)の再描画は走らず、overlay内のみ再描画が行われます。
一見問題ないように見えますが、実際にこれを動かしてみるとnavigationDestination・overlayは問題ありませんがfullScreenCover・sheetで画面遷移時のアニメーションが動作しません。これは詳細を調べていないのであくまでも憶測ですが、itemが変わるたびにswitchの分岐処理が走り何かしらのViewが選択されoverlay内の再描画が行われます。そうすると、Viewの描写と画面遷移が同時に行われることになり、アニメーション上手く動作しないのではと考えています。ただ、これは状態によってアニメーションすることもあり詳細はfullScreenCoverやsheetの画面遷移の詳細を調べる必要がありそうです。
ではどうするのが良いでしょう？

### 現状の最適解
コードが長くなりますが、以下のようにするのが思いつく限り最適解ではないかと考えています（PresentationState内はもっとスマートになるかと思いますが。。)
``` Swift
import SwiftUI

extension View {
  func present<Item: PresentationContentProtocol, Content: View>(item: Binding<Item?>, @ViewBuilder content: @escaping (Item?) -> Content) -> some View {
    self.modifier(PresentationViewModifier(item: item, content: content))
  }
}

// MARK: - PresentationViewModifier
struct PresentationViewModifier<Item: PresentationContentProtocol, FullScreenContent: View>: ViewModifier {
  @State private var state: PresentationState<Item>
  @Binding private var item: Item?
  private var fullScreenContent: (Item?) -> FullScreenContent
  init(item: Binding<Item?>, @ViewBuilder content: @escaping (Item?) -> FullScreenContent) {
    self._item = item
    self.fullScreenContent = content
    self._state = State(wrappedValue: .init(item: item))
  }

  func body(content: Content) -> some View {
    content
      .navigationDestination(isPresented: $state.isPresentedNavigation, destination: {
        fullScreenContent(item)
      })
      .fullScreenCover(isPresented: $state.isPresentedFullScreenCover, content: {
        fullScreenContent(item)
      })
      .sheet(isPresented: $state.isPresentedSheet, content: {
        fullScreenContent(item)
      })
      .overlay(alignment: .center, content: {
        if state.isPresentedOverlay {
          fullScreenContent(item)
        }
      })
      .onChange(of: item) { oldValue, newValue in
        state.onChange(oldValue: oldValue, newValue: newValue)
      }
  }
}

// MARK: - PresentationState
@MainActor
@Observable
class PresentationState<Item: PresentationContentProtocol> {
  var isPresentedNavigation: Bool = false { didSet { didSet(oldValue: oldValue, newValue: isPresentedNavigation) }}
  var isPresentedFullScreenCover: Bool = false { didSet{ didSet(oldValue: oldValue, newValue: isPresentedFullScreenCover) }}
  var isPresentedSheet: Bool = false { didSet { didSet(oldValue: oldValue, newValue: isPresentedSheet) }}
  var isPresentedOverlay: Bool = false { didSet { didSet(oldValue: oldValue, newValue: isPresentedOverlay) }}
  private var changeFlag: Bool = false
  @ObservationIgnored @Binding private var item: Item?

  init(item: Binding<Item?>) {
    self._item = item
  }

  func onChange(oldValue: Item?, newValue: Item?) {
    defer { changeFlag = false }
    changeFlag = true
    resetPresentation()
    guard let newValue else { return }
    switch newValue.type {
    case .navigation:
      isPresentedNavigation = true
    case .fullScreenCover:
      isPresentedFullScreenCover = true
    case .sheet:
      isPresentedSheet = true
    case .overlay:
      isPresentedOverlay = true
    }
  }

  func resetPresentation() {
    isPresentedNavigation = false
    isPresentedFullScreenCover = false
    isPresentedSheet = false
    isPresentedOverlay = false
  }

  func didSet(oldValue: Bool, newValue: Bool) {
    if oldValue, !newValue, !changeFlag {
      item = nil
    }
  }
}

enum PresentationType: String, Hashable, Identifiable, Sendable {
  case navigation
  case fullScreenCover
  case sheet
  case overlay
  public var id: String { self.rawValue }
}

protocol PresentationContentProtocol: Hashable, Identifiable, Sendable {
  var type: PresentationType { get }
}
```
状態を監視するためにPresentationStateを定義しています。そのため、状態を保持するためのViewModifierを新たに定義する必要が出てきます。
この書き方では、親Viewの再描画も走らず、アニメーションも問題なく動作します。描画コストは今までの中で最も低くなります。

### 使い方
Viewは以下のように簡潔に記述することができます。
``` Swift 
struct ParentView: View {
    @State var state: ParentViewModel = .init()
    var body: some View {
        content()
           .present(item: $state.fullScreenContent, content: { content in
               // contentに応じたView
           }
    }
    func content() -> some View {
        ...
    }
}
```
ViewModelでは例えば以下のようになるかと思います。
``` Swift
@MainActor
@Observable
class ParentViewModel {
  var fullScreenContent: FullScreenContent?

  enum FullScreenContent: String, PresentationContentProtocol {
    case child1, child2, child3, child4
    var id: String { self.rawValue }
    var type: PresentationType {
      return switch self {
      case .child1: .navigation
      case .child2: .fullScreenCover
      case .child3: .sheet
      case .child4: .overlay
      }
    }
  }
}
```

SwiftUIはiOS17 -> 18に変わった段階でNavigation周りの挙動が変わっており（バグが治っている？）、まだ十分理解できていない点が多いです。特に画面の再描画については場合によってはCriticalなバグになる可能性があり無視できない点だと業務の中で日々感じており、この記事を書きました。
誤り等あれば遠慮なくご指摘いただければと思います。