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
を見て、まずはどんな風に利用しているのかを見てみる。
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 の実装がどのようになっているかも見てみる。
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)
// }
//}
PresentationStateOf
は PresentationState
の 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
で、projectedValue
は PresentationState<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 における get
と set
にかかるコストを削減するための代替手段らしい
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
}
}
// ...
}
_read
は self
の 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)
}
}
// ...
}
_modify
も self
の 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
では newValue
と oldValue
を enumTag
というものを使って比較し、guard
の結果に応じて newValue
を self
にセットしている。
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 {}
PresentationActionOf
は PresentationStateOf
と同じように、typealias として宣言されている。
PresentationAction
は State
と Action
を 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 な ID
で PresentationAction
とマッチできるようにしている。(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
:PresentationStateOf
やPresentationActionOf
を保持する parent reducer -
Presented
:PresentationStateOf
やPresentationActionOf
で利用される 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 では、state
を Presenter.State
, action
を Presenter.Action
としていることがわかる。
この function の冒頭では、[EffectTask<Presenter.Action>]
型の effects
という array を用意し、state
と action
から KeyPath, CasePath を用いて state
に埋め込まれている PresentedState
と action
に埋め込まれている 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)
}
}
}
的なものがあった場合に、Animations
や Counter
で起こった全ての Action で発火してしまうものになっている気がする。(だからパフォーマンスを気にしていそうな処理が散見されるのかもしれない)
ここでは以下のように処理が分岐している。
-
currentPresentedState
が.presented(let id, var presentedState)
である場合- まず
presented
の dependency を override しつつreduce(into:action:)
を実行し、.presented
に action を埋め込んだ effect をeffects
に append する- ここで override している dependency は
dismiss
とnavigationID.current
の 2 つ。これで dismiss したい時には Reducer でdismiss()
のように呼べば、開かれている View を閉じることができるようになっている
- ここで override している dependency は
- その後に alert かどうかに応じて
state
を変更する
- まず
- そうではない場合
- 「何も View が present されていない時に
.presented
action が発火している = 異常な状態」なので runtime warning を発生させる
- 「何も View が present されていない時に
ということを行っている。
その後に続く処理も引き続いて見てみる。
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
つまり、presentedAction
がdismiss
で、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 しつつ withTaskCancellation
で DismissID
によるキャンセル処理を監視して、表示されている View の dismiss
のトリガーとするような effect を effects
に append している。
最後に以下のコードで effects
を merge
して実行している
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
に対して \.$destination
で PresentationState
への 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
を変更することでも画面遷移を表現できる結構柔軟な実装となっているらしい。