Closed10

TCA でまだ開発中の navigation branch の実装を読んでみる

アイカワアイカワ

Navigation という Discussion があり、brandon さんが

Hello everyone, we have some early explorations to share. It's pretty rough, but also promising. We think some of the tools will be made even nicer once we finish up the protocol reducer experimentation we have been working on. You can run the case studies app to see some simple usage.

The API is probably going to change quite a bit over the next few weeks/months, but it follows the style we've pushed for bindable state/actions as well as our nav experiments that some have found in our branches. Essentially there are reducer and view helpers that help you combine each feature's domain into the root domain.

と話している

explorations のリンクでは nav-stack という branch が示されているが、branch を見た感じ navigation branch の開発が一番進んでいそうだったので、そこを見てみる

だいぶ形はまだ変わっていきそうだけど、ちょうど Navigation について悩んでいて良い機会なので。

(スクラップということで、雑に書いていっているので正確性を欠いている部分が結構ありそうかもです)

アイカワアイカワ

新たに追加されているファイル群

SwiftUICaseStudies

  • Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet.swift
  • Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Stack.swift

みたいな Navigation 用の Sample が追加されていそう。

Examples にも Examples/Standups が追加されているようで、これは Modern SwiftUI でも説明されていた例を TCA で書き換えてみたみたいなやつかもしれない。
機能豊富めなアプリだった気がするので、より Examples が充実する形になりそう。

Navigation に関わる主な機能追加は ↓ だろうか

  • Sources/ComposableArchitecture/SwiftUI/Navigation.swift
  • Sources/ComposableArchitecture/SwiftUI/Presentation.swift
  • Dependency 系
    • Sources/ComposableArchitecture/Internal/NavigationID.swift
    • Sources/ComposableArchitecture/Dependencies/IsPresented.swift
    • Sources/ComposableArchitecture/Dependencies/Dismiss.swift
  • Sources/ComposableArchitecture/Internal/EnumTag.swift

少しずつ見ていってみる

アイカワアイカワ

ReducerProtocol の実装

今個人的に悩んでいるのは sheet 系の Navigation についてなので、03-Navigation-Sheet.swift を見て、まずはどんな風に利用しているのかを見てみる。

コードの全貌: https://github.com/pointfreeco/swift-composable-architecture/blob/53e45d21857285148052cde8df3087a393580ca3/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet.swift

ReducerProtocol の実装は以下のようになっている

コードを展開
struct SheetDemo: ReducerProtocol {
  struct State: Equatable {
    @PresentationStateOf<Destinations> var destination
  }

  enum Action: Equatable {
    case destination(PresentationActionOf<Destinations>)
    case swap
  }

  var body: some ReducerProtocol<State, Action> {
    Reduce { state, action in
      switch action {
      case .destination(.presented(.counter(.decrementButtonTapped))):
        if case let .counter(counterState) = state.destination,
          counterState.count < 0
        {
          state.destination = nil
        }
        return .none

      case .destination:
        return .none

      case .swap:
        switch state.destination {
        case .some(.animations):
          state.destination = .counter(Counter.State())
        case .some(.counter):
          state.destination = .animations(Animations.State())
        case .some(.alert), .none:
          break
        }
        return .none
      }
    }
    // TODO: Can we hide away the behavior of detecting alert action and `nil`-ing out destination.
    // TODO: Can we also not send `dismiss` when writing `nil` to binding in view layer?
    .presentationDestination(\.$destination, action: /Action.destination) {
      Destinations()
    }
  }

  struct Destinations: ReducerProtocol {
    enum State: Equatable {
      // state.destination = .alert(.delete)
      case alert(AlertState<AlertAction>)
      case animations(Animations.State)
      case counter(Counter.State)
    }

    enum Action: Equatable {
      case alert(AlertAction)
      case animations(Animations.Action)
      case counter(Counter.Action)
    }

    enum AlertAction {
      case confirm
      case deny
    }

    var body: some ReducerProtocol<State, Action> {
      Scope(state: /State.animations, action: /Action.animations) {
        Animations()
      }
      Scope(state: /State.counter, action: /Action.counter) {
        Counter()
      }
    }
  }
}

さらに少しずつ見てみる。
まず、注目するのは Destinations という Navigation に関わる state などがまとめられた ReducerProtocol。

  struct Destinations: ReducerProtocol {
    enum State: Equatable {
      // state.destination = .alert(.delete)
      case alert(AlertState<AlertAction>)
      case animations(Animations.State)
      case counter(Counter.State)
    }

    enum Action: Equatable {
      case alert(AlertAction)
      case animations(Animations.Action)
      case counter(Counter.Action)
    }

    enum AlertAction {
      case confirm
      case deny
    }

    var body: some ReducerProtocol<State, Action> {
      Scope(state: /State.animations, action: /Action.animations) {
        Animations()
      }
      Scope(state: /State.counter, action: /Action.counter) {
        Counter()
      }
    }
  }

ここでは通常の機能の ReducerProtocol を作るのと同じように State / Action / body を定義していっている。
State は enum で定義されているので、同時に起こりうる Navigation の状態が一つのみに絞られることを型レベルで表現できている。
実装自体に特殊な部分はなく、enum の associated value を利用して navigation に必要な状態を保持するなど、swiftui-navigation library の利用方法に近い感じで良さそう。

次に、機能自体の ReducerProtocol の実装も見てみる

struct SheetDemo: ReducerProtocol {
  struct State: Equatable {
    @PresentationStateOf<Destinations> var destination
  }

  enum Action: Equatable {
    case destination(PresentationActionOf<Destinations>)
    case swap
  }

  // ...

State では @PresentationStateOf<Destinations> var destination という状態を保持している。
@PresentationStateOf という Property Wrapper については後で詳しくみるとして、@PresenStateStateOf に先ほどの ReducerProtocol を提供することで、destination という状態を定義することができている。

Action でも PresentationActionOf<Destinations> という似たような形で navigation 用の Action を管理している。

struct SheetDemo: ReducerProtocol {
  // ...

  var body: some ReducerProtocol<State, Action> {
    Reduce { state, action in
      switch action {
      case .destination(.presented(.counter(.decrementButtonTapped))):
        if case let .counter(counterState) = state.destination,
          counterState.count < 0
        {
          state.destination = nil
        }
        return .none

      case .destination:
        return .none

      case .swap:
        switch state.destination {
        case .some(.animations):
          state.destination = .counter(Counter.State())
        case .some(.counter):
          state.destination = .animations(Animations.State())
        case .some(.alert), .none:
          break
        }
        return .none
      }
    }
    // TODO: Can we hide away the behavior of detecting alert action and `nil`-ing out destination.
    // TODO: Can we also not send `dismiss` when writing `nil` to binding in view layer?
    .presentationDestination(\.$destination, action: /Action.destination) {
      Destinations()
    }
  }

body も見てみる。
ちょっとずつ新しい概念がありそう。

上から順に見ていくと、まず以下の部分。

      case .destination(.presented(.counter(.decrementButtonTapped))):
        if case let .counter(counterState) = state.destination,

Action をハンドリングしている部分だが、case destination(PresentationActionOf<Destinations>) という形で Action を定義していたことによって、.destination(.presented(.counter(.decrementeButtonTapped))) という Action をハンドリングできるようになっていることがわかる。
Destinations には case counter(Counter.Action) という Action を定義しているため、PresentationActionOf というものを通すことによって、.presented(...) という形の Action を利用できているっぽい。
おそらくこれは、既に present されている counter domain の decrementButtonTapped が発火した時の Action を表しているのだと思う。(見た目から想像しているだけで、どんな処理になっているかは後でちゃんと見る)

あとは case let .counter(counterState) = state.destination という形で State に保持している destination から counter domain の associated value を取り出せていることもわかる。
この辺りも後で詳しく @PresentationStateOf の仕組みを見てみる。

次に以下の部分。

      case .swap:
        switch state.destination {
        case .some(.animations):
          state.destination = .counter(Counter.State())
        case .some(.counter):
          state.destination = .animations(Animations.State())
        case .some(.alert), .none:
          break
        }
        return .none

destination の状態に応じて、state.destination = ... という形で destination を直接書き換えていることがわかる。
これにより、Reducer だけで「別の画面に遷移させる」という動作を可能にしていそう。

最後に以下の部分。

    // TODO: Can we hide away the behavior of detecting alert action and `nil`-ing out destination.
    // TODO: Can we also not send `dismiss` when writing `nil` to binding in view layer?
    .presentationDestination(\.$destination, action: /Action.destination) {
      Destinations()
    }

後で詳しく見るが、.presentationDestination というのは ReducerProtocol を extension して定義されている function で、ReducerProtocol に準拠している _PresentationDestinationReducer というものを生成することができている。
TODO コメントは少し残っているようではあるが、現状見えている ReducerProtocol の実装は以上のようになっていそう。

アイカワアイカワ

View の実装

次にこの Reducer を利用している View の実装がどのようになっているかも見てみる。

コードの全貌は引き続き: https://github.com/pointfreeco/swift-composable-architecture/blob/53e45d21857285148052cde8df3087a393580ca3/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet.swift

View で重要そうなコードは以下の部分。

コードを展開
func form(_ viewStore: ViewStore<Void, SheetDemo.Action>) -> some View {
  Form {
    Button("Alert") { viewStore.send(.destination(.present(.alert(.alert)))) }
    Button("Animations") {
      viewStore.send(.destination(.present(.animations(Animations.State()))))
    }
    Button("Counter") {
      viewStore.send(.destination(.present(.counter(Counter.State()))))
    }
  }
}

struct SheetDemoView: View {
  let store: StoreOf<SheetDemo>

  var body: some View {
    WithViewStore(self.store.stateless) { viewStore in
      form(viewStore)
      .alert(
        store: self.store.scope(state: \.$destination, action: SheetDemo.Action.destination),
        state: /SheetDemo.Destinations.State.alert,
        action: SheetDemo.Destinations.Action.alert
      )
      .sheet(
        store: self.store.scope(state: \.$destination, action: SheetDemo.Action.destination),
        state: /SheetDemo.Destinations.State.animations,
        action: SheetDemo.Destinations.Action.animations
      ) { store in
        VStack {
          HStack {
            Button("Swap") {
              viewStore.send(.swap, animation: .default)
            }
            Button("Close") {
              viewStore.send(.destination(.dismiss))
            }
          }
          .padding()

          AnimationsView(store: store)
          Spacer()
        }
      }
      .sheet(
        store: self.store.scope(state: \.$destination, action: SheetDemo.Action.destination),
        state: /SheetDemo.Destinations.State.counter,
        action: SheetDemo.Destinations.Action.counter
      ) { store in
        VStack {
          HStack {
            Button("Swap") {
              viewStore.send(.swap, animation: .default)
            }
            Button("Close") {
              viewStore.send(.destination(.dismiss))
            }
          }
          .padding()

          CounterView(store: store)
          Spacer()
        }
      }
    }
    .navigationTitle("Sheets")
  }
}

まず注目したいのは以下の部分。

      .alert(
        store: self.store.scope(state: \.$destination, action: SheetDemo.Action.destination),
        state: /SheetDemo.Destinations.State.alert,
        action: SheetDemo.Destinations.Action.alert
      )
      .sheet(
        store: self.store.scope(state: \.$destination, action: SheetDemo.Action.destination),
        state: /SheetDemo.Destinations.State.animations,
        action: SheetDemo.Destinations.Action.animations
      ) { store in

どうやら .alert.sheet に Store を受け取るタイプの function が追加されているっぽい。
これによって、先ほど登場していた navigation の状態を司る presentationDestination reducer を scope して、navigation の処理を行うことが可能になっていそう。

もう一つ注目したいのは form という View の実装部分。

func form(_ viewStore: ViewStore<Void, SheetDemo.Action>) -> some View {
  Form {
    Button("Alert") { viewStore.send(.destination(.present(.alert(.alert)))) }
    Button("Animations") {
      viewStore.send(.destination(.present(.animations(Animations.State()))))
    }
    Button("Counter") {
      viewStore.send(.destination(.present(.counter(Counter.State()))))
    }
  }
}

ここでは viewStore を通じて色々な Action を送っている。
全て列挙すると、

  • viewStore.send(.destination(.present(.alert(.alert))))
  • viewStore.send(.destination(.present(.animations(Animations.State()))))
  • viewStore.send(.destination(.present(.counter(Counter.State()))))

.destination Action を通じて、定義している遷移先に遷移するための Action を送ることができるようになっているっぽい。
View 内で遷移先に必要な State を initialize するのは個人的には微妙な気もするので、今まで通り Reducer 内で State を initialize するようにしたい気もするかも。(シンプルなものであればこの形で遷移するのは良さそう)

アイカワアイカワ

Presentation.swift の実装

Presentation.swift では、主に以下のものが実装されている。

  • PresentationState, PresentationStateOf
  • PresentationAction, PresentationActionOf
  • ReducerProtocol.presentationDestination(_:action:destination:file:fileID:line)
  • View extension によるヘルパー function 群

実装内容が結構ボリューミーなので、それぞれについて見ていくことにする。

アイカワアイカワ

PresentationState, PresentationStateOf

PresentationState, PresentationStateOf に関わる実装は以下の通り。

コードを展開
// TODO: `@dynamicMemberLookup`? `Sendable where State: Sendable`
// TODO: copy-on-write box better than indirect enum?
@propertyWrapper
public enum PresentationState<State> {
  case dismissed
  indirect case presented(id: AnyHashable, State)

  public init(wrappedValue: State? = nil) {
    self =
      wrappedValue
      .map { .presented(id: DependencyValues._current.navigationID.next(), $0) }
      ?? .dismissed
  }

  public init(projectedValue: Self) {
    self = projectedValue
  }

  public var wrappedValue: State? {
    _read {
      switch self {
      case .dismissed:
        yield nil
      case let .presented(_, state):
        yield state
      }
    }
    _modify {
      switch self {
      case .dismissed:
        var state: State? = nil
        yield &state
      case let .presented(id, state):
        var state: State! = state
        yield &state
        self = .presented(id: id, state)
      }
    }
    set {
      // TODO: Do we need similar for the navigation APIs?
      // TODO: Should we _always_ reuse the `id` when value is non-nil, even when enum tags differ?
      guard
        let newValue = newValue,
        case let .presented(id, oldValue) = self,
        enumTag(oldValue) == enumTag(newValue)
      else {
        self = .init(wrappedValue: newValue)
        return
      }

      self = .presented(id: id, newValue)

      // TODO: Should we add state.$destination.present(...) for explicitly re-presenting new ID?
      // TODO: Should we do the following instead (we used to)?
      //self = .init(wrappedValue: newValue)
    }
  }

  public var projectedValue: Self {
    _read { yield self }
    _modify { yield &self }
  }

  public var id: AnyHashable? {
    switch self {
    case .dismissed:
      return nil
    case let .presented(id, _):
      return id
    }
  }
}

public typealias PresentationStateOf<R: ReducerProtocol> = PresentationState<R.State>

// TODO: Should ID be encodable/decodable ever?
extension PresentationState: Decodable where State: Decodable {
  public init(from decoder: Decoder) throws {
    self.init(wrappedValue: try State?(from: decoder))
  }
}
extension PresentationState: Encodable where State: Encodable {
  public func encode(to encoder: Encoder) throws {
    try self.wrappedValue?.encode(to: encoder)
  }
}

extension PresentationState: Equatable where State: Equatable {
  public static func == (lhs: Self, rhs: Self) -> Bool {
    lhs.wrappedValue == rhs.wrappedValue
  }
}
extension PresentationState: Hashable where State: Hashable {
  public func hash(into hasher: inout Hasher) {
    self.wrappedValue.hash(into: &hasher)
  }
}

// TODO: Should ID clutter custom dump logs?
//extension PresentationState: CustomReflectable {
//  public var customMirror: Mirror {
//    Mirror(reflecting: self.wrappedValue as Any)
//  }
//}

PresentationStateOfPresentationState の typealias となっている。

public typealias PresentationStateOf<R: ReducerProtocol> = PresentationState<R.State>

PresentationState は Property Wrapper として表現されており、State という Generics を持っているため、利用側では @PresentationStateOf<Destinations> という形で利用できていた。

少しずつ見ていくために、PresentationState の一部コードを抜粋する。

// TODO: `@dynamicMemberLookup`? `Sendable where State: Sendable`
// TODO: copy-on-write box better than indirect enum?
@propertyWrapper
public enum PresentationState<State> {
  case dismissed
  indirect case presented(id: AnyHashable, State)

  public init(wrappedValue: State? = nil) {
    self =
      wrappedValue
      .map { .presented(id: DependencyValues._current.navigationID.next(), $0) }
      ?? .dismissed
  }

  public init(projectedValue: Self) {
    self = projectedValue
  }

  // ...
}

この Property Wrapper の wrappedValue は Generic な State で、projectedValuePresentationState<State> enum 自体を指しているらしい。
initializer は wrappedValue を受け取るものと projectedValue を受け取るものが存在している。
wrappedValue の方の initializer は default value として nil が指定されており、nil の場合は .dismissed がセットされるようになっている。
もし wrappedValue を指定して initialize した場合は .presented(id: DependencyValues._current.navigationID.next(), $0) という形で、指定した State が埋め込まれる。

DependencyValues._current.navigationID.next() というのが何なのかというと、どうやら新しく追加された NavigationID.swift のものらしい。

extension DependencyValues {
//  @usableFromInline
  @inlinable
  public var navigationID: NavigationID {
    get { self[NavigationID.self] }
    set { self[NavigationID.self] = newValue }
  }
}

// TODO: Fix sendability of (`AnyHashable`)
// TODO: generalize? ReducerID?
public struct NavigationID: @unchecked Sendable {
  public var current: AnyHashable?
  public var next: @Sendable () -> AnyHashable
}

extension NavigationID: DependencyKey {
  public static var liveValue: Self {
    let id = UUIDGenerator { UUID() }
    return Self { id() }
  }
  // ...
}

// ...

シンプルな id 生成器のような役割を果たしているように見える。
Navigation を一意に識別するための id を生成して、それを .presented(id: DependencyValues._current.navigationID.next(), $0) という形で埋め込んでいる。

引き続き PresentationState のコードをもう少し抜粋する。

@propertyWrapper
public enum PresentationState<State> {
  case dismissed
  indirect case presented(id: AnyHashable, State)

  // ...

  public var wrappedValue: State? {
    // ...
  }

  public var projectedValue: Self {
    _read { yield self }
    _modify { yield &self }
  }

  public var id: AnyHashable? {
    switch self {
    case .dismissed:
      return nil
    case let .presented(id, _):
      return id
    }
  }
}

id という computed property は非常にシンプルで、self.presented であるなら、その associated value として埋め込まれている id を返却するだけのものとなっている。

projectedValue という computed property の方は、若干特殊で _read, _modify, yield を利用して表現されている。
_read, _modify は accessor coroutines と呼ばれるものらしく、computed property における getset にかかるコストを削減するための代替手段らしい

https://forums.swift.org/t/a-roadmap-for-improving-swift-performance-predictability-arc-improvements-and-ownership-control/54206#read-and-modify-accessor-coroutines-for-in-place-borrowing-and-mutation-of-data-structures-4

https://forums.swift.org/t/modify-accessors/31872

get, set と似た役割を果たすと考えると、projectedValue は非常にシンプルな定義に思える。

続いて wrappedValue のコードを見てみる

@propertyWrapper
public enum PresentationState<State> {
  case dismissed
  indirect case presented(id: AnyHashable, State)

  // ...

  public var wrappedValue: State? {
    _read {
      switch self {
      case .dismissed:
        yield nil
      case let .presented(_, state):
        yield state
      }
    }
    _modify {
      switch self {
      case .dismissed:
        var state: State? = nil
        yield &state
      case let .presented(id, state):
        var state: State! = state
        yield &state
        self = .presented(id: id, state)
      }
    }
    set {
      // TODO: Do we need similar for the navigation APIs?
      // TODO: Should we _always_ reuse the `id` when value is non-nil, even when enum tags differ?
      guard
        let newValue = newValue,
        case let .presented(id, oldValue) = self,
        enumTag(oldValue) == enumTag(newValue)
      else {
        self = .init(wrappedValue: newValue)
        return
      }

      self = .presented(id: id, newValue)

      // TODO: Should we add state.$destination.present(...) for explicitly re-presenting new ID?
      // TODO: Should we do the following instead (we used to)?
      //self = .init(wrappedValue: newValue)
    }
  }

だいぶ長い。
ここでは _read, _modify, set が利用されている。
_modify, set って同時に利用できるの?と思ったが、先ほどの Pitch に、

it is also possible to supply both a modify and a set. The set will be called in the case of straight assignment, which may be more efficient than first fetching/creating a value to then be overwritten

と書かれており、呼び出されるケースが異なるため、併用できるっぽいことが書かれていた。

だいぶ長いので、_read, _modify, set それぞれについて見ていくことにする。

  public var wrappedValue: State? {
    _read {
      switch self {
      case .dismissed:
        yield nil
      case let .presented(_, state):
        yield state
      }
    }
    // ...
  }

_readself の case に応じて、dismissed であれば nil を返すし、presented であれば何らかの State 型の state を持っているので、その state を返すだけのシンプルな処理となっている。

  public var wrappedValue: State? {
    // ...
    _modify {
      switch self {
      case .dismissed:
        var state: State? = nil
        yield &state
      case let .presented(id, state):
        var state: State! = state
        yield &state
        self = .presented(id: id, state)
      }
    }
    // ...
  }

_modifyself の case に応じて処理が異なっている。
dismissed である時に _modify の処理が走ったら (つまり wrappedValue への変更が行われたら)、nil で Optional な State 型の変数を宣言しておき、wrappedValue に与えられた新しい値をその変数に yield している。
presented である時に _modify の処理が走ったら、associated value の state を使って、新しい state という変数を宣言し、wrappedValue に与えられた新しい値をその変数に yield し、その state を使って self を書き換えている。

  public var wrappedValue: State? {
    // ...
    set {
      // TODO: Do we need similar for the navigation APIs?
      // TODO: Should we _always_ reuse the `id` when value is non-nil, even when enum tags differ?
      guard
        let newValue = newValue,
        case let .presented(id, oldValue) = self,
        enumTag(oldValue) == enumTag(newValue)
      else {
        self = .init(wrappedValue: newValue)
        return
      }

      self = .presented(id: id, newValue)

      // TODO: Should we add state.$destination.present(...) for explicitly re-presenting new ID?
      // TODO: Should we do the following instead (we used to)?
      //self = .init(wrappedValue: newValue)
    }
  }

set では newValueoldValueenumTag というものを使って比較し、guard の結果に応じて newValueself にセットしている。
enumTag というのは、TCA の中で新たに追加されている EnumTag.swift に定義されていて、以下のようになっている。

func enumTag<Case>(_ `case`: Case) -> UInt32? {
  let metadataPtr = unsafeBitCast(type(of: `case`), to: UnsafeRawPointer.self)
  let kind = metadataPtr.load(as: Int.self)
  let isEnumOrOptional = kind == 0x201 || kind == 0x202
  guard isEnumOrOptional else { return nil }
  let vwtPtr = (metadataPtr - MemoryLayout<UnsafeRawPointer>.size).load(as: UnsafeRawPointer.self)
  let vwt = vwtPtr.load(as: EnumValueWitnessTable.self)
  return withUnsafePointer(to: `case`) { vwt.getEnumTag($0, metadataPtr) }
}

struct EnumValueWitnessTable {
  let f1, f2, f3, f4, f5, f6, f7, f8: UnsafeRawPointer
  let f9, f10: Int
  let f11, f12: UInt32
  let getEnumTag: @convention(c) (UnsafeRawPointer, UnsafeRawPointer) -> UInt32
  let f13, f14: UnsafeRawPointer
}

この enumTag には oldValue もしくは newValue、つまり State 型の enum (制限は見当たらないが、大体は enum になるはず) が渡されることになる。
enumTag では、enum を受け取って UnsafeRawPointer にキャストしたりしつつ、UInt32? を取り出している。(知識がなさすぎて処理があまり理解できていないので、この辺りはいずれ勉強)

PresentationState については、他には Decodable などの準拠のための実装があるのみとなっている。

アイカワアイカワ

PresentationAction, PresentationActionOf

PresentationAction の実装は以下のように少ない。

public enum PresentationAction<State, Action> {
  case dismiss
  // NB: sending present(id, nil) from the view means let the reducer hydrate state
  case present(id: AnyHashable = DependencyValues._current.uuid(), State? = nil)
  case presented(Action)

  public static var present: Self { .present() }
}

public func ~= <State, Action, ID: Hashable> (
  lhs: ID, rhs: PresentationAction<State, Action>
) -> Bool {
  guard case .present(AnyHashable(lhs), _) = rhs else { return false }
  return true
}

public typealias PresentationActionOf<R: ReducerProtocol> = PresentationAction<R.State, R.Action>

// TODO:
//extension PresentationAction: Decodable where State: Decodable, Action: Decodable {}
//extension PresentationAction: Encodable where State: Encodable, Action: Encodable {}

extension PresentationAction: Equatable where State: Equatable, Action: Equatable {}
extension PresentationAction: Hashable where State: Hashable, Action: Hashable {}

PresentationActionOfPresentationStateOf と同じように、typealias として宣言されている。

PresentationActionStateAction を Generics として必要としていて、3 つの case と 1 つの property から構成されている。

public enum PresentationAction<State, Action> {
  case dismiss
  // NB: sending present(id, nil) from the view means let the reducer hydrate state
  case present(id: AnyHashable = DependencyValues._current.uuid(), State? = nil)
  case presented(Action)

  public static var present: Self { .present() }
}

Reducer の処理を見るまではなんとも言えないけれど、それぞれ以下のような感じだろうか。

  • dismiss: present している View を dismiss させるための Action
  • present (case): View を一意に識別できる id と共に present させるための Action
  • presented: View が present された際に呼ばれる Action ?
  • present (property): 何に使う?

上記の Action の使われ方については後ほど詳しく見ていくとして、これ以外に主要な実装は以下の部分がある。

public func ~= <State, Action, ID: Hashable> (
  lhs: ID, rhs: PresentationAction<State, Action>
) -> Bool {
  guard case .present(AnyHashable(lhs), _) = rhs else { return false }
  return true
}

~= を自分で定義して、Hashable な IDPresentationAction とマッチできるようにしている。(Expression Pattern)
この overload は、TicTacToe Example の LoginCore.swift の以下のコードで利用されているっぽい。

  public var body: some ReducerProtocol<State, Action> {
    Reduce { state, action in
      switch action {
      case .destination(ObjectIdentifier(TwoFactor.self)): // ← ここ
        state.isLoginRequestInFlight = true
        return .task { [email = state.email, password = state.password] in
          .loginResponse(
            await TaskResult {
              try await self.authenticationClient.login(
                .init(email: email, password: password)
              )
            }
          )
        }
アイカワアイカワ

ReducerProtocol.presentationDestination(_:action:destination:file:fileID:line)

ここが主要なロジックが定義されている部分になるので、一番読み解くのが大変そう。
コード自体もそこそこの長さなので、コード全体を貼るのはやめておいて少しずつ見ていくことにする。

全体的な構成としては以下のようになっている。

コードを展開
extension ReducerProtocol {
  @inlinable
  public func presentationDestination<Destination: ReducerProtocol>(
    _ toPresentedState: WritableKeyPath<State, PresentationStateOf<Destination>>,
    action toPresentedAction: CasePath<Action, PresentationActionOf<Destination>>,
    @ReducerBuilderOf<Destination> destination: () -> Destination,
    file: StaticString = #file,
    fileID: StaticString = #fileID,
    line: UInt = #line
  ) -> _PresentationDestinationReducer<Self, Destination> {
    _PresentationDestinationReducer(
      presenter: self,
      presented: destination(),
      toPresentedState: toPresentedState,
      toPresentedAction: toPresentedAction,
      file: file,
      fileID: fileID,
      line: line
    )
  }
}

public struct _PresentationDestinationReducer<
  Presenter: ReducerProtocol, Presented: ReducerProtocol
>: ReducerProtocol {
  @usableFromInline
  let presenter: Presenter

  @usableFromInline
  let presented: Presented

  @usableFromInline
  let toPresentedState: WritableKeyPath<Presenter.State, PresentationStateOf<Presented>>

  @usableFromInline
  let toPresentedAction:
    CasePath<
      Presenter.Action, PresentationActionOf<Presented>
    >

  @usableFromInline
  let file: StaticString

  @usableFromInline
  let fileID: StaticString

  @usableFromInline
  let line: UInt

  @usableFromInline
  enum DismissID {}

  @inlinable
  init(
    presenter: Presenter,
    presented: Presented,
    toPresentedState: WritableKeyPath<Presenter.State, PresentationStateOf<Presented>>,
    toPresentedAction: CasePath<Presenter.Action, PresentationActionOf<Presented>>,
    file: StaticString,
    fileID: StaticString,
    line: UInt
  ) {
    self.presenter = presenter
    self.presented = presented
    self.toPresentedState = toPresentedState
    self.toPresentedAction = toPresentedAction
    self.file = file
    self.fileID = fileID
    self.line = line
  }

  @inlinable
  public func reduce(
    into state: inout Presenter.State,
    action: Presenter.Action
  ) -> EffectTask<Presenter.Action> {
    // ...
  }
}

ReducerProtocol.presentationDestination はいくつかの引数を受け取って、_PresentationDestinationReducer という ReducerProtocol に準拠した struct を作り出すものとなっている。

_PresentationDestinationReducer は、いくつかの property と 1 つの reduce という function で構成されている。
まずは _PresentationDestinationReducer の property それぞれについて見てみる。

public struct _PresentationDestinationReducer<
  Presenter: ReducerProtocol, Presented: ReducerProtocol
>: ReducerProtocol {
  @usableFromInline
  let presenter: Presenter

  @usableFromInline
  let presented: Presented

  @usableFromInline
  let toPresentedState: WritableKeyPath<Presenter.State, PresentationStateOf<Presented>>

  @usableFromInline
  let toPresentedAction:
    CasePath<
      Presenter.Action, PresentationActionOf<Presented>
    >

  @usableFromInline
  let file: StaticString

  @usableFromInline
  let fileID: StaticString

  @usableFromInline
  let line: UInt

  @usableFromInline
  enum DismissID {}

  // ...
}

コンパイラ最適化のための @inlinable, @usableFromInline があるので (実際にはこれらを利用したことがないので、どのくらい意味があるのかはしっかり理解できていない。Attributes をチラ見したくらい。) 長く見えるが、登場しているものは以下のようになっている。

  • presenter
    • ReducerProtocol に準拠する Presenter という Generics
  • presented
    • ReducerProtocol に準拠する Presented という Generics
  • toPresentedState
    • `WritableKeyPath<Presenter.State, PresentationStateOf<Presented>>
  • toPresentedAction
    • `CasePath<Presenter.Action, PresentationActionOf<Presented>>
  • file, fileID, line
    • 名前の通り。割愛
  • DismissID
    • 空っぽの enum

先にコードを眺めたからわかる話にはなるが、

  • Presenter: PresentationStateOfPresentationActionOf を保持する parent reducer
  • Presented: PresentationStateOfPresentationActionOf で利用される reducer

となるので、そのように捉えておくと話がわかりやすいかもしれない。

実際にどんな処理が行われるのかを理解するために reduce function を少しずつ見ていってみる。

まず冒頭

  public func reduce(
    into state: inout Presenter.State,
    action: Presenter.Action
  ) -> EffectTask<Presenter.Action> {
    var effects: [EffectTask<Presenter.Action>] = []

    let currentPresentedState = state[keyPath: self.toPresentedState]
    let presentedAction = self.toPresentedAction.extract(from: action)

ReducerProtocol に準拠するための reduce function では、statePresenter.State, actionPresenter.Action としていることがわかる。

この function の冒頭では、[EffectTask<Presenter.Action>] 型の effects という array を用意し、stateaction から KeyPath, CasePath を用いて state に埋め込まれている PresentedStateaction に埋め込まれている PresentedAction を取り出している。

次はちょっと長めの switch 文が出てくる。

    switch presentedAction {
    case let .present(id, .some(presentedState)):
      state[keyPath: self.toPresentedState] = .presented(id: id, presentedState)

    case let .presented(presentedAction):
      if case .presented(let id, var presentedState) = currentPresentedState {
        effects.append(
          self.presented
            .dependency(\.dismiss, DismissEffect { Task.cancel(id: DismissID.self) })
            .dependency(\.navigationID.current, id)
            .reduce(into: &presentedState, action: presentedAction)
            .map { self.toPresentedAction.embed(.presented($0)) }
            .cancellable(id: id)
        )

        if isAlertState(presentedState) {
          state[keyPath: self.toPresentedState] = .dismissed
        } else {
          state[keyPath: self.toPresentedState] = .presented(id: id, presentedState)
        }
      } else {
        runtimeWarn(...)
        return .none
      }

    case .present(_, .none), .dismiss, .none:
      break
    }

長かったので、コメントと runtimeWarn の中身は省略した。

ここでは presentedAction を switch 文で分岐して処理を行っている。
その中でも presentedAction.present(id, .some(presentedState)) の時と .presented(presentedAction) の時のみ処理を行っており、それ以外の場合は break している。

.presentedAction.present(id, .some(presentedState)) だった場合は、何かの View を present したいということなので、state[keyPath: self.toPresentedState] = .presented(id: id, presentedState) という形で、state を変更している。

.presentedAction.presented(presentedAction) だった場合についても詳しく見る。

    case let .presented(presentedAction):
      if case .presented(let id, var presentedState) = currentPresentedState {
        effects.append(
          self.presented
            .dependency(\.dismiss, DismissEffect { Task.cancel(id: DismissID.self) })
            .dependency(\.navigationID.current, id)
            .reduce(into: &presentedState, action: presentedAction)
            .map { self.toPresentedAction.embed(.presented($0)) }
            .cancellable(id: id)
        )

        if isAlertState(presentedState) {
          state[keyPath: self.toPresentedState] = .dismissed
        } else {
          state[keyPath: self.toPresentedState] = .presented(id: id, presentedState)
        }
      } else {
        runtimeWarn(...)
        return .none
      }

この case let .presented(presentedAction) な Action は、

struct SampleReducer: ReducerProtocol {
  struct State: Equatable {
    @PresentationStateOf<Destinations> var destination
  }

  enum Action: Equatable {
    case destination(PresentationActionOf<Destinations>)
  }

  struct Destinations: ReducerProtocol {
    enum State: Equatable {
      case animations(Animations.State)
      case counter(Counter.State)
    }

    enum Action: Equatable {
      case animations(Animations.Action)
      case counter(Counter.Action)
    }
  }
}

的なものがあった場合に、AnimationsCounter で起こった全ての Action で発火してしまうものになっている気がする。(だからパフォーマンスを気にしていそうな処理が散見されるのかもしれない)

ここでは以下のように処理が分岐している。

  • currentPresentedState.presented(let id, var presentedState) である場合
    • まず presented の dependency を override しつつ reduce(into:action:) を実行し、.presented に action を埋め込んだ effect を effects に append する
      • ここで override している dependency は dismissnavigationID.current の 2 つ。これで dismiss したい時には Reducer で dismiss() のように呼べば、開かれている View を閉じることができるようになっている
    • その後に alert かどうかに応じて state を変更する
  • そうではない場合
    • 「何も View が present されていない時に .presented action が発火している = 異常な状態」なので runtime warning を発生させる

ということを行っている。

その後に続く処理も引き続いて見てみる。

    effects.append(self.presenter.reduce(into: &state, action: action))

    if case .dismiss = presentedAction, case let .presented(id, _) = currentPresentedState {
      state[keyPath: self.toPresentedState].wrappedValue = nil
      effects.append(.cancel(id: id))
    } else if case let .presented(id, _) = currentPresentedState,
      state[keyPath: self.toPresentedState].id != id
    {
      effects.append(.cancel(id: id))
    }

まず先ほどまでに説明していた処理が行われた後で、View を present する元のロジックである presenter.reduce(into:action:)effects に append している。

その次は以下のように処理が分かれている。

  • if case .dismiss = presentedAction, case let .presented(id, _) = currentPresentedState つまり、presentedActiondismiss で、currentPresentedState.presented である場合は、何らかの View が表示されている時に dismiss したいという action が送られていることを意味するため、state に KeyPath でアクセスして、nil を書き込み、effects.cancel も append している。
  • if case let .presented(id, _) = currentPresentedState かつ state[keyPath: self.toPresentedState].id != id つまり、何らかの View が表示されている時に何らかの View を表示しようとしている場合は、元々の View を dismiss する必要があるため、effects.cancel を append している。

次の部分は以下のようなコードになっている。

    let tmp = state[keyPath: self.toPresentedState] // TODO: better name, write tests
    if
      let id = tmp.id,
      id != currentPresentedState.id,
      // NB: Don't start lifecycle effect for alerts
      //     TODO: handle confirmation dialogs too
      tmp.wrappedValue.map(isAlertState) != true
    {
      effects.append(
        .run { send in
          do {
            try await withDependencies {
              $0.navigationID.current = id
            } operation: {
              try await withTaskCancellation(id: DismissID.self) {
                try await Task.never()
              }
            }
          } catch is CancellationError {
            await send(self.toPresentedAction.embed(.dismiss))
          }
        }
        .cancellable(id: id)
      )
    }

やっていることとしては、最終的な state[keyPath: self.toPresentedState] つまり表示されている View の状態から id を抽出し、dependency を override しつつ withTaskCancellationDismissID によるキャンセル処理を監視して、表示されている View の dismiss のトリガーとするような effect を effects に append している。

最後に以下のコードで effectsmerge して実行している

return .merge(effects)
アイカワアイカワ

View extension によるヘルパー function 群

View extension によるヘルパー function は色々なものが定義されているが、大体は同じような実装になっているため、sheet の実装のみ追うことにする。

まず以下に全体像を示す。

コードを展開
extension View {
  public func sheet<State, Action, Content: View>(
    store: Store<PresentationState<State>, PresentationAction<State, Action>>,
    @ViewBuilder content: @escaping (Store<State, Action>) -> Content
  ) -> some View {
    self.sheet(store: store, state: { $0 }, action: { $0 }, content: content)
  }

  public func sheet<State, Action, DestinationState, DestinationAction, Content: View>(
    store: Store<PresentationState<State>, PresentationAction<State, Action>>,
    state toDestinationState: @escaping (State) -> DestinationState?,
    action fromDestinationAction: @escaping (DestinationAction) -> Action,
    @ViewBuilder content: @escaping (Store<DestinationState, DestinationAction>) -> Content
  ) -> some View {
    WithViewStore(store, removeDuplicates: { $0.id == $1.id }) { viewStore in
      self.sheet(
        item: viewStore.binding(
          get: { Item(destinations: $0, destination: toDestinationState) }, send: .dismiss
        )
      ) { _ in
        IfLetStore(
          store.scope(
            state: returningLastNonNilValue { $0.wrappedValue.flatMap(toDestinationState) },
            action: { .presented(fromDestinationAction($0)) }
          ),
          then: content
        )
      }
    }
  }
  // ...
}

private struct Item: Identifiable {
  let id: AnyHashable

  init?<Destination, Destinations>(
    destinations: PresentationState<Destinations>,
    destination toDestination: (Destinations) -> Destination?
  ) {
    guard
      case let .presented(id, destinations) = destinations,
      toDestination(destinations) != nil
    else { return nil }

    self.id = id
  }
}

利用部分のコード

      .sheet(
        store: self.store.scope(state: \.$destination, action: SheetDemo.Action.destination),
        state: /SheetDemo.Destinations.State.animations,
        action: SheetDemo.Destinations.Action.animations
      ) { store in
        // ...
      }

利用箇所と照らし合わせると、そこまで難しい場所はなかったので割愛 (疲れてしまった)

アイカワアイカワ

もう一度 SheetDemo を見てみるメモ

例えば sheet であれば、以下のように表示している。

      .sheet(
        store: self.store.scope(state: \.$destination, action: SheetDemo.Action.destination),
        state: /SheetDemo.Destinations.State.animations,
        action: SheetDemo.Destinations.Action.animations
      ) { store in

\.$destination という形で @PresentationStateOf<Destinations> の projectedValue にアクセスしている。
@PresentationState の projectedValue は Self つまり、PresentationState 自身なので、store.scope に対して \.$destinationPresentationState への KeyPath を渡すことができている。

sheet によって表示される View では以下のようなことを行っている。

        VStack {
          HStack {
            Button("Swap") {
              viewStore.send(.swap, animation: .default)
            }
            Button("Close") {
              viewStore.send(.destination(.dismiss))
            }
          }
          .padding()

          AnimationsView(store: store)
          Spacer()
        }

viewStore.send(.swap, animation: .default)viewStore.send(.destination(.dismiss)) がある。

viewStore.send(.destination(.dismiss)) は、以下のように定義されているため、PresentationActionOf 経由で .destination(.dismiss) のように Action を送ることができ、これによって View を閉じる Reducer のロジックを発火させることができている。

struct SheetDemo: ReducerProtocol {
  // ...

  enum Action: Equatable {
    case destination(PresentationActionOf<Destinations>)
    case swap
  }
  // ...

  struct Destinations: ReducerProtocol {
    // ...

    enum Action: Equatable {
      case alert(AlertAction)
      case animations(Animations.Action)
      case counter(Counter.Action)
    }

    // ...
}

viewStore.send(.swap, animation: .default) の方では、以下のようなロジックが実行されている。

      case .swap:
        switch state.destination {
        case .some(.animations):
          state.destination = .counter(Counter.State())
        case .some(.counter):
          state.destination = .animations(Animations.State())
        case .some(.alert), .none:
          break
        }
        return .none

現在の destination の状態に応じて、遷移先を新たにセットしているようだ。
ここで、state.destination とアクセスしたり、state.destination = ... とアクセスしているので、これらは全て wrappedValue としての destination にアクセスしていることがわかる。

form という View は以下のようなコードになっている。

func form(_ viewStore: ViewStore<Void, SheetDemo.Action>) -> some View {
  Form {
    Button("Alert") { viewStore.send(.destination(.present(.alert(.alert)))) }
    Button("Animations") {
      viewStore.send(.destination(.present(.animations(Animations.State()))))
    }
    Button("Counter") {
      viewStore.send(.destination(.present(.counter(Counter.State()))))
    }
  }
}

ここでも PresentationActionOf を通じることによって、.destination(.present(...)) という形で Action を送信し、それによって View の present を表現しているらしい。

このように現状は Action を送って画面遷移させることもできるし、Reducer の中で state を変更することでも画面遷移を表現できる結構柔軟な実装となっているらしい。

このスクラップは2023/01/27にクローズされました