Swift/Swift CS

[iOS/SwiftUI] TCA 프로젝트 뜯어보기

힛해 2024. 8. 26. 17:33
728x90

https://github.com/pointfreeco/swift-composable-architecture?tab=readme-ov-file#examples

 

GitHub - pointfreeco/swift-composable-architecture: A library for building applications in a consistent and understandable way,

A library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind. - pointfreeco/swift-composable-architecture

github.com

 

해당 프로젝트를 실행했을때의 앱 화면이다.

 

 

TCA GitHub의 Examples 중에서 SearchApp코드를 하나하나 뜯어보자.

 

Search Reducer 

@Reducer
struct Search {
  @ObservableState
  struct State: Equatable {
    var results: [GeocodingSearch.Result] = []
    var resultForecastRequestInFlight: GeocodingSearch.Result?
    var searchQuery = ""
    var weather: Weather?

    struct Weather: Equatable {
      var id: GeocodingSearch.Result.ID
      var days: [Day]

      struct Day: Equatable {
        var date: Date
        var temperatureMax: Double
        var temperatureMaxUnit: String
        var temperatureMin: Double
        var temperatureMinUnit: String
      }
    }
  }

  enum Action {
    case forecastResponse(GeocodingSearch.Result.ID, Result<Forecast, Error>)
    case searchQueryChanged(String)
    case searchQueryChangeDebounced
    case searchResponse(Result<GeocodingSearch, Error>)
    case searchResultTapped(GeocodingSearch.Result)
  }

  @Dependency(\.weatherClient) var weatherClient
  private enum CancelID { case location, weather }

  var body: some Reducer<State, Action> {
    Reduce { state, action in
      switch action {
      case .forecastResponse(_, .failure):
        state.weather = nil
        state.resultForecastRequestInFlight = nil
        return .none

      case let .forecastResponse(id, .success(forecast)):
        state.weather = State.Weather(
          id: id,
          days: forecast.daily.time.indices.map {
            State.Weather.Day(
              date: forecast.daily.time[$0],
              temperatureMax: forecast.daily.temperatureMax[$0],
              temperatureMaxUnit: forecast.dailyUnits.temperatureMax,
              temperatureMin: forecast.daily.temperatureMin[$0],
              temperatureMinUnit: forecast.dailyUnits.temperatureMin
            )
          }
        )
        state.resultForecastRequestInFlight = nil
        return .none

      case let .searchQueryChanged(query):
        state.searchQuery = query

        // When the query is cleared we can clear the search results, but we have to make sure to
        // cancel any in-flight search requests too, otherwise we may get data coming in later.
        guard !state.searchQuery.isEmpty else {
          state.results = []
          state.weather = nil
          return .cancel(id: CancelID.location)
        }
        return .none

      case .searchQueryChangeDebounced:
        guard !state.searchQuery.isEmpty else {
          return .none
        }
        return .run { [query = state.searchQuery] send in
          await send(.searchResponse(Result { try await self.weatherClient.search(query: query) }))
        }
        .cancellable(id: CancelID.location)

      case .searchResponse(.failure):
        state.results = []
        return .none

      case let .searchResponse(.success(response)):
        state.results = response.results
        return .none

      case let .searchResultTapped(location):
        state.resultForecastRequestInFlight = location

        return .run { send in
          await send(
            .forecastResponse(
              location.id,
              Result { try await self.weatherClient.forecast(location: location) }
            )
          )
        }
        .cancellable(id: CancelID.weather, cancelInFlight: true)
      }
    }
  }
}

 

너무 길다...! 분해해서 알아보자

 

ObservableState : 감시하는 값들

@ObservableState
    // 관리하려는 상태들을 State 구조체 안에 묶어서 접근
  struct State: Equatable {
    var results: [GeocodingSearch.Result] = []
    var resultForecastRequestInFlight: GeocodingSearch.Result?
    var searchQuery = ""
    var weather: Weather?

    struct Weather: Equatable {
      var id: GeocodingSearch.Result.ID
      var days: [Day]

      struct Day: Equatable {
        var date: Date
        var temperatureMax: Double
        var temperatureMaxUnit: String
        var temperatureMin: Double
        var temperatureMinUnit: String
      }
    }
  }

 

State구조체 안에 관리하는 상태들을 묶은 모습이다.

 

하나의 Reducer는 하나의 뷰에서 사용하는 경우가 많기에 어짜피 변수 하나만 바뀌어도 뷰가 업데이트된다.

 

현재 토이 프로젝트에서는 구조체로 묶지않았는데 이런식으로 묶어서 관리하면 가독성 측면에서 좋을 것 같다.

 

Action

enum Action {
    case forecastResponse(GeocodingSearch.Result.ID, Result<Forecast, Error>)
    case searchQueryChanged(String)
    case searchQueryChangeDebounced
      
      // 성공 액션과 실패 액션 모두 정의
    case searchResponse(Result<GeocodingSearch, Error>)
    case searchResultTapped(GeocodingSearch.Result)
  }

 

forecastResponse는 GeocodingSearch.Result.ID (리스트의 번호를 사용하기위해) , Result 제네릭을 매개변수로 사용하는데.

 

body Reducer 정의 부분에서 성공했을때와 실패했을때를 아래와 같이 모두 정의해 주어야한다.

case .forecastResponse(_, .failure):
        state.weather = nil
        state.resultForecastRequestInFlight = nil
        return .none

          // 통신이 성공하면 state.weather에 응답 값 할당
      case let .forecastResponse(id, .success(forecast)):
        state.weather = State.Weather(
          id: id,
          days: forecast.daily.time.indices.map {
            State.Weather.Day(
              date: forecast.daily.time[$0],
              temperatureMax: forecast.daily.temperatureMax[$0],
              temperatureMaxUnit: forecast.dailyUnits.temperatureMax,
              temperatureMin: forecast.daily.temperatureMin[$0],
              temperatureMinUnit: forecast.dailyUnits.temperatureMin
            )
          }
        )
        state.resultForecastRequestInFlight = nil
        return .none

 

이때 실패했을때는 case 성공했을때는 case let으로 로직을 정의한 것을 볼 수 있는데.

 

case let으로 정의하면 변수를 캡쳐해서 정의된 로직에 변수를 할당해서 사용할 수 있다.

 

간단 정리

더보기

case만 사용하는 경우

값이 필요하지 않고, 단순히 패턴이 일치하는지 확인할 때 사용된다.

값을 바인딩하지 않기 때문에, 이후 코드에서 해당 값을 사용할 수 없다.

case let을 사용하는 경우

패턴이 일치할 뿐 아니라, 그 값을 바인딩하여 이후 코드에서 사용하고자 할 때 사용된다. 

즉, let 키워드를 사용하여 특정 값을 캡처하고 이후 로직에서 그 값을 참조해 사용할 수 있다.

 

@Dependency

 

@Dependency(\.weatherClient) var weatherClient

 

외부 API 통신 설정 전역적으로 불러와서 사용한다.

 

이 부분은 뒷부분에서 자세하게 알아보자.

 

Reduce body : state, action 로직 정의

 Reduce { state, action in
      switch action {
      
      // 통신이 실패 했을때
      case .forecastResponse(_, .failure):
        state.weather = nil
        state.resultForecastRequestInFlight = nil
        return .none

      // 통신이 성공하면 state.weather에 응답 값 할당
      case let .forecastResponse(id, .success(forecast)):
        state.weather = State.Weather(
          id: id,
          days: forecast.daily.time.indices.map {
            State.Weather.Day(
              date: forecast.daily.time[$0],
              temperatureMax: forecast.daily.temperatureMax[$0],
              temperatureMaxUnit: forecast.dailyUnits.temperatureMax,
              temperatureMin: forecast.daily.temperatureMin[$0],
              temperatureMinUnit: forecast.dailyUnits.temperatureMin
            )
          }
        )
        state.resultForecastRequestInFlight = nil
        return .none

      // 검색문장을 바로 state.searchQuery에 패턴매칭
      case let .searchQueryChanged(query):
        state.searchQuery = query

        // When the query is cleared we can clear the search results, but we have to make sure to
        // cancel any in-flight search requests too, otherwise we may get data coming in later.
        guard !state.searchQuery.isEmpty else {
          state.results = []
          state.weather = nil
          return .cancel(id: CancelID.location)
        }
        return .none

      case .searchQueryChangeDebounced:
          
          // 검색쿼리가 비어있다면
        guard !state.searchQuery.isEmpty else {
          return .none
        }
          
          // 검색쿼리가 비어있지 않다면
        return .run { [query = state.searchQuery] send in
          await send(.searchResponse(Result { try await self.weatherClient.search(query: query) }))
        }
        .cancellable(id: CancelID.location)

    // 통신에 실패했을때
      case .searchResponse(.failure):
          // results에 빈 배열을 반환한다.
        state.results = []
        return .none
    
    // 통신에 성공했을때
      case let .searchResponse(.success(response)):
          // results에 통신 결과값들을 반환한다.
        state.results = response.results
        return .none

    // 검색 결과를 눌렀을때
      case let .searchResultTapped(location):
          // 통신중이다 버퍼링 표시
        state.resultForecastRequestInFlight = location

        return .run { send in
          await send(
            .forecastResponse(
              location.id,
              Result { try await self.weatherClient.forecast(location: location) }
            )
          )
        }
          // 다른 버튼을 누르면 이전 통신 취소
        .cancellable(id: CancelID.weather, cancelInFlight: true)
      }
    }

 

실행되는 순서를 살펴보자

  1. searchQueryChangeDebounced : 검색창 입력값을 감시하고 searchResponse를 실행한다.
  2. searchResponse : 입력값으로 통신하여 결과값을 리스트에 반환한다.
  3. searchResultTapped : 리스트를 클릭했을때 해당 리스트 이름을 forecastResponse로직에 담아 실행시킨다.
  4. forecastResponse : 해당 지역의 날씨정보를 API통신으로 반환한다.

그럼 동작 하나하나 뜯어보자.

searchQueryChangeDebounced

case .searchQueryChangeDebounced:
          
          // 검색쿼리가 비어있다면
        guard !state.searchQuery.isEmpty else {
          return .none
        }
          
          // 검색쿼리가 비어있지 않다면
        return .run { [query = state.searchQuery] send in
          await send(.searchResponse(Result { try await self.weatherClient.search(query: query) }))
        }
        .cancellable(id: CancelID.location)

 

검색쿼리가 비어있으면 return .none으로 아무런 Effect를 실행시키지 않는다.

 

[query = state.searchQuery]

  • 이 부분은 클로저의 캡처 목록인데, 캡처 목록을 사용하여 클로저가 외부 상태를 캡처할 때, 해당 상태의 복사본을 사용할 수 있게한다.
  • query = state.searchQuery는 state.searchQuery의 현재 값을 query라는 이름으로 클로저 내부에서 사용할 수 있도록 캡처한다.

send in

  • await send(...) 비동기 작업이 완료된 후 호출되어 상태를 업데이트한다.

cancellable(id: CancelID.location)

  • 같은 API 통신이 일어나면 기존의 것을 취소한다.

 

searchResponse

// 통신에 실패했을때
      case .searchResponse(.failure):
          // results에 빈 배열을 반환한다.
        state.results = []
        return .none
    
    // 통신에 성공했을때
      case let .searchResponse(.success(response)):
          // results에 통신 결과값들을 반환한다.
        state.results = response.results
        return .none

 

성공했을떄는 state.results에 통신결과값을 담아 화면에 리스트를 표시한다

 

searchResultTapped

// 검색 결과를 눌렀을때
      case let .searchResultTapped(location):
          // 통신중이다 버퍼링 표시
        state.resultForecastRequestInFlight = location

        return .run { send in
          await send(
            .forecastResponse(
              location.id,
              Result { try await self.weatherClient.forecast(location: location) }
            )
          )
        }
          // 다른 버튼을 누르면 이전 통신 취소
        .cancellable(id: CancelID.weather, cancelInFlight: true)

 

리스트를 클릭했을때  state.resultForecasetRequestInFlight 에 location(GeocodingSearch.results) 값을 담는다

 

추후 버퍼링 표시의 id와 비교하기 위해서.

 

.run

  • 비동기 작업을 정의하고, 작업이 완료되면 send 클로저를 호출하여 액션을 디스패치하는 역할을 한다.

 

매개변수로 location.id와 통신결과값을 forecasetResponse에 담아 액션을 실행시킨다.

 

 

forecastResponse

case .forecastResponse(_, .failure):
        state.weather = nil
        state.resultForecastRequestInFlight = nil
        return .none

          // 통신이 성공하면 state.weather에 응답 값 할당
      case let .forecastResponse(id, .success(forecast)):
        state.weather = State.Weather(
          id: id,
          days: forecast.daily.time.indices.map {
            State.Weather.Day(
              date: forecast.daily.time[$0],
              temperatureMax: forecast.daily.temperatureMax[$0],
              temperatureMaxUnit: forecast.dailyUnits.temperatureMax,
              temperatureMin: forecast.daily.temperatureMin[$0],
              temperatureMinUnit: forecast.dailyUnits.temperatureMin
            )
          }
        )
        state.resultForecastRequestInFlight = nil
        return .none

 

통신이 끝나면 버퍼링 표시는 필요없으니 성공과 실패의 경우 resultForecastRequestInFlight의 값을 nil로 바꿔준다.

 

id는 location.id 즉 리스트의 번호다.

 

통신에서 성공한 값 forecaset를 State.Weather구조체에 담아 state.weather에 할당해준다.

 

Reducer는 전부 알아보았다.

 

이제 외부 API 통신 코드를 뜯어보자

 

전체 코드

import ComposableArchitecture
import Foundation

// MARK: - API models

struct GeocodingSearch: Decodable, Equatable, Sendable {
  var results: [Result]

  struct Result: Decodable, Equatable, Identifiable, Sendable {
    var country: String
    var latitude: Double
    var longitude: Double
    var id: Int
    var name: String
    var admin1: String?
  }
}

struct Forecast: Decodable, Equatable, Sendable {
  var daily: Daily
  var dailyUnits: DailyUnits

  struct Daily: Decodable, Equatable, Sendable {
    var temperatureMax: [Double]
    var temperatureMin: [Double]
    var time: [Date]
  }

  struct DailyUnits: Decodable, Equatable, Sendable {
    var temperatureMax: String
    var temperatureMin: String
  }
}

// MARK: - API client interface

// Typically this interface would live in its own module, separate from the live implementation.
// This allows the search feature to compile faster since it only depends on the interface.

@DependencyClient
struct WeatherClient {
  var forecast: @Sendable (_ location: GeocodingSearch.Result) async throws -> Forecast
  var search: @Sendable (_ query: String) async throws -> GeocodingSearch
}


// 실제 통신 전 테스트
extension WeatherClient: TestDependencyKey {
    // 여기서의 Self는 WeatherClient
  static let previewValue = Self(
    forecast: { _ in .mock },
    search: { _ in .mock }
  )

  static let testValue = Self()
}

extension DependencyValues {
  var weatherClient: WeatherClient {
    get { self[WeatherClient.self] }
    set { self[WeatherClient.self] = newValue }
  }
}

// MARK: - Live API implementation

extension WeatherClient: DependencyKey {
  static let liveValue = WeatherClient(
    forecast: { result in
      var components = URLComponents(string: "https://api.open-meteo.com/v1/forecast")!
      components.queryItems = [
        URLQueryItem(name: "latitude", value: "\(result.latitude)"),
        URLQueryItem(name: "longitude", value: "\(result.longitude)"),
        URLQueryItem(name: "daily", value: "temperature_2m_max,temperature_2m_min"),
        URLQueryItem(name: "timezone", value: TimeZone.autoupdatingCurrent.identifier),
      ]

      let (data, _) = try await URLSession.shared.data(from: components.url!)
      return try jsonDecoder.decode(Forecast.self, from: data)
    },
    search: { query in
      var components = URLComponents(string: "https://geocoding-api.open-meteo.com/v1/search")!
      components.queryItems = [URLQueryItem(name: "name", value: query)]

      let (data, _) = try await URLSession.shared.data(from: components.url!)
      return try jsonDecoder.decode(GeocodingSearch.self, from: data)
    }
  )
}

// MARK: - Mock data

extension Forecast {
  static let mock = Self(
    daily: Daily(
      temperatureMax: [90, 70, 100],
      temperatureMin: [70, 50, 80],
      time: [0, 86_400, 172_800].map(Date.init(timeIntervalSince1970:))
    ),
    dailyUnits: DailyUnits(temperatureMax: "°F", temperatureMin: "°F")
  )
}

extension GeocodingSearch {
  static let mock = Self(
    results: [
      GeocodingSearch.Result(
        country: "United States",
        latitude: 40.6782,
        longitude: -73.9442,
        id: 1,
        name: "Brooklyn",
        admin1: nil
      ),
      GeocodingSearch.Result(
        country: "United States",
        latitude: 34.0522,
        longitude: -118.2437,
        id: 2,
        name: "Los Angeles",
        admin1: nil
      ),
      GeocodingSearch.Result(
        country: "United States",
        latitude: 37.7749,
        longitude: -122.4194,
        id: 3,
        name: "San Francisco",
        admin1: nil
      ),
    ]
  )
}

// MARK: - Private helpers

private let jsonDecoder: JSONDecoder = {
  let decoder = JSONDecoder()
  let formatter = DateFormatter()
  formatter.calendar = Calendar(identifier: .iso8601)
  formatter.dateFormat = "yyyy-MM-dd"
  formatter.timeZone = TimeZone(secondsFromGMT: 0)
  formatter.locale = Locale(identifier: "en_US_POSIX")
  decoder.dateDecodingStrategy = .formatted(formatter)
  return decoder
}()

extension Forecast {
  private enum CodingKeys: String, CodingKey {
    case daily
    case dailyUnits = "daily_units"
  }
}

extension Forecast.Daily {
  private enum CodingKeys: String, CodingKey {
    case temperatureMax = "temperature_2m_max"
    case temperatureMin = "temperature_2m_min"
    case time
  }
}

extension Forecast.DailyUnits {
  private enum CodingKeys: String, CodingKey {
    case temperatureMax = "temperature_2m_max"
    case temperatureMin = "temperature_2m_min"
  }
}

 

크게 세 부분으로 나뉘어져있다.

 

1. 통신을 위한 모델링 및 JSONDecoding extension

2. ClientDependency 설정 및 API 통신 정의

3. 테스트를 위한 목 데이터

 

하나하나 알아보자

@DependencyClient
struct WeatherClient {
  var forecast: @Sendable (_ location: GeocodingSearch.Result) async throws -> Forecast
  var search: @Sendable (_ query: String) async throws -> GeocodingSearch
}

 

@DependencyClient

@DependencyClient는 Composable Architecture에서 제공하는 속성 래퍼다.

이 래퍼를 사용하여 의존성을 정의하고 주입할 수 있고, 클라이언트 구조체에 이 속성 래퍼를 사용하면, TCA의 의존성 주입 시스템을 통해 의존성을 쉽게 관리하고, 테스트와 모킹을 지원하는 데 유용하다.

 

@Sendable

Swift의 새로운 비동기 및 동시성 기능과 관련된 속성. Swift 5.5에서 도입된 비동기 프로그래밍 모델과 관련하여 사용되는데 이 속성은 함수가 스레드와 비동기 컨텍스트에서 안전하게 호출될 수 있음을 나타내는 역할을 한다고 한다.

@Sendable의 역할

  1. 스레드-안전성 보장
    • @Sendable 속성은 해당 클로저나 함수가 스레드 간에 안전하게 전달될 수 있음을 보장한다.
    • 이 클로저는 여러 스레드에서 동시에 호출되어도 문제가 없도록 설계되어야 한다.
  2. 비동기 작업과의 호환성
    • Swift의 비동기 프로그래밍 모델에서는 비동기 함수나 클로저를 스레드 간에 안전하게 전달할 수 있어야 한다.
    • @Sendable은 이러한 비동기 작업에서 클로저가 안전하게 사용할 수 있음을 보장합니다.
  3. 클로저의 안전성 검증
    • Swift 컴파일러는 @Sendable을 사용하여 클로저가 스레드-안전성을 유지하는지 검사한다.
    • 클로저가 공유 상태를 변경하거나 비동기 작업 중에 상태를 접근할 때 스레드-안전성을 보장하기 위해 컴파일 타임 검사를 수행한다.

 

오류가 발생하면 예외처리를 할 수 있고 반환타입은 각각 모델링에 따라 반환한다.

 

extension WeatherClient : TestDependencyKey

// 실제 통신 전 테스트
extension WeatherClient: TestDependencyKey {
    // 여기서의 Self는 WeatherClient
  static let previewValue = Self(
    forecast: { _ in .mock },
    search: { _ in .mock }
  )

  static let testValue = Self()
}

 

목데이터를 집어넣어 테스트를 해볼 수 있게 만들어준다.

 

주석처리를 해도 사용에는 문제가 없고 @DependencyClient 에서 테스트할 수 있는 기능이다.

 

extension DependencyValues

extension DependencyValues {
  var weatherClient: WeatherClient {
    get { self[WeatherClient.self] }
    set { self[WeatherClient.self] = newValue }
  }
}

 

DependencyValues 객체에 WeatherClient 인스턴스를 저장하고 접근할 수 있게 한다.

 

self[WeatherClient.self]

  • 이 구문은 DependencyValues에 저장된 WeatherClient의 값을 접근하거나 설정하는데 사용된다.
  • self는 DependencyValues 객체를 나타내며, [WeatherClient.self]는 WeatherClient 타입에 대한 키를 나타낸다.
  • self[WeatherClient.self]는 DependencyValues에서 WeatherClient 타입의 값을 가져오거나 저장하는데 사용되는 내부 메커니즘이다.

 

extension WeatherClient: DependencyKey

extension WeatherClient: DependencyKey {
  static let liveValue = WeatherClient(
    forecast: { result in
      var components = URLComponents(string: "https://api.open-meteo.com/v1/forecast")!
      components.queryItems = [
        URLQueryItem(name: "latitude", value: "\(result.latitude)"),
        URLQueryItem(name: "longitude", value: "\(result.longitude)"),
        URLQueryItem(name: "daily", value: "temperature_2m_max,temperature_2m_min"),
        URLQueryItem(name: "timezone", value: TimeZone.autoupdatingCurrent.identifier),
      ]

      let (data, _) = try await URLSession.shared.data(from: components.url!)
      return try jsonDecoder.decode(Forecast.self, from: data)
    },
    search: { query in
      var components = URLComponents(string: "https://geocoding-api.open-meteo.com/v1/search")!
      components.queryItems = [URLQueryItem(name: "name", value: query)]

      let (data, _) = try await URLSession.shared.data(from: components.url!)
      return try jsonDecoder.decode(GeocodingSearch.self, from: data)
    }
  )
}

 

 

DependencyKey 프로토콜

  • DependencyKey 프로토콜은 ComposableArchitecture에서 의존성을 정의하는 데 사용된다.
  • 이를 통해 애플리케이션의 다양한 부분에서 의존성을 설정하고 가져올 수 있다.

liveValue 프로퍼티

  • liveValue는 WeatherClient의 실제 구현을 정의하는 정적 프로퍼티
  • 실제 API 호출을 통해 데이터를 가져오는 로직을 포함한다.
  • liveValue는 WeatherClient를 실제 환경에서 사용하는 경우의 기본값으로 설정되고 가상 환경이라면 extension WeatherClient : TestDependencyKey 의 목 데이터가 사용된다.

 

이런 설정을 해두어 Reducer에서 아래와 같이 통신이 가능한 것이었다.

@Dependency(\.weatherClient) var weatherClient

case let .searchResultTapped(location):
          // 통신중이다 버퍼링 표시
        state.resultForecastRequestInFlight = location

        return .run { send in
          await send(
            .forecastResponse(
              location.id,
              Result { try await self.weatherClient.forecast(location: location) }
            )
          )
        }

 

 

 

이제 마지막으로 실제 뷰에서 어떻게 사용되는지 알아보면 끝이다...!

SearchView 전체 코드

struct SearchView: View {
  @Bindable var store: StoreOf<Search>

  var body: some View {
    NavigationStack {
      VStack(alignment: .leading) {
        Text(readMe)
          .padding()

        HStack {
          Image(systemName: "magnifyingglass")
          TextField(
            "New York, San Francisco, ...", text: $store.searchQuery.sending(\.searchQueryChanged)
          )
          .textFieldStyle(.roundedBorder)
          .autocapitalization(.none)
          .disableAutocorrection(true)
        }
        .padding(.horizontal, 16)

        List {
            // seoul이라고 검색했을때 seoul과 그와 이름이 유사한 도시들이 List로 나옴
            // 이때 location은 Result 구조체
//            struct Result: Decodable, Equatable, Identifiable, Sendable {
//              var country: String
//              var latitude: Double
//              var longitude: Double
//              var id: Int
//              var name: String
//              var admin1: String?
//            }
          ForEach(store.results) { location in
            VStack(alignment: .leading) {
              Button {
                store.send(.searchResultTapped(location))
              } label: {
                HStack {
                  Text(location.name)

                    // List로 나열했을때 id로 구분해서 버퍼링 표시를 하기위해 identifiable처리
                  if store.resultForecastRequestInFlight?.id == location.id {
                    ProgressView()
                  }
                }
              }

                // id 값이 서로 일치하면 해당 리스트 id위치에 검색결과 뷰 표시
              if location.id == store.weather?.id {
                weatherView(locationWeather: store.weather)
              }
            }
          }
        }

        Button("Weather API provided by Open-Meteo") {
          UIApplication.shared.open(URL(string: "https://open-meteo.com/en")!)
        }
        .foregroundColor(.gray)
        .padding(.all, 16)
      }
      .navigationTitle("Search")
    }
    .task(id: store.searchQuery) {
      do {
        try await Task.sleep(for: .milliseconds(300))
        await store.send(.searchQueryChangeDebounced).finish()
      } catch {}
    }
  }

  @ViewBuilder
  func weatherView(locationWeather: Search.State.Weather?) -> some View {
    if let locationWeather {
      let days = locationWeather.days
        .enumerated()
        .map { idx, weather in formattedWeather(day: weather, isToday: idx == 0) }

      VStack(alignment: .leading) {
        ForEach(days, id: \.self) { day in
          Text(day)
        }
      }
      .padding(.leading, 16)
    }
  }
}

 

@Bindable로 store를 불러와서 사용한다.

 

현재 진행중인 토이 프로젝트에서 나는 이렇게 사용중이었다.

 @State var store: StoreOf<HomeFeature>
 
 ...
  CustomTextField(text: $store.message)
  
  // Reducer
  
  switch action {
            case .binding(\.message):
               // 여기서 사용자 이름 관찰
               print ( "toDolMessage" , state.message)
               return .none

 

Bindable은 iOS17 이상에서 지원하여 최대한 많은 사용자가 사용할 수 있도록 @State로 불러와 사용하고

 

모듈에서 바인딩 값을 사용하려면 Reducer에서 case .binding(\.바인딩값) 으로 binding case를 만들었어야 했다.

 

가독성 측면에서 우수하지만 내 프로젝트에서 적용하지 못해 아쉬울 따름이다.

 

.tast(id: store.searchQuery)

  • 검색 입력값이 바뀌면 3초의 시간을 가지고 비동기 통신을 실행한다.

 

전체 동작 시연

 

 

예제 코드를 뜯어보니 기존 코드를 어떻게 리팩토링하면 좋아질지 보이고, TCA 구조의 정석을 맛볼 수 있었다.

 

이제 토이 프로젝트에 적용하러 가보자.