TCA+SwiftUIで汎用的なポップアップ画面を実装する

# はじめに
TCAアーキテクチャとSwiftUIを用いた開発にて、汎用的なポップアップ画面の実装を行いました。作りとして、そこそこ汎用的に使えるようにできたので、記事として展開しようと思います。

# 完成品
以下の様に、アニメーションを伴ってポップアップ画面が表示、非表示にできるものを実装していきます。

![PopUp画面完成品.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/6dcd6241-5442-458c-403a-1f6dff397b33.gif)

# 実装コード
## 実装方針
ポップアップ画面のバックグラウンドを表示するViewを実装し、そのViewにコンテンツを注入できるような設計にします。
上記完成品でいうところの、グレー色のバックグラウンドの部分は汎用的な部品として、白いコンテンツ部分は開発者が好きに実装できる部分にします。
ここでは便宜上汎用的な部品を ___親画面___ 、コンテンツ部分を ___子画面___ と呼称します。

### 親画面の実装
```swift
import ComposableArchitecture
import SwiftUI

// Viewの実装

struct PopUpView<PopUpContentView: PopUpableContentView, PopUpContentFeature: PopUpableContentFeature>: View where PopUpContentView.Feature == PopUpContentFeature {
    
    @Bindable private var store: StoreOf<PopUpFeature<PopUpContentFeature>>
        
    init(store: StoreOf<PopUpFeature<PopUpContentFeature>>) {
        
        self.store = store
    }
    
    var body: some View {
        ZStack {
            if store.isShowing {
                ZStack {
                    createBackground()
                    createContentArea()
                        .padding(16)
                }
                .transition(.opacity.animation(.easeInOut))
            }
        }
        .presentationBackground(.clear)
        .onAppear {
            store.send(.onAppear)
        }
    }
}

private extension PopUpView {
    
    func createBackground() -> some View {
        Color(asset: CustomColor.dialogBackgroundColor)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .ignoresSafeArea()
            .onTapGesture {
                store.send(.tappedBackground)
            }
    }
    
    func createContentArea() -> some View {
        PopUpContentView(store: store.scope(state: \.childState, action: \.childAction))
            .padding(16)
            .frame(maxWidth: .infinity)
            .background(Color(asset: CustomColor.appPrimaryBackgroundColor))
            .borderModifier()
    }
}

// 以降がReducerの実装

@Reducer
struct PopUpFeature<ChildContentFeature: PopUpableContentFeature> where ChildContentFeature.State: Equatable & Sendable, ChildContentFeature.Action: PopUpableContentAction {
    
    struct DebounceId: Hashable {}
    
    // MARK: - state definition
    
    @ObservableState
    struct State: Equatable, Sendable {
        
        var isShowing = false
        var childState: ChildContentFeature.State
    }
    
    // MARK: - action definition
    
    enum Action: Equatable, Sendable {
        
        // MARK: event action
        
        case onAppear
        case tappedBackground
        
        // MARK: child content action
        
        case childAction(ChildContentFeature.Action)
    }
    
    // MARK: - dependency
    
    @Dependency(\.dismiss) private var dismiss
    
    // MARK: - reducer definition
    
    var body: some ReducerOf<Self> {
        
        Scope(state: \.childState, action: \.childAction) {
            ChildContentFeature()
        }
        Reduce { state, action in
            
            logger.info("action: \(action)")
            
            switch action {
                
            case .onAppear:
                state.isShowing = true
                return .none
                
            case .tappedBackground:
                return .run { send in
                    
                    await send(.childAction(.willDismissAction),
                               animation: .easeInOut)
                }
                
            case .childAction(.willDismissAction):
                state.isShowing = false
                return dismissPopUp()
                
            case .childAction:
                return .none
            }
        }
    }
}

private extension PopUpFeature {
    
    func dismissPopUp() -> Effect<Action> {
        
        return .run { _ in
            
            await dismiss()
        }
        .debounce(id: DebounceId(),
                  for: .seconds(1.0),
                  scheduler: DispatchQueue.main)
    }
}
```

### 子画面を表すプロトコル実装
```swift
import ComposableArchitecture
import SwiftUI

protocol PopUpableContentFeature: Reducer where State: Equatable & Sendable, Action: PopUpableContentAction {
    
    init()
}

protocol PopUpableContentAction: Equatable, Sendable {
    
    static var willDismissAction: Self { get }
}

protocol PopUpableContentView<Feature>: View {
    
    associatedtype Feature: PopUpableContentFeature
    
    init(store: some StoreOf<Feature>)
}
```

### 子画面の実装
```swift
import Combine
import ComposableArchitecture
import Foundation
import SwiftUI

// Viewの実装

struct SelectTrainingTypeContentView: PopUpableContentView {
    
    typealias Feature = SelectTrainingTypeContentFeature
    
    // MARK: - store property
    
    @Bindable private var store: StoreOf<SelectTrainingTypeContentFeature>
    
    // MARK: - initialize method
    
    init(store: some StoreOf<SelectTrainingTypeContentFeature>) {
        
        self.store = store
    }
    
    // MARK: - view definition
    
    var body: some View {
        ZStack {
            Button {
                store.send(.tappedCloseButton)
            } label: {
                Image(systemName: "xmark.circle")
                    .resizable()
                    .frame(width: 20, height: 20)
                    .padding(8)
            }
            .frameButtonStyle(frameWidth: .zero)
            .frame(maxWidth: .infinity,
                   maxHeight: .infinity,
                   alignment: .topTrailing)
            .zIndex(.infinity)
            VStack(alignment: .leading, spacing: .zero) {
                Text("種目の選択")
                    .font(.system(size: 20, weight: .bold))
                Spacer()
                    .frame(maxHeight: 20)
                ScrollView {
                    VStack(alignment: .leading,
                           spacing: .zero) {
                    }
                    .frame(maxWidth: .infinity, alignment: .leading)
                }
                .scrollBounceBehavior(.basedOnSize)
            }
        }
        .frame(maxHeight: 300)
        .onAppear {
            store.send(.onAppear)
        }
    }
}

// 以降がReducerの実装

@Reducer
struct SelectTrainingTypeContentFeature: PopUpableContentFeature {
            
    // MARK: - state definition
    
    @ObservableState
    struct State: Equatable {}
    
    // MARK: - action definition
    
    enum Action: PopUpableContentAction {
        
        // MARK: event action
        
        /// 画面表示時
        case onAppear
        /// 画面非表示前
        case willDismiss
        /// 閉じるボタン押下時
        case tappedCloseButton
                
        static let willDismissAction: Self = .willDismiss
    }
    
    // MARK: - reducer body
    
    var body: some ReducerOf<Self> {
        
        Reduce { state, action in
            
            switch action {
                
            case .onAppear:
                return .none
                
            case .willDismiss:
                return .none
                
            case .tappedCloseButton:
                return .run { send in
                    await send(.willDismiss, animation: .easeInOut)
                }
            }
        }
    }
}
```

## 実装の説明

### 親画面のView部分の実装

```swift
struct PopUpView<PopUpContentView: PopUpableContentView, PopUpContentFeature: PopUpableContentFeature>: View where PopUpContentView.Feature == PopUpContentFeature {
    
    @Bindable private var store: StoreOf<PopUpFeature<PopUpContentFeature>>
        
    init(store: StoreOf<PopUpFeature<PopUpContentFeature>>) {
        
        self.store = store
    }
    
    var body: some View {
        ZStack {
            if store.isShowing {
                ZStack {
                    createBackground()
                    createContentArea()
                        .padding(16)
                }
                .transition(.opacity.animation(.easeInOut))
            }
        }
        .presentationBackground(.clear) // 背景を透過させるためのmodifier
        .onAppear {
            store.send(.onAppear)
        }
    }
}

private extension PopUpView {
    
    func createBackground() -> some View {
        Color(asset: CustomColor.dialogBackgroundColor) // プロジェクトで定義したColorなので任意にColorを設定してください
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .ignoresSafeArea()
            .onTapGesture {
                store.send(.tappedBackground)
            }
    }
    
    func createContentArea() -> some View {
        PopUpContentView(store: store.scope(state: \.childState, action: \.childAction))
            .padding(16)
            .frame(maxWidth: .infinity)
            .background(Color(asset: CustomColor.appPrimaryBackgroundColor))
            .borderModifier()
    }
}
```

まず、ポイントとしてはジェネリクスを用いて、子画面の`View`と`Reducer`の型を受け入れられる様にしています。
これにより、さまざまな子画面を受け入れられる様になります。
`createContentArea`では子画面を生成していますが、これができるのはジェネリクスのViewの型である`PopUpContentView`を`PopUpableContentView`に準拠させているためです。

```swift
protocol PopUpableContentView<Feature>: View {
    
    associatedtype Feature: PopUpableContentFeature
    
    init(store: some StoreOf<Feature>)
}
```

ここでstoreを引数に持ったイニシャライザを定義しているため、`createContentArea`でそのイニシャライザを呼び、子画面の`Reducer`の型で`Store`を生成して子画面である`PopUpContentView`のインスタンスを生成できます。

ポップアップ画面は`fullCoverScreen`でモーダル遷移で表示することを想定していますが、これを任意のアニメーションで表示するための実装は以下記事を参考に行いました。

https://stackoverflow.com/questions/78365391/custom-fullscreencover-animation

具体的には以下実装の`transition`のmodifier部分でアニメーションを指定しています。
`isShowing`で`ZStack`部分、つまりポップアップ画面全体を出し分けしており、`transition`で表示、非表示時のアニメーションを指定しているのです。
```swift
Stack {
    if store.isShowing {
        ZStack {
            createBackground()
            createContentArea()
                .padding(16)
        }
        .transition(.opacity.animation(.easeInOut))
    }
}
```

:::note
`transition`については、過去に記事を書いたのでよければご参照ください！
https://qiita.com/stotic-dev/items/294c89bd1622fb35fcce
:::

`isShowing`は`onAppear`時と`Dismiss`前に切り替える様にしています。

```swift
@Reducer
struct PopUpFeature<ChildContentFeature: PopUpableContentFeature> where ChildContentFeature.State: Equatable & Sendable, ChildContentFeature.Action: PopUpableContentAction {
    
    struct DebounceId: Hashable {}
    
    // MARK: - state definition
    
    @ObservableState
    struct State: Equatable, Sendable {
        
        var isShowing = false
        var childState: ChildContentFeature.State
    }
    
    // MARK: - action definition
    
    enum Action: Equatable, Sendable {
        
        // MARK: event action
        
        case onAppear
        case tappedBackground
        
        // MARK: child content action
        
        case childAction(ChildContentFeature.Action)
    }
    
    // MARK: - dependency
    
    @Dependency(\.dismiss) private var dismiss
    
    // MARK: - reducer definition
    
    var body: some ReducerOf<Self> {
        
        Scope(state: \.childState, action: \.childAction) {
            ChildContentFeature()
        }
        Reduce { state, action in
            
            logger.info("action: \(action)")
            
            switch action {
                
            case .onAppear:
                state.isShowing = true
                return .none
                
            case .tappedBackground:
                return .run { send in
                    
                    await send(.childAction(.willDismissAction),
                               animation: .easeInOut)
                }
                
            case .childAction(.willDismissAction):
                state.isShowing = false
                return dismissPopUp()
                
            case .childAction:
                return .none
            }
        }
    }
}

private extension PopUpFeature {
    
    func dismissPopUp() -> Effect<Action> {
        
        return .run { _ in
            
            await dismiss()
        }
        .debounce(id: DebounceId(),
                  for: .seconds(1.0),
                  scheduler: DispatchQueue.main)
    }
}
```

今回はポップアップ画面の背景をタップされた場合、画面を閉じるということを行うために`tappedBackground`を呼ばれたときに、子画面の`Reducer`のActionを介して`dismissPopUp`を呼んで`dismiss`で画面を非表示にしています。

わざわざ、子画面の`Reducer`を介しているのは、画面非表示しようとしていることを子画面にも伝えるためです。
子画面では`onDisappear`で画面非表示のイベントを拾えば良いという考えもありますが、私が調べた限りはこれは良くなさそうでした。
というのも、TCAではDismiss後にActionを`send`するとTCAのワーニングが表示されてしまいます。`onDisappear`はどうしてもDismiss後に`send`されてしまうため、ワーニングが回避できなかったのです。

::: note warn
この辺りの事情は、きちんとドキュメントやフォーラムで見たわけではなく、コードに警告が出るといういう結果から私の考えを述べたに過ぎませんので、本当のところは正しいのか怪しいです。
もしご指摘やより良い解決法などあればご教授いただけますと幸いです。
:::

親`Reducer`がジェネリクスの子`Reducer`にどうやって画面を非表示にしようとしているのかを通知しているのかというと、子`Reducer`が`PopUpableContentFeature`に準拠し、子`Reducer.Action`が`PopUpableContentAction`に準拠しているためです。

```swift
protocol PopUpableContentFeature: Reducer where State: Equatable & Sendable, Action: PopUpableContentAction {
    
    init()
}

protocol PopUpableContentAction: Equatable, Sendable {
    
    static var willDismissAction: Self { get }
}

// 子画面のReducer.Action

enum Action: PopUpableContentAction {
        
        // MARK: event action
        
        /// 画面表示時
        case onAppear
        /// 画面非表示前
        case willDismiss
        /// 閉じるボタン押下時
        case tappedCloseButton
        
        static let willDismissAction: Self = .willDismiss
}
```

子画面の`Reducer.Action`はプロトコルに準拠するために`willDismissAction`を定義し、そこで画面を閉じる前に通知されるActionを指定します。
そうすることで、親画面の`Reducer`でも子画面の`Reducer.Action`を一部知ることができるのです。

あとは、`dismissPopUp`について、単純に`dismiss`を呼んでいるだけでなく、`debounce`にて1秒遅延させて行っているのは、画面非表示のアニメーションが終わる前に`dismiss`で画面が非表示になってしまうことを防ぐためです。
つまりアニメーションが終わるのを待つために、遅延処理を入れています。
バニラSwiftUIであれば、[`withAnimation(completion:)`](https://developer.apple.com/documentation/swiftui/withanimation(_:completioncriteria:_:completion:))でアニメーションを終えた後に`dismiss`を行うということもできるのでしょうが、TCAだとこれに変わるAPIがなさそうだったので、仕方なく遅延処理を入れています、、
(何か良いやり方あれば是非教えていただきたいです！)

### 子画面の実装
子画面のモジュール(`View`+`Reducer`)は以下プロトコルに準拠するだけですので、詳細の説明は省略します。

```swift
protocol PopUpableContentFeature: Reducer where State: Equatable & Sendable, Action: PopUpableContentAction {
    
    init()
}

protocol PopUpableContentAction: Equatable, Sendable {
    
    static var willDismissAction: Self { get }
}

protocol PopUpableContentView<Feature>: View {
    
    associatedtype Feature: PopUpableContentFeature
    
    init(store: some StoreOf<Feature>)
}
```

### ポップアップ画面呼び出しの実装
最後に以下の様にポップアップ画面を呼び出し部分を実装すれば完成です。
```swift
.fullScreenCover(item: $store.scope(state: \.popup, action: \.popup)) {
    PopUpView<SelectTrainingTypeContentView, SelectTrainingTypeContentFeature>(store: $0)
}
.transaction { $0.disablesAnimations = true }
```

補足として、`transaction`のmodifierで行っているのは、モーダル遷移のアニメーションを発火させないようにするためです。
これを行わないと、下からにゅっと出てくるモーダル遷移のアニメーションが付与されるため、ポップアップ画面ぽくなりません。

こちらも以下記事を参考にしたものです。
https://stackoverflow.com/questions/78365391/custom-fullscreencover-animation

# おわり
以上が、TCA+SwiftUIでの汎用的なポップアップ画面の実装になります。
割と限られた状況での実装例なので、ニッチな話ですがドンピシャでこの内容を取り上げている記事もなかったと思うので、参考になる方がいれば幸いです。