Swiftの文字列補間をカスタマイズする
以下の3つのProtocolを用いることで、Swiftの文字列補間(式展開, 変数展開などとも言う)をカスタマイズすることができる。
文字列リテラルで任意の型を初期化する
ExpressibleByStringLiteralは、準拠した型を文字列リテラルで初期化することを可能にするProtocolで、StringもこのProtocolに準拠している。
このProtocolを任意の型に準拠させることで、String同様に文字列リテラルで初期化することができる。ただ、このProtocolに準拠するのみだと文字列補間を扱うことはできない。
struct Note { let value: String } // 文字列リテラルでNoteをinitできるようになる extension Note: ExpressibleByStringLiteral { init(stringLiteral value: StringLiteralType) { self.init(value: value) } } let note1: Note = "NOTE1" print(note1.value) // → NOTE // 文字列補完は扱えない let note2: Note = "NOTE\(2)" // → error: cannot convert value of type 'String' to specified type 'Note'
文字列補間を含む文字列リテラルで任意の型を初期化する
文字列補間を可能にするためには、ExpressibleByStringInterpolationに準拠する必要がある。StringはこのProtocolに準拠しているため文字列補間を含む文字列リテラルで初期化できるようになっている。
上記のコードにおいて、NoteはこのProtocolに準拠しておらず文字列補間が行えないため、文字列リテラル "NOTE\(2)"
はStringとして認識されて error: cannot convert value of type 'String' to specified type 'Note'
というエラーが出力されている。
ExpressibleByStringInterpolationを任意の型に準拠させると、String同様に文字列補間を含む文字列リテラルで初期化できるようになる。
ちなみに、ExpressibleByStringInterpolationはExpressibleByStringLiteralを継承している。
struct Note { let value: String } // String同様の文字列補間をNoteのinitで使えるようになる extension Note: ExpressibleByStringInterpolation { // MARK: - ExpressibleByStringLiteral init(stringLiteral value: StringLiteralType) { self.init(value: value) } } let note2: Note = "NOTE\(2)" print(note2.value) // → NOTE2
文字列補間をカスタマイズする
ExpressibleByStringInterpolationは以下のStringInterpolationというStringInterpolationProtocolに準拠したassociatedtypeを持ち、このStringInterpolationを拡張することで文字列補間をカスタマイズすることができる。
associatedtype StringInterpolation : StringInterpolationProtocol = DefaultStringInterpolation where Self.StringLiteralType == Self.StringInterpolation.StringLiteralType
例えば、StringのStringInterpolationを拡張することで、Stringの文字列リテラル内で下記のような文字列補間を有効にできる。具体的には、以下のようにappendInterpolation
メソッドをoverloadすることで行う。overloadのため、任意の引数をもつメソッドを任意の数だけ追加でき、利用時にはコード補完もされる。
extension String.StringInterpolation { mutating func appendInterpolation(if bool: Bool, _ str: String) { if bool { appendLiteral(str) } } } print("\(if: true, "TRUE")") // → TRUE
StringのStringInterpolationはDefaultStringInterpolationであるため、DefaultStringInterpolationが適用されている他の型でも同様の文字列補間を利用できる。
extension String.StringInterpolation { mutating func appendInterpolation(if bool: Bool, _ str: String) { if bool { appendLiteral(str) } } } struct Note { let value: String } extension Note: ExpressibleByStringInterpolation { // MARK: - ExpressibleByStringLiteral init(stringLiteral value: StringLiteralType) { self.init(value: value) } } let note: Note = "NOTE \(if: true, "TRUE")" print(note.value) // → NOTE TRUE
また、StringInterpolationを自作することで独自の文字列補間のみを利用することもできる。この場合、DefaultStringInterpolationで実装されている文字列補間は利用できなくなる。
struct Note { let value: String } extension Note: ExpressibleByStringInterpolation { // MARK: - ExpressibleByStringLiteral init(stringLiteral value: StringLiteralType) { self.init(value: value) } init(stringInterpolation: StringInterpolation) { self.init(stringLiteral: stringInterpolation.output) } struct StringInterpolation: StringInterpolationProtocol { var output = "" init(literalCapacity: Int, interpolationCount: Int) { output.reserveCapacity(literalCapacity) } mutating func appendLiteral(_ literal: String) { output.append(literal) } mutating func appendInterpolation(ifNot bool: Bool, _ str: String) { if !bool { appendLiteral(str) } } } } let note: Note = "NOTE \(ifNot: false, "FALSE")" print(note.value) // → NOTE FALSE
機能のモジュール化
機能単位でモジュール分割するにあたってどのように依存解決したら良いかを考えてみた。 以下のリポジトリであれこれしながら考えた。
機能モジュールとは
- 機能単位でモジュールを作成していくこと
- 上記の図のように Feature A 画面と Feature B 画面がある場合はそれぞれをモジュールにする
- 複数の画面をまとめて単一のモジュールにしてもよい
- Swift Package Manager を前提としている
- ビルドがめちゃくちゃ高速化される
- 機能単位でビルドできる
- Xcode Preview が利用できるようになる
- アプリを Run しなくてもレイアウトを確認できるようになる
- 責務を自然と意識することになる
対応の方針
モジュールの循環依存
- 機能モジュールは循環依存の関係が生じやすい
- Feature A.画面から Feature B 画面に遷移できる
- Feature B 画面から Feature A 画面に遷移できる
- 循環依存が生じているとビルドできない
// A Feature モジュール import BFeature public struct AView: View { func transition() -> { let bView = BView() let host = UIHostingController(rootView: bView) navigationController?.push(host) } } // B Feature モジュール import AFeature public struct BView: View { func transition() -> { let aView = AView() let host = UIHostingController(rootView: aView) navigationController?.push(host) } }
インターフェースモジュールによる依存解決
- 循環依存を防ぐためにインターフェースを分離してモジュール(Environment)とする
- 機能モジュールはインターフェースを介して実体を取得する
- App Target ではインターフェースの実体を提供する
- 各 Feature に DI する
- Environment には View のインスタンスを提供するインターフェース(View Buildable)を持つ
- App では各 View Buildable に準拠した Builder を持つ
- 各 Feature の View の初期化時に Builder を DI する
- 各 Feature の View は Builder を介して他の Feature の View を取得する
- Builder は Protocol Composition パターンで DI を実現する
- 各 Feature で必要なインターフェースの実装漏れを静的に検知できる
- 各 Feature で必要なインターフェースのみを公開できる
// Environment モジュール public protocol AViewBuildable { func buildAView(id: Int) -> AnyView } public protocol BViewBuildable { func buildBView() -> UIViewController } // App import AFeature import BFeature struct AppViewBuilder {} extension AppViewBuilder: AViewBuildable { func buildAView(id: Int) -> AnyView { let viewModel = AViewModel(id: id) return AnyView(AView(builder: self, viewModel: viewModel)) } } extension AppViewBuilder: BViewBuildable { func buildBView() -> UIViewController { let repository = BRepository() let viewModel = BViewModel(repository: repository) return BViewController(builder: self, viewModel: viewModel) } } // A Feature モジュール import Environment public struct AView: View { // 必要な View のインターフェースのみにアクセスを限定できる // DI された builder が指定したインターフェースを提供しない場合にはコンパイルエラーで検知できる typealias ViewBuildable = BViewBuildable & ... private let builder: ViewBuildable public init(builder: ViewBuildable, viewModel: View) { self.builder = builder self.viewModel = viewModel } func transition() -> { let bView = builder.buildBView() let host = UIHostingController(rootView: bView) navigationController?.push(host) }
Xcode Preview における実体の差し替え
Xcode Preview 用の Builder
// Environment モジュール public protocol XcodePreviewBuildable: AViewBuildable & BViewBuildable // 他のモジュールから初期化できないように internal に struct XcodePreviewViewBuilder: XcodePreviewBuildable { extension XcodePreviewBuilder { func buildAView(id: Int) -> AnyView { AnyView(Color.green) } func buildBView() -> UIViewController { .init() } } // PreviewProvider 内でのみ PreviewBuilder を参照できるように public extension PreviewProvider { static var previewBuilder: some XcodePreviewBuildable { XcodePreviewBuilder() } } // A Feature モジュール import Environment struct AView_Previews: PreviewProvider { static var previews: some View { AView( builder: previewBuilder, // Xcode Preview 用の Builder を DI viewModel: AViewModel(id: 1) ) } }
💡 Tips
Generics View の body で @ViewBuilder
をもつ View を使うと Xcode Preview がクラッシュするので @ViewBuilder
をもつ View を作りたいときは @ViewBuilder
なメソッドを用いる。(Xcode 13.3.1 で確認)
// ⛔️ struct FeatureView<T>: View { var body: some View { SubView { Text("クラッシュする") } } struct SubView<T: View>: View { @ViewBuilder let content: T var body: some View { content } } }
// ✅ struct FeatureView<T>: View { var body: some View { subView { Text("クラッシュしない") } } func subView<T: View>(@ViewBuilder content: () -> T) -> some View { content() } }
ViewModel の DI をしていく
ViewModel の DI ができないと困る
- 任意の状態を再現するために ViewModel のロジックを把握して操作する必要がある
- 全ての依存性の Mock を準備する必要がある
- View の実装時はレイアウトのみに集中したい
final class AViewModel: ObservableObject { @Published private(set) var items: [Item] = [] init( id: Int, repository1: Repository1Protocol, repository2, Repository2Protocol ) { self.id = id self.repository1 = repository1 self.repository2 = repository2 } func buttonDidTap() { ... // 任意の items の状態で Preview したいときに repository を操作する必要がある // View のレイアウト実装時に ViewModel のロジックを意識しないといけない items = repository.getItems() ... } } struct AView_Previews: PreviewProvider { static var previews: some View { // ViewModel の依存性の全てを準備する必要がある let repository1 = Repository1Mock() let repository2 = Repository2Mock() let viewModel = AViewModel( id: 1, repository1: repository2, repository2: repository2 ) AView( builder: previewBuilder, viewModel: viewModel ) } }
DI できるようにするために
- Observable Object を継承する Protocol に準拠させる
- @Published プロパティから
viewModel.$items
のような Publisherの生成ができなくなる- Protocol で Publisher のインターフェースを定義すれば良い
- そもそも Publisher が必要なケースはなさそう
onReceive(_:perform:)
が使いたい場合はonChange(of:perform:)
を使えるはずで Publisher がそもそも不要になる
- Binding は
$viewModel.items
ので作れる
protocol AViewModelProtocol: ObservableObject { var items: [Item] { get } func buttonDidTap() } final class AViewModel: AViewModelProtocol { @Published private(set) var items: [Item] = [] init( id: Int, repository1: Repository1Protocol, repository2, Repository2Protocol ) { self.id = id self.repository1 = repository1 self.repository2 = repository2 } func buttonDidTap() { ... items = repository.getItems() ... } } struct AView_Previews: PreviewProvider { class ViewModel: AViewModelProtocol { let items = Array(repeating: Items(), count: 10) func buttonDidTap() {} } static var previews: some View { AView( builder: previewBuilder, viewModel: ViewModel() ) } }
機能モジュール作成手順 📝
Swift Package の作成
- メニューから Swift Package を作る
- File > New > Package
- Add to は App
- Group は Package
- 不要なディレクトリ・ファイルを削除する(場合によって)
- .gitignore
- README.md
- Package.swift を記述する
// swift-tools-version: 5.6 import PackageDescription let package = Package( name: "Logger", platforms: [.iOS(.v14)], products: [ // ライブラリの定義。これを定義すると App Target や他の Swift Package から参照できる。 .library(name: "Logger", targets: ["Logger"]) ], dependencies: [ // 依存するライブラリの定義。 .package(name: "Entity", path: "../Entity"), .package(name: "Extensions", path: "../Extensions") ], targets: [ // ライブラリに含めるターゲット。 .target( name: "Logger", dependencies: ["Entity", "Extensions"], path: "./Sources" ) ] )
実装
- View, ViewModel, Repository などを機能モジュールで実装する
- Buildable(機能のインターフェース)を Environment に定義する
- Builder で準拠する
課題・理想
- 各種 Assets の扱いについて
- Assets 用のモジュールを作る or 各モジュールに Assets を持たせてしまうとよさそう
- 複数の Package で使う外部依存性が重複する
- 単一の Swift Package にする
- モジュールのテストについて
- CI では差分のあるモジュールを検知して該当モジュールのテストだけ実行するとよさそう
- Xcode Preview Builder は自動生成したい
リソースファイルの扱い方についてメモ
- Apple Developer Documentation
- Swift5.3 だとバグがある?
- https://newbedev.com/use-resources-in-unit-tests-with-swift-package-manager
- https://forums.swift.org/t/5-3-resources-support-not-working-on-with-swift-test/40381/9
- https://forums.swift.org/t/swift-5-3-spm-resources-in-tests-uses-wrong-bundle-path/37051/21
- https://github.com/Nef10/SPMResourcesInTest
- What is Bundle.module?
参考
WWDC2021 で発表された App Store の In-App Events
Meet in-app events on the App Store の内容まとめ。 developer.apple.com
In-App Events とは
Meet in-app events on the App Store - WWDC21 - Videos - Apple Developer
- 下記のようなタイムリーなイベントを App Store 上で紹介することができるようになる
- ゲームの大会
- フィットネスチャレンジ
- 映画の初公開
- プロダクトページ・Todayタブ・ゲームタブ・Appタブ・検索タブとあらゆるところで表示される
表示のされ方
イベントカード
Meet in-app events on the App Store - WWDC21 - Videos - Apple Developer
- イベントはカード状のコンポーネントで表示される
- 画像またはビデオ
- イベント名
- 簡単な説明
- イベントが近づいたり開始したりすると自動的に更新される時間インジケータ
- 開くボタン(アプリインストール済みのユーザ)
- タップするとアプリ内のイベントページが開く
- プロダクトページにおいては下記の位置に表示される
- 検索タブにおいてイベントが表示され方は下記の通り
詳細ページ
Meet in-app events on the App Store - WWDC21 - Videos - Apple Developer
- イベントカードタップでイベント詳細ページに遷移する
- イベント詳細ページにはより詳細な情報を持たせることができる
- App Store URL にユニークなイベントIDを付与することでイベント詳細ページを直接開くこともできる
イベント開始通知
Meet in-app events on the App Store - WWDC21 - Videos - Apple Developer
- ユーザがイベント開始通知を設定することができる
- イベント開始通知は App Store から送信される
- 通知からアプリ内のイベントページを直接起動できる
- ユーザがアプリをインストールしていない場合はイベントの詳細ページが開く
イベント公開フロー
Meet in-app events on the App Store - WWDC21 - Videos - Apple Developer
- イベント作成して保存するとドラフト状態になる
- 公開するにはレビューに提出する必要がある
- 全てのメタデータを入力するとレビューに提出できる
- 新規アプリバージョンやバイナリのアップロードは不要
- レビューが通ると設定したスケジュールに沿って自動で公開される
- レビューが通った状態のイベントを最大で10個まで保持することができる
- 最大で5個まで同時に公開できる
- 上記を満たすことができない設定のイベントはレビューに提出できないようになっている
- イベント公開日時はイベント開始前の14日以内を指定できる
- イベント終了日時はイベント開始後の31日以内を指定できる
- イベント終了日に達すると App Store に公開されなくなる
- イベント終了から30日後まで App Store のリンクからは開くことができる点に注意する
イベントのメタデータ
Meet in-app events on the App Store - WWDC21 - Videos - Apple Developer
- イベント参照名
- App Store Connect 内でのみ表示される名前
- 最大で64文字
- イベント名
- ユーザに見えるイベントの名前
- 最大30文字
- 簡単な説明
- 最大で50文字
- イベントカードに表示される
- 詳細な説明
- 最大で120文字
- イベント詳細ページに表示される
- 画像または動画
- イベントカード用とイベント詳細ページ用のそれぞれ用意する
- 動画は最大30秒
- 動画はループして再生される
- バッジ(オプション)
- イベントの種類を表す
- Challenge
- Competition
- Live Event
- Major Update
- New Season Premiere
- Special Event
- イベントカードとイベン詳細ページに表示される
- イベントの種類を表す
- 国と地域
- アプリを提供している国と地域の中から選択できる
- イベント開始日時
- アプリ内のイベント開始日時
- イベント終了日時
- アプリ内のイベント終了日時
- イベント開始後の31日以内を指定できる
- イベント公開日時
- イベント開始前の14日以内を指定できる
- ディープリンク
- ユニバーサルリンクまたはカスタムURL
- URLの短縮やリダイレクトを行わせるなどのサービスの利用は避ける
- イベントの目的
- App Store 上のパーソナライズに用いられる
- 多くの場合は全てのユーザに向けたイベントであるのでそれがデフォルトになっている
- 全てのユーザ
- 新規ユーザ
- 既存ユーザ
- 離脱ユーザ
- 目的を限定しても検索等で全てのユーザが閲覧できることに注意する
- イベントの優先度
- 2種類ある
- プロダクトページ上で優先度の高い順に並ぶ
- 同一優先度の中では開始日が近い順
- アプリ内購入またはサブスクリプションの必要有無
- イベントカードとイベント詳細ページに表示される
- 第一言語
- デフォルトではアプリと同じ設定になっている
App Analytics で計測できること
- AppStoreでイベントを見た人の数
- ユーザがイベントを閲覧した場所
- 商品ページ
- 検索
- その他の場所など
- アプリを開いたユーザー数
- イベントの詳細ページを開いたユーザー数
- イベントからアプリをダウンロードまたは再ダウンロードしたユーザー数
- イベント通知を有効にしたユーザー数
WWDC2021 で発表された App Store プロダクトページのカスタマイズと最適化
Get ready to optimize your App Store product page の動画の内容まとめ。
この発表では、 App Store のプロダクトページにおける新機能である下記の2つの機能について紹介している。
- 任意の目的のためのプロダクトページカスタマイズ
- App Store 内の検索から訪れたユーザに表示されるプロダクトページの最適化
これらの機能の概要は後述するとおりで、今年の後半に利用できるようになるとのこと。
任意の目的のためのプロダクトページカスタマイズ
Get ready to optimize your App Store product page - WWDC 2021 - Videos - Apple Developer
- プロダクトページの要素を任意の組み合わせで配置できるようになる
- カスタマイズしたページの固有の URL を作成することができる
- App Analytics では URL ごとの数値を確認することができる
- 上記を活用して任意の目的専用のカスタマイズページを作成して効果検証することができる
- 最大で35ページ作成できる
- ページの作成にはアプリの申請は不要
- ページの作成はアプリの申請とは異なる申請が必要
- ページ作成の申請が承認されるとURLが発行される
App Store 内の検索から訪れたユーザに表示されるプロダクトページの最適化
Get ready to optimize your App Store product page - WWDC 2021 - Videos - Apple Developer
- App Store 内の検索から訪れたユーザに対して表示されるデフォルトのページを最適化するための仕組み
- カスタム製品ページ同様の機能に加えてアプリアイコンもカスタマイズ可能
- デフォルトのページを除いて最大で3種類のパターンを同時に比較できる
- それぞれのカスタマイズを適用するユーザの割合を指定することで、指定した割合をカスタマイズページ数で等分した割合で適用される
- アプリアイコンをカスタマイズするには、全てのカスタマイズアイコンをバイナリに含める必要がある
- ホーム画面に表示されるアイコンも、製品ページのアイコンと同じになる
- 特定のローカライゼーションにのみ対象を絞ることもできる
- App Analytics でそれぞれのパターンの数値を確認できる
- 良い結果のパターンをデフォルトのパターンに適用する場合は、次のリリース時にデフォルトのアプリアイコンの変更を忘れないように注意する
SwiftUI で画面下部にボタンを置く
NG
画面下部にボタンを置く場合、 bottom の Safe Area に被らないように置かないといけない。
何も考えずに置くと、下記のように Safe Area まで拡張されずに残念な感じになる。
VStack(spacing: .zero) { List(0..<100) { index in Text("\(index)") } Button("Button", action: {}) .frame(maxWidth: .infinity, minHeight: 44.0) .background(Color.orange) }
NG
Safe Area までコンテンツを拡張しようと思って VStack に .ignoresSafeArea(edges: .bottom)
をつけると、確かに拡張はされているが Safe Area に表示されるホームインジケータと位置が被ってしまって Human Interface Guidlines 的にも NG。
VStack(spacing: .zero) { List(0..<100) { index in Text("\(index)") } Button("Button", action: {}) .frame(maxWidth: .infinity, minHeight: 44.0) .background(Color.orange) } .ignoresSafeArea(edges: .bottom) // NG
OK
ボタンの背景色だけを Safe Area まで拡張するには、下記のように .background()
で指定している Color に対してだけ .ignoresSafeArea(edges: .bottom)
を指定してやれば良い。
VStack に対しては .ignoresSafeArea(edges: .bottom)
を指定しないのがキモ。
VStack(spacing: .zero) { List(0..<100) { index in Text("\(index)") } Button("Button", action: {}) .frame(maxWidth: .infinity, minHeight: 44.0) .background(Color.orange.ignoresSafeArea(edges: .bottom)) // OK }
SwiftUI のボーダーボタン と clipped
四角ボタン
四角のボタンにボーダーをつける場合は .border
modifier を指定すれば作れる。
Button("四角のボーダーボタン", action: {}) .frame(height: height) .padding() .background(Color.orange) .border(Color.red, width: borderWidth)
角丸ボタン
角丸のボタンにボーダーをつける場合には Button.init(action:,label)
の label に Text を置いて、 .overlay
modifier で ボーダーを引いた Shape を指定すれば作れる。
Button( action: {}, label: { Text(“角丸のボーダーボタン”) .frame(height: height) .padding() .background(Color.orange) .cornerRadius(radius) .overlay( RoundedRectangle(cornerRadius: radius) .stroke(Color.red, lineWidth: borderWidth) ) } )
四角同様に .border
modifier だけで作ろうとしても、角丸にするための .conerRadius
modifier では下記のように四角いボーダーがついてしまうので NG。
Button("角丸のボーダーボタン", action: {}) .frame(height: height) .padding() .background(Color.orange) .cornerRadius(radius) .border(Color.red, width: borderWidth)
角丸ボタンの clipped
Button が clipped されるようなレイアウトを作る場合、Button に padding を付与する必要がある。
.stroke
modifier で作られたボーダーは、 Button の edge をセンターとしてそこから左右に広がるように描画されるため、 Button のサイズで clipped されるとボーダーが見切れることになってしまう。
Button( action: {}, label: { Text(“角丸のボーダーボタン”) .frame(height: height) .padding() .background(Color.orange) .cornerRadius(radius) .overlay( RoundedRectangle(cornerRadius: radius) .stroke(Color.red, lineWidth: borderWidth) ) } ) .padding(borderWidth / 2.0) // <- これがないとボーダーが見切れる .clipped()
The Composable Architecture(TCA)メモ
ViewStore
binding
public func binding<LocalState>( get: @escaping (State) -> LocalState, send localStateToViewAction: @escaping (LocalState) -> Action ) -> Binding<LocalState>
ViewStore の State, Action から Binding を作る。
双方方向バインディングをしたい時に使う。
Binding の getter では state の値を返し、setter では渡されたアクションを send する。
// 例 struct State { var name = "" } enum Action { case nameChanged(String) } TextField( "名前を入力してください", text: viewStore.binding( get: { $0.name }, send: { Action.nameChanged($0) } ) )
Reducer
pullback
public func pullback<GlobalState, GlobalAction, GlobalEnvironment>( state toLocalState: WritableKeyPath<GlobalState, State>, action toLocalAction: CasePath<GlobalAction, Action>, environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment ) -> Reducer<GlobalState, GlobalAction, GlobalEnvironment>
子の Reducer を親の State, Action に紐づいた Reducer へ変換する。
これにより、単一の Reducer が大きくなりすぎてしまわないように適切に子 Reducer へと分割し、それらをマージして親 Reducer を作ることができる。
// 例 // 親の State struct AppState { var settings: SettingsState, ... } // 親の Action enum AppAction { case settings(SettingsAction), ... } // 子の Reducer let settingsReducer = Reducer<SettingsState, SettingsAction, SettingsEnvironment> { ... } // 親の Reducer // combine で複数の子 Reducer を結合する let appReducer = Reducer<AppState, AppAction, AppEnviroment> = .combine( settingsReducer.pullback( state: \.settings, action: /AppAction.settings, environment: { $0.settings } ), // 他の子 Reducer ... )
combine
public static func combine(_ reducers: [Reducer]) -> Reducer
複数の Reducer を順番に実行する単一の Reducer を作る。
順番に実行するため配列の記述順序に影響し、親・子関係の Reducer が同一の State, Action を使用する際に問題を生じる場合がある。
特に Optional な Reducer を用いる場合に起こりやすく、子 Reducer の前に 親 Reducer を実行すると assertion failure となる可能性があるため、子から親という順序にするのが一般的である。
// 例 let parentReducer = Reducer<ParentState, ParentAction, ParentEnvironment>.combine( // 後続の Reducer で state を nil にしているが先に実行されるので state を処理できる childReducer.optional().pullback( state. \.child, action: /ParentAction.child, ennvironment: { $0.child } ), // childReducer の後に実行されるので state を nil にしても childReducer には影響がない Reducer { state, action, environment in switch action { case .child(.dismiss): state.child = nil return .none ... } } )