《SwiftUI 与 Combine 编程》 + TCA - SwiftUI 的救星? (喵神) 读后实践
一个天气 App,可搜索、关注城市,查看城市详细天气预报。
由 SwiftUI 驱动的跨平台 app,包括 UI 布局、状态管理、网络数据获取和本地数据存储等等。
编译环境:macOS 13.0, Xcode 14.1, iOS 16.1
preview.mp4
- State:即状态,是一个用于描述某个功能的执行逻辑,和渲染界面所需的数据的类。
struct SearchReducer: ReducerProtocol {
struct State: Equatable {
@BindableState var searchQuery = ""
var list: [Find.City] = []
}
}
- Action:一个代表在功能中所有可能的动作的类,如用户的行为、提醒,和事件源等。
struct SearchReducer: ReducerProtocol {
enum Action: Equatable, BindableAction {
case binding(BindingAction<SearchState>)
case search(query: String)
case citiesResponse(Result<[Find.City], AppError>)
}
}
- Environment:一个包含功能的依赖的类,如API客户端,分析客户端等。
// SearchReducer ...
@Dependency(\.weatherClient) var weatherClient
// ...
- Reducer:一个用于描述触发「Action」时,如何从当前状态(state)变化到下一个状态的函数,它同时负责返回任何需要被执行的「Effect」,如API请求(通过返回一个「Effect」实例来完成)。
struct SearchReducer: ReducerProtocol {
var body: some ReducerProtocol<State, Action> {
BindingReducer()
Reduce { state, action in
switch action {
case .binding(let action):
if action.keyPath == \.$searchQuery, state.searchQuery.count == 0 {
state.status = .normal
state.list = []
}
return .none
case .search(let query):
struct SearchCityId: Hashable { }
guard state.status != .loading else { return .none }
state.status = .loading
return .task {
await .citiesResponse(TaskResult<[Find.City]> {
try await weatherClient.searchCity(query)
})
}
case .citiesResponse(let result):
switch result {
case .success(let list):
state.status = list.count > 0 ? .normal : .noResult
state.list = list
case .failure(let error):
state.status = .failed(error.localizedDescription)
state.list = []
}
return .none
}
}
}
}
- Store:用于驱动某个功能的运行时(runtime)。将所有用户行为发送到「Store」中,令它运行「Reducer」和「Effects」。同时从「Store」中观测「State」,以更新UI。
SearchView(
store: .init(
initialState: .init(),
reducer: WeatherReducer()
)
)
struct SearchView: View {
let store: StoreOf<WeatherReducer>
var body: some View {
WithViewStore(searchStore) { viewStore in
List(viewStore.list) { item in
Cell(...)
}
.searchable(text: viewStore.binding(\.$searchQuery))
.onSubmit(of: .search) {
viewStore.send(.search(query: viewStore.searchQuery))
}
}
}
}
NavigationView {
// 第一个 view 为左侧 sidebar
List {
// 点击 link 会 push 到 destination;如果是分屏下,destination 会显示在 detailView。
NavigationLink(destination: CityView(city: city)) {
Text(city.description)
}
}
// 第二个 view 为右侧 detail view
Image(systemName: "cloud.sun").font(.largeTitle)
}
// iOS16.0+ macOS13.0+
NavigationSplitView { // sidebar
List(selection: searchViewStore.binding(\.$selectedCity)) {
SearchSection(viewStore: searchViewStore)
FollowingSection(store: forecastStore)
}
} detail: { // detail
IfLetStore(
store.scope(state: \.search.selectedCity)
) { letStore in
WithViewStore(letStore) { letViewStore in
CityView(store: forecastStore, city: letViewStore.state)
}
} else: {
Image(systemName: "cloud.sun").font(.largeTitle)
}
}
struct WeatherClient {
var searchCity: @Sendable (String) async throws -> [Find.City]
}
extension WeatherClient {
static let live = WeatherClient(
searchCity: { query in
guard let q = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: "https://openweathermap.org/data/2.5/find?q=\(q)&appid=\(appid)&units=metric")
else {
throw AppError.badURL
}
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(Find.self, from: data).list
}
)
通过 @Environment
获取 horizontalSizeClass
环境变量。
当 horizontalSizeClass == .compact
,可能是竖屏的 iPhone 或者分屏下的 iPad。
@Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass?
var body: some View {
if horizontalSizeClass == .compact {
VStack {
// ...
}
} else {
HStack {
// ...
}
}
}
同样还有 verticalSizeClass
源码不含 appid,需要编译看效果可以去官网注册免费的 appid。然后赋值 WeatherClient.swift
:
private let appid = "xxx"