🍆

Target 'CasePathsMacros' must be enabled fefore it can ve used.

に公開

TCA久しぶりに使うとエラーが出た?

久しぶりにTCAを使ったみようとしたらエラーが出た?

https://github.com/pointfreeco/swift-composable-architecture

SPMを使用してパッケージを追加する。

errorが出ている箇所を削除すればビルドすることはできた。

Todoアプリを作ってみた。

https://youtube.com/shorts/jtPeONss2mQ

こちらが完成品

メモリに値を保存するだけなのでデータの永続化はできません。画面が切り替わる状態を管理するだけの単純なアプリです。単純だけど作るのが難しかった。

Todoの値を保持するモデルを作成。

Models/TodoItem.swift
// TodoItem: 個々のTodoアイテムを表すモデル
// - Equatable: 値の比較が必要なため
// - Identifiable: SwiftUIのForEachで使用するため
import Foundation

struct TodoItem: Equatable, Identifiable {
    let id: UUID                // Todoの一意識別子
    var title: String          // Todoのタイトル
    var isCompleted: Bool      // 完了状態のフラグ
    
    init(id: UUID = UUID(), title: String, isCompleted: Bool = false) {
        self.id = id
        self.title = title
        self.isCompleted = isCompleted
    }
}

Reduxの経験がある方は見てみるとわかるかもしれませんが、ActionとReducerを作成します。

Features/TodoFeature.swift
// TodoFeature: TCAのメイン機能を実装
// アプリケーションの状態管理、アクション、および状態更新ロジックを定義
import Foundation
import ComposableArchitecture

@Reducer
struct TodoFeature {
    // State: アプリケーションの状態を定義
    struct State: Equatable {
        var todos: IdentifiedArrayOf<TodoItem> = []     // Todoリストの配列
        var newTodoTitle: String = ""                   // 新規Todo入力用の文字列
        var filter: Filter = .all                       // 現在のフィルター状態
        
        // フィルターの種類を定義
        enum Filter {
            case all        // すべてのTodo
            case active     // 未完了のTodoのみ
            case completed  // 完了済みのTodoのみ
        }
        
        // 現在のフィルター状態に応じてTodoをフィルタリング
        var filteredTodos: IdentifiedArrayOf<TodoItem> {
            switch filter {
            case .all:
                return todos
            case .active:
                return todos.filter { !$0.isCompleted }
            case .completed:
                return todos.filter { $0.isCompleted }
            }
        }
    }
    
    // Action: アプリケーションで発生する可能性のあるすべてのアクションを定義
    enum Action {
        case addTodoButtonTapped           // 追加ボタンが押された
        case todoTitleChanged(String)      // Todo入力テキストが変更された
        case todoToggled(TodoItem.ID)      // Todoの完了状態が切り替えられた
        case todoDeleted(TodoItem.ID)      // Todoが削除された
        case filterSelected(State.Filter)  // フィルターが選択された
    }
    
    // Reducer: 各アクションに対する状態更新ロジックを実装
    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .addTodoButtonTapped:
                guard !state.newTodoTitle.isEmpty else { return .none }
                state.todos.append(TodoItem(title: state.newTodoTitle))
                state.newTodoTitle = ""
                return .none
                
            case let .todoTitleChanged(title):
                state.newTodoTitle = title
                return .none
                
            case let .todoToggled(id):
                guard let index = state.todos.index(id: id) else { return .none }
                state.todos[index].isCompleted.toggle()
                return .none
                
            case let .todoDeleted(id):
                state.todos.remove(id: id)
                return .none
                
            case let .filterSelected(filter):
                state.filter = filter
                return .none
            }
        }
    }
}

TCAの操作をするViewを作成。今回は、追加・表示・チェック・削除を実装しております。Storeを使ってStateの監視をしております。Reactだったらボタンのコールバック{}の中に、関数を呼び出すディスパッチがあります。TCAがやってくれていることは、画面が切り替わるかどうかの状態を監視と状態の更新をしてくれていることです。これだけですね。

Views/TodoListView.swift
// TodoListView: メインのTodoリストビュー
// TCAのStoreを受け取り、UIとの連携を行う
import SwiftUI
import ComposableArchitecture

struct TodoListView: View {
    let store: StoreOf<TodoFeature>    // TCAのストアを保持
    
    var body: some View {
        // WithViewStore: Storeの状態を監視し、アクションを送信するためのラッパー
        WithViewStore(store, observe: { $0 }) { viewStore in
            NavigationView {
                VStack {
                    // 新規Todo入力フィールド
                    HStack {
                        // 双方向バインディング: テキスト入力とState更新
                        TextField("新しいTodoを入力", text: viewStore.binding(
                            get: \.newTodoTitle,
                            send: TodoFeature.Action.todoTitleChanged
                        ))
                        .textFieldStyle(RoundedBorderTextFieldStyle())
                        
                        // 追加ボタン: 空の入力を防ぐ
                        Button("追加") {
                            viewStore.send(.addTodoButtonTapped)
                        }
                        .disabled(viewStore.newTodoTitle.isEmpty)
                    }
                    .padding()
                    
                    // フィルターセグメントコントロール
                    // 双方向バインディング: フィルター選択とState更新
                    Picker("フィルター", selection: viewStore.binding(
                        get: \.filter,
                        send: TodoFeature.Action.filterSelected
                    )) {
                        Text("全て").tag(TodoFeature.State.Filter.all)
                        Text("未完了").tag(TodoFeature.State.Filter.active)
                        Text("完了").tag(TodoFeature.State.Filter.completed)
                    }
                    .pickerStyle(SegmentedPickerStyle())
                    .padding(.horizontal)
                    
                    // Todoリスト表示
                    List {
                        ForEach(viewStore.filteredTodos) { todo in
                            HStack {
                                // 完了状態トグルボタン
                                Button(action: {
                                    viewStore.send(.todoToggled(todo.id))
                                }) {
                                    Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                                }
                                .buttonStyle(PlainButtonStyle())
                                
                                // Todoタイトル(完了時は取り消し線を表示)
                                Text(todo.title)
                                    .strikethrough(todo.isCompleted)
                                
                                Spacer()
                                
                                // 削除ボタン
                                Button(action: {
                                    viewStore.send(.todoDeleted(todo.id))
                                }) {
                                    Image(systemName: "trash")
                                        .foregroundColor(.red)
                                }
                                .buttonStyle(PlainButtonStyle())
                            }
                        }
                    }
                }
                .navigationTitle("TCA Todo Demo")
            }
        }
    }
}

アプリのエントリーポイントのファイルでTCAを設定すればデモ用のTODOアプリを使うことができます。

// TcaDataFlowDemoApp: アプリケーションのエントリーポイント
// TCAのストアを初期化し、アプリケーション全体で共有する
import SwiftUI
import ComposableArchitecture

@main
struct TcaDataFlowDemoApp: App {
    // グローバルなTCAストアを静的プロパティとして保持
    // - initialState: 初期状態を設定
    // - reducer: 状態更新ロジックを提供
    static let store = Store(initialState: TodoFeature.State()) {
        TodoFeature()
    }
    
    var body: some Scene {
        WindowGroup {
            // ルートビューにストアを注入
            TodoListView(store: Self.store)
        }
    }
}

まとめ

個人開発や仕事では使ったことがなくて興味がありたまに使ってみるとエラーが詰まることが多いのですが、SwiftUIはMVVMが相性が悪いらしくTCAで開発するのが流行りのようです。

これはAIに質問してみた内容です。

SwiftUIとMVVMの相性が悪い主な理由:

  1. @State/@StateObjectの存在
  • SwiftUIは独自の状態管理システムを持つ
  • MVVMのViewModelとの役割重複が発生
  1. 宣言的UI
  • SwiftUIは状態変更を自動的にUIに反映
  • MVVMのバインディング層が不要になる
  1. データフロー
  • SwiftUIはデータの単方向フロー
  • MVVMの双方向バインディングと競合

より適したパターン:

// シンプルなデータフローの例
struct ContentView: View {
    @StateObject private var state = ViewState()
    
    var body: some View {
        List(state.items) { item in 
            ItemRow(item: item)
        }
    }
}

参考になった記事も貼っておきます
https://qiita.com/karamage/items/f63a5750e65c5c9745ae
https://musicline-developer.hatenablog.com/entry/2023/04/10/084110

Discussion