🦋
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