🦋
SwiftUI: TCAでモーダルじゃないViewをモーダルのように扱う
sheetやalertのようなモーダルの場合、TCA(The Composable Architecture)ではifLetを用いることでReducerの管理をComposableArchitecture Frameworkに任せてモーダルの表示ができます。この際特に便利なのが、モーダル内のReducerでdismiss()を叩くとモーダルが閉じ自動的にReducerをnilにしてくれます。
sheetやalertのようにあらかじめ用意されているモーダルではなく独自に作ったモーダル風のViewでも、このようなComposableArchitecture Frameworkのモーダルにおける利便性を活用したいと思い、弊チームメンバーで試行錯誤した結果良い方法があったのでまとめておきます。
環境
- Swift: 5.10
- TCA: 1.12.1
- Xcode 15.4
デモ

既存のsheetと独自のバナーを表示
普通のモーダルの場合の実装例
ボタンを押したらsheetが表示されるような例を実装します。sheetの中の✖️ボタンを押すとsheetが閉じるようにします。
AppFeature
import ComposableArchitecture
import SwiftUI
@Reducer
struct AppFeature {
@ObservableState
struct State {
@Presents var someSheet: SomeSheet.State?
}
enum Action {
case showSheetButtonTapped
case someSheet(PresentationAction<SomeSheet.Action>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .showSheetButtonTapped:
state.someSheet = SomeSheet.State()
return .none
case .someSheet:
return .none
}
}
.ifLet(\.$someSheet, action: \.someSheet) {
SomeSheet()
}
}
}
struct AppView: View {
@Bindable var store: StoreOf<AppFeature>
var body: some View {
VStack {
Button {
store.send(.showSheetButtonTapped)
} label: {
Text("Show Sheet")
}
}
.sheet(item: $store.scope(state: \.someSheet, action: \.someSheet)) { store in
SomeSheetView(store: store)
}
}
}
SomeSheet
import ComposableArchitecture
import SwiftUI
@Reducer
struct SomeSheet {
@ObservableState
struct State: Equatable {}
enum Action {
case closeButtonTapped
}
@Dependency(\.dismiss) var dismiss
var body: some ReducerOf<Self> {
Reduce { _, action in
switch action {
case .closeButtonTapped:
return .run { [dismiss] _ in
await dismiss()
}
}
}
}
}
struct SomeSheetView: View {
let store: StoreOf<SomeSheet>
var body: some View {
HStack {
Text("Some Sheet")
.font(.largeTitle)
.padding()
Button {
store.send(.closeButtonTapped)
} label: {
Image(systemName: "xmark")
}
}
}
}
-
AppFeature.Stateで@PresentsをつけたSomeSheet.Stateを持つ -
someSheet(PresentationAction<SomeSheet.Action>)というケースをActionに定義 -
sheetを表示するタイミングでstate.someSheet = SomeSheet.State()の代入 - 必要なタイミングでReducerを用意できるように
ifLetでSomeSheetを紐づける -
sheet(item:content:)でReducerとSomeSheetViewを紐づける
独自Viewをモーダルのように扱う実装例
画面上部にポップするバナーを実装します。バナーの中にある✖️ボタンでバナーを非表示にできるようにします。
AppFeature
import ComposableArchitecture
import SwiftUI
@Reducer
struct AppFeature {
@ObservableState
struct State {
@Presents var banner: Banner.State?
}
enum Action {
case showBannerButtonTapped
case banner(PresentationAction<Banner.Action>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .showBannerButtonTapped:
state.banner = Banner.State()
return .none
case .banner:
return .none
}
}
.ifLet(\.$banner, action: \.banner) {
Banner()
}
}
}
struct AppView: View {
@Bindable var store: StoreOf<AppFeature>
var body: some View {
ZStack {
VStack {
Button {
store.send(.showBannerButtonTapped, animation: .default)
} label: {
Text("Show Banner")
}
}
if let store = store.scope(state: \.banner, action: \.banner) {
BannerView(store: store)
}
}
}
}
Banner
import ComposableArchitecture
import SwiftUI
@Reducer
struct Banner {
@ObservableState
struct State {}
enum Action {
case closeButtonTapped
}
@Dependency(\.dismiss) var dismiss
var body: some ReducerOf<Self> {
Reduce { _, action in
switch action {
case .closeButtonTapped:
return .run { [dismiss] _ in
await dismiss(animation: .default)
}
}
}
}
}
struct BannerView: View {
let store: Store<Banner.State, PresentationAction<Banner.Action>>
var body: some View {
VStack {
HStack {
Text("Banner")
Spacer()
Button {
store.send(.presented(.closeButtonTapped))
} label: {
Image(systemName: "xmark")
}
}
.padding(8)
.background(Color.white, in: .rect(cornerRadius: 8))
.shadow(radius: 5)
.padding()
Spacer()
}
}
}
-
AppFeatureの実装はsheetの時とほぼ同様 -
AppViewで独自のViewを表示させる時はif letで目的のScope Reducerを取得する - 独自のViewの表示がスムーズに見えるように
store.sendではanimationを指定する - 独自のViewのReducerに難しい実装はない
- 独自のViewのstoreの定義で
PresentationActionを受け取るようにする - 独自のViewで
dismiss()するときも非表示がスムーズに見えるようにanimationを指定する
Discussion