본문 바로가기
Swift/TCA

[TCA] day 9 Shared State활용한 TodosList 예제 (TCA @Shared, Sheet)

by 마라민초닭발로제 2024. 5. 1.

 

작성개요

TCA 1.10부터 SharedState을 관리하는 방식이 달라졌습니다. 이전에 SharedState를 관리하기 위해서 onchange매서드를 통해관리했는데, 이제는 @Shared라는 Macro로 관리하는 방법으로 달라졌습니다. 그래서 이를 공부하고자 글을 작성하게 되었습니다. 

 

 

코드

https://github.com/MaraMincho/MakingFrogWithoutDissecting/tree/main/TIL_TodosWithTCAShared/TIL_TodosWithTCAShared

 

MakingFrogWithoutDissecting/TIL_TodosWithTCAShared/TIL_TodosWithTCAShared at main · MaraMincho/MakingFrogWithoutDissecting

개구리를 해부하지 않고 직접 만들기, 공부 레포. Contribute to MaraMincho/MakingFrogWithoutDissecting development by creating an account on GitHub.

github.com

//
//  ContentView.swift
//  TIL_TodosWithTCAShared
//
//  Created by MaraMincho on 5/1/24.
//

import SwiftUI
import ComposableArchitecture

struct TodosMainView: View {
  @Bindable
  var store: StoreOf<TodosMain>
  
  var body: some View {
    VStack {
      
      Text("최근 편집한 TODO: \(store.recentEdited.title)")
      
      ZStack {
        Color
          .green
          .frame(height: 60)
          .clipShape(RoundedRectangle(cornerRadius: 8))
        HStack {
          Text("투두 투두 ")
          Spacer()
          Button("추가") {
            store.send(.tappedCreateTodo)
          }
        }
        .padding()
      }
      
      ScrollView() {
        VStack {
          ForEach(store.todosContent) { content in
            ZStack {
              Color
                .black
                .opacity(0.1)
                .frame(height: 60)
                .clipShape(RoundedRectangle(cornerRadius: 8))
              HStack {
                Text(content.title)
                  .onTapGesture {
                    store.send(.tappedDetailOfTodos(id: content.id))
                  }
                Spacer()
                Button {
                  store.send(.deleteTodo(id: content.id))
                } label: {
                  Image(systemName: "pencil")
                }
              }
              .frame(maxWidth: .infinity)
            }
          }
        }
      }
    }
    .sheet(item: $store.scope(state: \.todo, action: \.todo)) { store in
      TodoView(store: store)
    }
    .navigationTitle("투두둑 투두둑")
    .padding()
  }
}


struct TodoContentProperty: Equatable, Identifiable {
  var title: String
  var content: String
  var id = UUID()
}


@Reducer
struct TodosMain {
  @ObservableState
  struct State {
    @Shared var todosContent: [TodoContentProperty]
    @Presents var todo: Todo.State? = nil
    @Shared var recentEdited: TodoContentProperty
    init() {
      _recentEdited = .init(TodoContentProperty(title: "", content: ""))
      _todosContent = .init([])
    }
  }
  
  enum Action: Equatable {
    case tappedCreateTodo
    case tappedDetailOfTodos(id: UUID)
    case presentTodo(id: UUID)
    case todo(PresentationAction<Todo.Action>)
    case deleteTodo(id: UUID)
  }
  
  var body: some Reducer<State, Action> {
    Reduce { state, action in
      switch action {
      case .tappedCreateTodo:
        let newTodo = TodoContentProperty(title: "", content: "")
        state.recentEdited = newTodo
        state.todo = .init(todo: state.$recentEdited)
        return .none
      case let .tappedDetailOfTodos(id):
        return .run { send in
          await send(.presentTodo(id: id))
        }
      case let .presentTodo(id):
        if let ind = state.todosContent.enumerated().first(where: {$0.element.id == id})?.offset{
          state.recentEdited = state.todosContent[ind]
          state.todo = .init(todo: state.$recentEdited)
        }
        return .none
      case .todo(.dismiss):
        if let index = state.todosContent.enumerated().first(where: {$0.element.id == state.recentEdited.id})?.offset {
          state.todosContent[index] = state.recentEdited
        }else {
          state.todosContent.append(state.recentEdited)
        }
        return .none
      case .todo:
        return .none
      case let .deleteTodo(id):
        state.todosContent = state.todosContent.filter{$0.id != id}
        return .none
      }
    }
    .ifLet(\.$todo, action: \.todo) {
      Todo()
    }
  }
}
//
//  ContentView.swift
//  TIL_TodosWithTCAShared
//
//  Created by MaraMincho on 5/1/24.
//

import SwiftUI
import ComposableArchitecture

struct TodoView: View {
  @Bindable
  var store: StoreOf<Todo>
  var body: some View {
    VStack {
      TextField("", text: $store.todo.title, prompt: Text("제목을 입력하세요"))
      
      Color.gray
        .frame(maxWidth: .infinity, maxHeight: 4)
      ZStack {
        Color
          .black
          .opacity(0.1)
          .frame(maxHeight: .infinity)
          .clipShape(RoundedRectangle(cornerRadius: 8))
        TextField("", text: $store.todo.content, axis: .vertical)
          .frame(maxHeight: .infinity)
      }
      
      
    }
    .padding()
  }
}


@Reducer
struct Todo {
  @ObservableState
  struct State {
    @Shared var todo: TodoContentProperty
    init(todo: Shared<TodoContentProperty>) {
      _todo = todo
    }
  }
  
  enum Action: BindableAction, Equatable {
    case binding(BindingAction<Todo.State>)
    case tappedCreateTodo
    case tappedDetailOfTodos
  }
  
  var body: some Reducer<State, Action> {
    BindingReducer()
    Reduce { state, action in
      switch action {
      default:
        return .none
      }
    }
  }
}

 

 

 

동작 화면

 

코드 설명

 

@Shared를 자식에게 전달

SharedState가 쓰이는 곳은 Modal하는 부분입니다. Modal하는 부분은 ChildState입니다. Parent State에서 ReferenceType으로 인자를 넘겨준다면 좋겠지만, TCA특성상 Class를 넘겨도 깊은 복사가 이루어집니다. 그렇기에 @Shared를 활용하여 현재 펼쳐진 Todo에 대해서 연결해주면 좋을 것 같습니다. 

 

  struct State {
    @Shared var todosContent: [TodoContentProperty]
    @Presents var todo: Todo.State? = nil
    @Shared var recentEdited: TodoContentProperty
    //중요
    init() {
      _recentEdited = .init(TodoContentProperty(title: "", content: ""))
      _todosContent = .init([])
    }
  }
  
  enum Action: Equatable {
    case tappedCreateTodo
    case tappedDetailOfTodos(id: UUID)
    case presentTodo(id: UUID)
    case todo(PresentationAction<Todo.Action>)
    case deleteTodo(id: UUID)
  }
  
  var body: some Reducer<State, Action> {
    Reduce { state, action in
      switch action {
      case .tappedCreateTodo:
        let newTodo = TodoContentProperty(title: "", content: "")
        state.recentEdited = newTodo
        state.todo = .init(todo: state.$recentEdited)
        return .none
        // code...

 

 

부모 View에서 자식 View로 레퍼런스를 줘야 합니다. 우리는 이를 @shared 로 선언된 recentEdited Property로 넘길 예정입니다. 만약 tappedCreateTodo가 실행될 경우 새 프로퍼티를 생성하고 이를 recentEdited 에 할당합니다. 그리고 이 shared객체를 하위 모달뷰의 state에 넘겨줍니다.(초기값을 세팅)

 

이를 그림으로 나타내면 다음과 같습니다. 

 

Action을 통해 TodoReducer를 생성하고 TodoReducerState에 arguement인 TodoProperty를 부모 View recentEdited로 넘겨줍니다. 그렇게 되어서 ifletd을 통해 TodoReducer를 생성하게 됩니다. 

 

 

TodoReducer을 통해서 생성하면 @shared로 선언했던 Property들이 참조 관계가 될 것입니다.

 

 

@Shared로 부모뷰의 SharingState

그리고 TodoReducer의 Title이나 Content들이 바뀔 때 마다 부모View의 state에 즉각적으로 변경될 것입니다. TodoReducer를 생성하기 위해서 선언한 State의 매개변수가 참조관계이기 때문입니다. 우리는 자식 Reducer가 dismiss되었을 때 관리해주면 됩니다.

 

관리는 두가지 분기로 나뉩니다. 

1. state.todosContent에 recentEditedProperty이미 있을 경우(편집)

편집의 경우는 Content를 업데이트 시켜주면 됩니다.

 

2. state.todosContent에 recentEditedProperty 없을 경우(생성)

생성의 경우여서, todos에 값을 업데이트 시켜줍니다. 

 case .todo(.dismiss):
        if let index = state.todosContent.enumerated().first(where: {$0.element.id == state.recentEdited.id})?.offset {
          state.todosContent[index] = state.recentEdited
        }else {
          state.todosContent.append(state.recentEdited)
        }
        return .none

 

 

 

 

 

 

 

 

tree based navigation(현재는 Modal)

TCA에서 Modal을 구현하는 방법은 여러가지가 있습니다. modal을 구현하기 위해서 child.state가 parent.state에 선언되어 있어야 합니다. 그리고 특정 액션시 state.childState = .Init(...) 으로 값을 주입해서 활용하면됩니다.

 

이는 공식문서 코드에 잘 나와있습니다.  state.addItem = itemFromFeature.state()로 state을 주입하고, iflet분기를 통해서 reducer를 생성합니다. 

@Reducer
struct InventoryFeature {
  @ObservableState
  struct State: Equatable { /* ... */ }
  enum Action { /* ... */ }
  
  var body: some ReducerOf<Self> {
    Reduce { state, action in 
      switch action {
      case .addButtonTapped:
        // Populating this state performs the navigation
        state.addItem = ItemFormFeature.State()
        return .none


      // ...
      }
    }
    .ifLet(\.$addItem, action: \.addItem) {
      ItemFormFeature()
    }
  }
}

 

 

그리고 View에서 .sheet(item:)으로 적절한 store의 scope을 넘기는 모습을 확인할 수 있습니다. 원하는 scope을 통해서 reducer가 있을 경우 뷰를 생성합니다. 

struct InventoryView: View {
  @Bindable var store: StoreOf<InventoryFeature>


  var body: some View {
    List {
      // ...
    }
    .sheet(
      item: $store.scope(state: \.addItem, action: \.addItem)
    ) { store in
      ItemFormView(store: store)
    }
  }
}

 

 

 

 

 

참고자료

https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/treebasednavigation#Testing

 

Documentation

 

pointfreeco.github.io

https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/sharingstate/#Initialization-rules

 

Documentation

 

pointfreeco.github.io