🦋

SwiftUI: TCAでモーダルじゃないViewをモーダルのように扱う

2024/08/05に公開

sheetalertのようなモーダルの場合、TCA(The Composable Architecture)ではifLetを用いることでReducerの管理をComposableArchitecture Frameworkに任せてモーダルの表示ができます。この際特に便利なのが、モーダル内のReducerでdismiss()を叩くとモーダルが閉じ自動的にReducerをnilにしてくれます。

sheetalertのようにあらかじめ用意されているモーダルではなく独自に作ったモーダル風の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を用意できるようにifLetSomeSheetを紐づける
  • 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