axiom-swiftui-architecture by charleswiltgen/axiom
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-swiftui-architecture在以下情况下使用此技能:
| 你可能问的问题 | 此技能为何有帮助 |
|---|---|
| “我的模型视图文件中有相当多关于逻辑的代码。如何提取它?” | 提供重构工作流程和决策树,以确定逻辑的归属位置 |
| “我应该使用 MVVM、TCA 还是 Apple 的原生模式?” | 基于应用复杂度、团队规模、可测试性需求的决策标准 |
| “如何使我的 SwiftUI 代码可测试?” | 展示无需导入 SwiftUI 即可实现测试的分离模式 |
| “格式化器和计算应该放在哪里?” | 反模式部分防止逻辑出现在视图主体中 |
| “我应该使用哪个属性包装器?” | 用于 @State、@Environment、@Bindable 或普通属性的决策树 |
What's driving your architecture choice?
│
├─ Starting fresh, small/medium app, want Apple's patterns?
│ └─ Use Apple's Native Patterns (Part 1)
│ - @Observable models for business logic
│ - State-as-Bridge for async boundaries
│ - Property wrapper decision tree
│
├─ Familiar with MVVM from UIKit?
│ └─ Use MVVM Pattern (Part 2)
│ - ViewModels as presentation adapters
│ - Clear View/ViewModel/Model separation
│ - Works well with @Observable
│
├─ Complex app, need rigorous testability, team consistency?
│ └─ Consider TCA (Part 3)
│ - State/Action/Reducer/Store architecture
│ - Excellent testing story
│ - Learning curve + boilerplate trade-off
│
└─ Complex navigation, deep linking, multiple entry points?
└─ Add Coordinator Pattern (Part 4)
- Can combine with any of the above
- Extracts navigation logic from views
- NavigationPath + Coordinator objects
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
“数据模型在数据与和数据进行交互的视图之间提供了分离。这种分离促进了模块化,提高了可测试性,并有助于更轻松地理解应用的工作原理。” — Apple 开发者文档
Apple 的现代 SwiftUI 模式(WWDC 2023-2025)围绕以下几点:
异步函数会创建可能破坏动画的挂起点:
// ❌ 有问题:动画可能错过帧截止时间
struct ColorExtractorView: View {
@State private var isLoading = false
var body: some View {
Button("Extract Colors") {
Task {
isLoading = true // 同步 ✅
await extractColors() // ⚠️ 挂起点!
isLoading = false // ❌ 可能发生得太晚
}
}
.scaleEffect(isLoading ? 1.5 : 1.0) // ⚠️ 动画时间不确定
}
}
“找到需要时间敏感变化的 UI 代码与长时间运行的异步逻辑之间的边界。”
// ✅ 正确:State 桥接 UI 和异步代码
@Observable
class ColorExtractor {
var isLoading = false
var colors: [Color] = []
func extract(from image: UIImage) async {
// 此方法是异步的,可以放在模型中
let extracted = await heavyComputation(image)
// 用于 UI 更新的同步修改
self.colors = extracted
}
}
struct ColorExtractorView: View {
let extractor: ColorExtractor
var body: some View {
Button("Extract Colors") {
// 用于动画的同步状态更改
withAnimation {
extractor.isLoading = true
}
// 启动异步工作
Task {
await extractor.extract(from: currentImage)
// 用于动画的同步状态更改
withAnimation {
extractor.isLoading = false
}
}
}
.scaleEffect(extractor.isLoading ? 1.5 : 1.0)
}
}
好处:
只需回答 3 个问题:
Which property wrapper should I use?
│
├─ Does this model need to be STATE OF THE VIEW ITSELF?
│ └─ YES → Use @State
│ Examples: Form inputs, local toggles, sheet presentations
│ Lifetime: Managed by the view's lifetime
│
├─ Does this model need to be part of the GLOBAL ENVIRONMENT?
│ └─ YES → Use @Environment
│ Examples: User account, app settings, dependency injection
│ Lifetime: Lives at app/scene level
│
├─ Does this model JUST NEED BINDINGS?
│ └─ YES → Use @Bindable
│ Examples: Editing a model passed from parent
│ Lightweight: Only enables $ syntax for bindings
│
└─ NONE OF THE ABOVE?
└─ Use as plain property
Examples: Immutable data, parent-owned models
No wrapper needed: @Observable handles observation
// ✅ @State — 视图拥有模型
struct DonutEditor: View {
@State private var donutToAdd = Donut() // 视图自身的状态
var body: some View {
TextField("Name", text: $donutToAdd.name)
}
}
// ✅ @Environment — 应用范围的模型
struct MenuView: View {
@Environment(Account.self) private var account // 全局
var body: some View {
Text("Welcome, \(account.userName)")
}
}
// ✅ @Bindable — 需要对父级拥有的模型进行绑定
struct DonutRow: View {
@Bindable var donut: Donut // 父级拥有它
var body: some View {
TextField("Name", text: $donut.name) // 需要绑定
}
}
// ✅ 普通属性 — 仅读取
struct DonutRow: View {
let donut: Donut // 父级拥有,无需绑定
var body: some View {
Text(donut.name) // 仅读取
}
}
使用 @Observable 来处理需要触发 UI 更新的业务逻辑:
// ✅ 包含业务逻辑的领域模型
@Observable
class FoodTruckModel {
var orders: [Order] = []
var donuts = Donut.all
var orderCount: Int {
orders.count // 计算属性自动工作
}
func addDonut() {
donuts.append(Donut())
}
}
// ✅ 视图自动跟踪访问的属性
struct DonutMenu: View {
let model: FoodTruckModel // 无需包装器!
var body: some View {
List {
Section("Donuts") {
ForEach(model.donuts) { donut in
Text(donut.name) // 跟踪 model.donuts
}
Button("Add") {
model.addDonut()
}
}
Section("Orders") {
Text("Count: \(model.orderCount)") // 跟踪 model.orders
}
}
}
}
工作原理(WWDC 2023/10149):
body 执行期间访问了哪些属性当你需要过滤、排序或视图特定逻辑时,将 ViewModel 用作 表示适配器:
// ✅ ViewModel 作为表示适配器
@Observable
class PetStoreViewModel {
let petStore: PetStore // 领域模型
var searchText: String = ""
// 视图特定的计算属性
var filteredPets: [Pet] {
guard !searchText.isEmpty else { return petStore.myPets }
return petStore.myPets.filter { $0.name.contains(searchText) }
}
}
struct PetListView: View {
@Bindable var viewModel: PetStoreViewModel
var body: some View {
List {
ForEach(viewModel.filteredPets) { pet in
PetRowView(pet: pet)
}
}
.searchable(text: $viewModel.searchText)
}
}
何时使用 ViewModel 适配器:
何时不使用 ViewModel:
MVVM(Model-View-ViewModel)适用于以下情况:
✅ 你从 UIKit 中熟悉它 — 团队更容易上手 ✅ 你想要明确的 View/ViewModel 分离 — 清晰的契约 ✅ 你有复杂的表示逻辑 — 多个过滤/排序操作 ✅ 你正在从 UIKit 迁移 — 熟悉的心智模型
❌ 避免在以下情况使用 MVVM:
// Model — 领域数据和业务逻辑
struct Pet: Identifiable {
let id: UUID
var name: String
var kind: Kind
var trick: String
var hasAward: Bool = false
mutating func giveAward() {
hasAward = true
}
}
// ViewModel — 表示逻辑
@Observable
class PetListViewModel {
private let petStore: PetStore
var pets: [Pet] { petStore.myPets }
var searchText: String = ""
var selectedSort: SortOption = .name
var filteredSortedPets: [Pet] {
let filtered = pets.filter { pet in
searchText.isEmpty || pet.name.contains(searchText)
}
return filtered.sorted { lhs, rhs in
switch selectedSort {
case .name: lhs.name < rhs.name
case .kind: lhs.kind.rawValue < rhs.kind.rawValue
}
}
}
init(petStore: PetStore) {
self.petStore = petStore
}
func awardPet(_ pet: Pet) {
petStore.awardPet(pet.id)
}
}
// View — 仅 UI
struct PetListView: View {
@Bindable var viewModel: PetListViewModel
var body: some View {
List {
ForEach(viewModel.filteredSortedPets) { pet in
PetRow(pet: pet) {
viewModel.awardPet(pet)
}
}
}
.searchable(text: $viewModel.searchText)
}
}
// ❌ @State + @Observable 是冗余的 — @State 创建自己的存储
struct MyView: View {
@State private var viewModel = MyViewModel() // ❌ 冗余包装器
}
// ✅ 直接传递 @Observable — 仅当视图拥有生命周期时才使用 @State
struct MyView: View {
let viewModel: MyViewModel // ✅ 或者如果视图创建它,则使用 @State
}
// ❌ 不要这样做
@Observable
class AppViewModel {
// Settings
var isDarkMode = false
var notificationsEnabled = true
// User
var userName = ""
var userEmail = ""
// Content
var posts: [Post] = []
var comments: [Comment] = []
// ... 50 more properties
}
// ✅ 正确:分离关注点
@Observable
class SettingsViewModel {
var isDarkMode = false
var notificationsEnabled = true
}
@Observable
class UserProfileViewModel {
var user: User
}
@Observable
class FeedViewModel {
var posts: [Post] = []
}
// ❌ 业务规则属于 Model,而不是 ViewModel
@Observable class OrderViewModel {
func calculateDiscount(for order: Order) -> Double { /* ... */ } // ❌ 业务逻辑
}
// ✅ Model 拥有业务逻辑;ViewModel 仅格式化用于显示
struct Order {
func calculateDiscount() -> Double { /* business rules */ }
}
@Observable class OrderViewModel {
let order: Order
var displayDiscount: String {
"$\(order.calculateDiscount(), specifier: "%.2f")" // ✅ 仅格式化
}
}
TCA 是来自 Point-Free 的第三方架构。在以下情况下考虑使用:
✅ 严格的可测试性至关重要 — TestStore 使测试具有确定性 ✅ 大型团队需要一致性 — 严格的模式减少差异 ✅ 复杂的状态管理 — 副作用、依赖项、组合 ✅ 你重视类 Redux 的模式 — 单向数据流
❌ 在以下情况避免使用 TCA:
TCA 有 4 个构建块 — State(数据)、Action(事件)、Reducer(状态演变)和 Store(运行时引擎)。以下是它们在单个功能中的体现:
// STATE — 你的功能需要的数据
@ObservableState
struct CounterFeature {
var count = 0
var fact: String?
var isLoading = false
}
// ACTION — 所有可能的事件
enum Action {
case incrementButtonTapped
case decrementButtonTapped
case factButtonTapped
case factResponse(String)
}
// REDUCER — 状态如何响应动作而演变
struct CounterFeature: Reducer {
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .incrementButtonTapped:
state.count += 1
return .none
case .decrementButtonTapped:
state.count -= 1
return .none
case .factButtonTapped:
state.isLoading = true
return .run { [count = state.count] send in
let fact = try await numberFact(count)
await send(.factResponse(fact))
}
case let .factResponse(fact):
state.isLoading = false
state.fact = fact
return .none
}
}
}
}
// STORE — 接收动作并执行 reducer 的运行时
struct CounterView: View {
let store: StoreOf<CounterFeature>
var body: some View {
VStack {
Text("\(store.count)")
Button("Increment") { store.send(.incrementButtonTapped) }
}
}
}
| 好处 | 描述 |
|---|---|
| 可测试性 | TestStore 使测试具有确定性和全面性 |
| 一致性 | 所有功能使用一种模式,减少认知负荷 |
| 组合性 | 小的 reducer 组合成更大的功能 |
| 副作用 | 结构化的效果管理(网络、计时器等) |
| 成本 | 描述 |
|---|---|
| 样板代码 | 每个功能都需要 State/Action/Reducer |
| 学习曲线 | 来自函数式编程的概念(效果、依赖项) |
| 依赖项 | 第三方库,非 Apple 支持 |
| 迭代速度 | 对于简单功能需要编写更多代码 |
| 场景 | 建议 |
|---|---|
| 小型应用 (< 10 个屏幕) | Apple 模式(更简单) |
| 中型应用,经验丰富的团队 | 如果可测试性是优先事项,则使用 TCA |
| 大型应用,多个团队 | 为了一致性使用 TCA |
| 快速原型设计 | Apple 模式(更快) |
| 关键任务(银行、医疗) | 为了严格测试使用 TCA |
Coordinators 从视图中提取导航逻辑。在以下情况下使用:
✅ 复杂导航 — 多个路径、条件流 ✅ 深度链接 — URL 驱动的导航到任何屏幕 ✅ 多个入口点 — 同一屏幕来自不同上下文 ✅ 可测试的导航 — 将导航与 UI 隔离
// 最小化 coordinator — Route 枚举 + @Observable coordinator + NavigationStack 绑定
enum Route: Hashable {
case detail(Pet)
case settings
}
@Observable
class AppCoordinator {
var path: [Route] = []
func showDetail(for pet: Pet) { path.append(.detail(pet)) }
func popToRoot() { path.removeAll() }
}
// 根视图将 NavigationStack 绑定到 coordinator 的路径
struct AppView: View {
@State private var coordinator = AppCoordinator()
var body: some View {
NavigationStack(path: $coordinator.path) {
PetListView(coordinator: coordinator)
.navigationDestination(for: Route.self) { route in
switch route {
case .detail(let pet): PetDetailView(pet: pet, coordinator: coordinator)
case .settings: SettingsView(coordinator: coordinator)
}
}
}
}
}
通过 .onOpenURL 和 URL 到路由的解析添加深度链接:
// 添加到 AppCoordinator
func handleDeepLink(_ url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return }
// 将 URL 路径解析为路由
if components.path.hasPrefix("/pets/"), let id = components.path.split(separator: "/").last {
path = [.detail(loadPet(id: String(id)))]
}
}
// 添加到 AppView 的 body
.onOpenURL { url in coordinator.handleDeepLink(url) }
Coordinators 无需 SwiftUI 即可测试 — 直接断言路径状态:
func testDeepLink() {
let coordinator = AppCoordinator()
coordinator.handleDeepLink(URL(string: "myapp://pets/123")!)
XCTAssertEqual(coordinator.path.count, 1) // 导航到详情
}
对于状态恢复、高级 URL 路由和基于标签页的协调,请参阅 axiom-swiftui-nav — 模式 7(Coordinator)用于结构,模式 1b 用于基于 URL 的深度链接。
你可以将 Coordinators 与任何架构结合使用:
| 模式 | Coordinator 角色 |
|---|---|
| Apple 原生 | Coordinator 管理路径,@Observable 模型用于数据 |
| MVVM | Coordinator 管理路径,ViewModels 用于表示 |
| TCA | Coordinator 管理路径,Reducers 用于功能 |
在你的视图上运行此检查清单:
视图主体包含:
如果存在任何这些内容,该逻辑很可能应该移出。
使用此决策树:
Where does this logic belong?
│
├─ Pure domain logic (discounts, validation, business rules)?
│ └─ Extract to Model
│ Example: Order.calculateDiscount()
│
├─ Presentation logic (filtering, sorting, formatting)?
│ └─ Extract to ViewModel or computed property
│ Example: filteredItems, displayPrice
│
├─ External side effects (API, database, file system)?
│ └─ Extract to Service
│ Example: APIClient, DatabaseManager
│
└─ Just expensive computation?
└─ Cache with @State or create once
Example: let formatter = DateFormatter()
// ❌ 之前:逻辑在视图主体中
struct OrderListView: View {
let orders: [Order]
var body: some View {
let formatter = NumberFormatter() // ❌ 每次渲染都创建
formatter.numberStyle = .currency
let discounted = orders.filter { order in // ❌ 每次渲染都计算
let discount = order.total * 0.1 // ❌ 视图中的业务逻辑
return discount > 10.0
}
return List(discounted) { order in
Text(formatter.string(from: order.total)!) // ❌ 强制解包
}
}
}
// ✅ 之后:逻辑已提取
// Model — 业务逻辑
struct Order {
let id: UUID
let total: Decimal
var discount: Decimal {
total * 0.1
}
var qualifiesForDiscount: Bool {
discount > 10.0
}
}
// ViewModel — 表示逻辑
@Observable
class OrderListViewModel {
let orders: [Order]
private let formatter: NumberFormatter // ✅ 创建一次
var discountedOrders: [Order] { // ✅ 计算属性
orders.filter { $0.qualifiesForDiscount }
}
init(orders: [Order]) {
self.orders = orders
self.formatter = NumberFormatter()
formatter.numberStyle = .currency
}
func formattedTotal(_ order: Order) -> String {
formatter.string(from: order.total as NSNumber) ?? "$0.00"
}
}
// View — 仅 UI
struct OrderListView: View {
let viewModel: OrderListViewModel
var body: some View {
List(viewModel.discountedOrders) { order in
Text(viewModel.formattedTotal(order))
}
}
}
如果满足以下条件,则重构成功:
// ✅ 无需导入 SwiftUI 即可测试
import XCTest
final class OrderTests: XCTestCase {
func testDiscountCalculation() {
let order = Order(id: UUID(), total: 100)
XCTAssertEqual(order.discount, 10)
}
func testQualifiesForDiscount() {
let order = Order(id: UUID(), total: 100)
XCTAssertTrue(order.qualifiesForDiscount)
}
}
final class OrderViewModelTests: XCTestCase {
func testFilteredOrders() {
let orders = [
Order(id: UUID(), total: 50), // Discount: 5 ❌
Order(id: UUID(), total: 200), // Discount: 20 ✅
]
let viewModel = OrderListViewModel(orders: orders)
XCTAssertEqual(viewModel.discountedOrders.count, 1)
}
}
提取后,更新属性包装器:
// 重构前
struct OrderListView: View {
@State private var orders: [Order] = [] // 视图拥有
// ... 主体中的逻辑
}
// 重构后
struct OrderListView: View {
@State private var viewModel: OrderListViewModel // 视图拥有 ViewModel
init(orders: [Order]) {
_viewModel = State(initialValue: OrderListViewModel(orders: orders))
}
}
// 或者如果父级拥有它
struct OrderListView: View {
let viewModel: OrderListViewModel // 父级拥有,仅读取
}
// 或者如果需要绑定
struct OrderListView: View {
@Bindable var viewModel: OrderListViewModel // 父级拥有,需要 $
}
// ❌ 不要这样做
struct ProductListView: View {
let products: [Product]
var body: some View {
let formatter = NumberFormatter() // ❌ 每次渲染都创建!
formatter.numberStyle = .currency
let sorted = products.sorted { $0.price > $1.price } // ❌ 每次渲染都排序!
return List(sorted) { product in
Text("\(product.name): \(formatter.string(from: product.price)!)")
}
}
}
为何错误:
formatter 每次渲染都创建(性能)sorted 每次渲染都计算(性能)sorted)位于视图中(不可测试)// ✅ 正确
@Observable
class ProductListViewModel {
let products: [Product]
private let formatter = NumberFormatter()
var sortedProducts: [Product] {
products.sorted { $0.price > $1.price }
}
init(products: [Product]) {
self.products = products
formatter.numberStyle = .currency
}
func formattedPrice(_ product: Product) -> String {
formatter.string(from: product.price as NSNumber) ?? "$0.00"
}
}
struct ProductListView: View {
let viewModel: ProductListViewModel
var body: some View {
List(viewModel.sortedProducts) { product in
Text("\(product.name): \(viewModel.formattedPrice(product))")
}
}
}
参见上面第一部分中的 State-as-Bridge 模式 — 保持 UI 状态更改同步(在 withAnimation 内部),通过 Task 单独启动异步工作。
// ❌ 不要对传入的模型使用 @State
struct DetailView: View {
@State var item: Item // ❌ 创建副本,丢失父级更改
}
// ✅ 正确:对传入的模型不使用包装器
struct DetailView: View {
let item: Item // ✅ 或者如果你需要 $item,则使用 @Bindable
}
// ❌ 不要对视图本地状态使用 @Environment
struct FormView: View {
@Environment(FormData.self) var formData // ❌ 对于本地表单来说过于复杂
}
// ✅ 正确:对视图本地状态使用 @State
struct FormView: View {
@State private var formData = FormData() // ✅ 视图拥有它
}
参见上面第二部分中的 MVVM 错误 2 — 按关注点拆分为单独的 ViewModels。
切勿在 @Observable 类内部使用 @AppStorage — 它会静默地破坏观察。@AppStorage 是为 SwiftUI 视图设计的属性包装器,而不是模型类。
// ❌ 已损坏 — @AppStorage 静默地破坏 @Observable
@Observable
class Settings {
@AppStorage("theme") var theme = "light" // 更改不会触发视图更新
}
// ✅ 在视图中读取 @AppStorage,传递给模型
struct SettingsView: View {
@AppStorage("theme") private var theme = "light"
// ...
}
在视图主体中创建 Binding(get:set:) 会在每次求值时创建一个新的绑定,破坏 SwiftUI 的身份跟踪。
// ❌ 每次主体求值时都创建新的 Binding
var body: some View {
TextField("Name", text: Binding(
get: { model.name },
set: { model.name = $0 }
))
}
// ✅ 使用 @Bindable 或计算绑定
var body: some View {
@Bindable var model = model
TextField("Name", text: $model.name)
}
合并 SwiftUI 代码前,请验证:
withAnimation { } 块之间没有 await经理:“我们需要在周五前完成这个功能。暂时先把逻辑放在视图里,我们以后再重构。”
如果你听到:
选项 A:将逻辑放在视图中
选项 B:正确提取逻辑
步骤 1:承认截止日期
“我理解周五是截止日期。让我向你展示为什么适当的分离实际上更快。”
步骤 2:展示时间比较
“将逻辑放在视图中需要 5 小时且没有测试。正确提取需要 3.5 小时且有完整的测试。我们节省了 1.5 小时并且获得了测试。”
步骤 3:提供折中方案
“如果时间真的不够,我现在可以提取 80%,并将剩余的 20% 标记为技术债务并创建工单。但我们不要完全跳过提取。”
步骤 4:如果被迫继续,请记录
// TODO: TECH DEBT - Extract business logic to ViewModel
// Ticket: PROJ-123
// Added: 2025-12-14
// Reason: Deadline pressure from manager
// Estimated refactor time: 2 hours
仅在以下情况下跳过提取:
技术负责人:“TCA 对这个项目来说太复杂了。就用原生的 SwiftUI 配合 @Observable 吧。”
问这些问题:
| 问题 | TCA | 原生 |
|---|---|---|
| 可测试性是否关键(医疗、金融)? | ✅ | ❌ |
| 你是否有 < 5 个屏幕? | ❌ | ✅ |
| 团队是否有函数式编程经验? | ✅ | ❌ |
| 是否需要快速原型设计? | ❌ | ✅ |
| 大型团队的一致性是否关键? | ✅ | ❌ |
| 是否有复杂的副作用(套接字、计时器)? | ✅ | ~ |
推荐矩阵:
如果支持 TCA:
“我理解 TCA 感觉有点重。但我们正在构建一个银行应用。TestStore 为我们提供了全面的测试,可以在生产前捕获错误。对于 2 年的维护来说,2 周的学习曲线是值得的。”
如果反对 TCA:
“我同意 TCA 很强大,但我们每周都在原型化功能。样板代码会减慢我们的速度。我们现在先使用 @Observable,如果证明这些功能值得构建,再迁移到 TCA。”
产品经理:“我们这个月要发布 3 个功能。我们不能花 2 周时间重构现有视图。”
你不必一次重构所有内容:
第 1 周:提取 1 个视图
第 2 周:提取 2 个视图
第 3 周:新功能使用正确的架构
第 2 个月:在接触文件时逐步重构
“我并不是提议我们停止功能工作 2 周。我提议:
- 第 1 周:提取我们最糟糕的视图(那个有 500 行的 OrdersView)
- 第 2 周:再提取 2 个有问题的视图
- 从现在开始:所有新功能都使用正确的架构
- 当我们无论如何都要接触旧视图时,再重构它们
这需要前期投入 10 小时,但从现在开始每个功能可以节省 2+ 小时。”
// 😰 200 行痛苦
struct OrderListView: View {
@State private var orders: [Order] = []
@State private var searchText = ""
@State private var selectedFilter: FilterType = .all
var body: some View {
// ❌ 每次渲染都创建格式化器
let currencyFormatter = NumberFormatter()
currencyFormatter.numberStyle = .currency
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
// ❌ 视图中的
Use this skill when:
| What You Might Ask | Why This Skill Helps |
|---|---|
| "There's quite a bit of code in my model view files about logic things. How do I extract it?" | Provides refactoring workflow with decision trees for where logic belongs |
| "Should I use MVVM, TCA, or Apple's vanilla patterns?" | Decision criteria based on app complexity, team size, testability needs |
| "How do I make my SwiftUI code testable?" | Shows separation patterns that enable testing without SwiftUI imports |
| "Where should formatters and calculations go?" | Anti-patterns section prevents logic in view bodies |
| "Which property wrapper do I use?" | Decision tree for @State, @Environment, @Bindable, or plain properties |
What's driving your architecture choice?
│
├─ Starting fresh, small/medium app, want Apple's patterns?
│ └─ Use Apple's Native Patterns (Part 1)
│ - @Observable models for business logic
│ - State-as-Bridge for async boundaries
│ - Property wrapper decision tree
│
├─ Familiar with MVVM from UIKit?
│ └─ Use MVVM Pattern (Part 2)
│ - ViewModels as presentation adapters
│ - Clear View/ViewModel/Model separation
│ - Works well with @Observable
│
├─ Complex app, need rigorous testability, team consistency?
│ └─ Consider TCA (Part 3)
│ - State/Action/Reducer/Store architecture
│ - Excellent testing story
│ - Learning curve + boilerplate trade-off
│
└─ Complex navigation, deep linking, multiple entry points?
└─ Add Coordinator Pattern (Part 4)
- Can combine with any of the above
- Extracts navigation logic from views
- NavigationPath + Coordinator objects
"A data model provides separation between the data and the views that interact with the data. This separation promotes modularity, improves testability, and helps make it easier to reason about how the app works." — Apple Developer Documentation
Apple's modern SwiftUI patterns (WWDC 2023-2025) center on:
Async functions create suspension points that can break animations:
// ❌ Problematic: Animation might miss frame deadline
struct ColorExtractorView: View {
@State private var isLoading = false
var body: some View {
Button("Extract Colors") {
Task {
isLoading = true // Synchronous ✅
await extractColors() // ⚠️ Suspension point!
isLoading = false // ❌ Might happen too late
}
}
.scaleEffect(isLoading ? 1.5 : 1.0) // ⚠️ Animation timing uncertain
}
}
"Find the boundaries between UI code that requires time-sensitive changes, and long-running async logic."
// ✅ Correct: State bridges UI and async code
@Observable
class ColorExtractor {
var isLoading = false
var colors: [Color] = []
func extract(from image: UIImage) async {
// This method is async and can live in the model
let extracted = await heavyComputation(image)
// Synchronous mutation for UI update
self.colors = extracted
}
}
struct ColorExtractorView: View {
let extractor: ColorExtractor
var body: some View {
Button("Extract Colors") {
// Synchronous state change for animation
withAnimation {
extractor.isLoading = true
}
// Launch async work
Task {
await extractor.extract(from: currentImage)
// Synchronous state change for animation
withAnimation {
extractor.isLoading = false
}
}
}
.scaleEffect(extractor.isLoading ? 1.5 : 1.0)
}
}
Benefits :
There are only 3 questions to answer:
Which property wrapper should I use?
│
├─ Does this model need to be STATE OF THE VIEW ITSELF?
│ └─ YES → Use @State
│ Examples: Form inputs, local toggles, sheet presentations
│ Lifetime: Managed by the view's lifetime
│
├─ Does this model need to be part of the GLOBAL ENVIRONMENT?
│ └─ YES → Use @Environment
│ Examples: User account, app settings, dependency injection
│ Lifetime: Lives at app/scene level
│
├─ Does this model JUST NEED BINDINGS?
│ └─ YES → Use @Bindable
│ Examples: Editing a model passed from parent
│ Lightweight: Only enables $ syntax for bindings
│
└─ NONE OF THE ABOVE?
└─ Use as plain property
Examples: Immutable data, parent-owned models
No wrapper needed: @Observable handles observation
// ✅ @State — View owns the model
struct DonutEditor: View {
@State private var donutToAdd = Donut() // View's own state
var body: some View {
TextField("Name", text: $donutToAdd.name)
}
}
// ✅ @Environment — App-wide model
struct MenuView: View {
@Environment(Account.self) private var account // Global
var body: some View {
Text("Welcome, \(account.userName)")
}
}
// ✅ @Bindable — Need bindings to parent-owned model
struct DonutRow: View {
@Bindable var donut: Donut // Parent owns it
var body: some View {
TextField("Name", text: $donut.name) // Need binding
}
}
// ✅ Plain property — Just reading
struct DonutRow: View {
let donut: Donut // Parent owns, no binding needed
var body: some View {
Text(donut.name) // Just reading
}
}
Use @Observable for business logic that needs to trigger UI updates:
// ✅ Domain model with business logic
@Observable
class FoodTruckModel {
var orders: [Order] = []
var donuts = Donut.all
var orderCount: Int {
orders.count // Computed properties work automatically
}
func addDonut() {
donuts.append(Donut())
}
}
// ✅ View automatically tracks accessed properties
struct DonutMenu: View {
let model: FoodTruckModel // No wrapper needed!
var body: some View {
List {
Section("Donuts") {
ForEach(model.donuts) { donut in
Text(donut.name) // Tracks model.donuts
}
Button("Add") {
model.addDonut()
}
}
Section("Orders") {
Text("Count: \(model.orderCount)") // Tracks model.orders
}
}
}
}
How it works (WWDC 2023/10149):
body executionUse ViewModels as presentation adapters when you need filtering, sorting, or view-specific logic:
// ✅ ViewModel as presentation adapter
@Observable
class PetStoreViewModel {
let petStore: PetStore // Domain model
var searchText: String = ""
// View-specific computed property
var filteredPets: [Pet] {
guard !searchText.isEmpty else { return petStore.myPets }
return petStore.myPets.filter { $0.name.contains(searchText) }
}
}
struct PetListView: View {
@Bindable var viewModel: PetStoreViewModel
var body: some View {
List {
ForEach(viewModel.filteredPets) { pet in
PetRowView(pet: pet)
}
}
.searchable(text: $viewModel.searchText)
}
}
When to use a ViewModel adapter :
When NOT to use a ViewModel :
MVVM (Model-View-ViewModel) is appropriate when:
✅ You're familiar with it from UIKit — Easier onboarding for team ✅ You want explicit View/ViewModel separation — Clear contracts ✅ You have complex presentation logic — Multiple filtering/sorting operations ✅ You're migrating from UIKit — Familiar mental model
❌ Avoid MVVM when :
// Model — Domain data and business logic
struct Pet: Identifiable {
let id: UUID
var name: String
var kind: Kind
var trick: String
var hasAward: Bool = false
mutating func giveAward() {
hasAward = true
}
}
// ViewModel — Presentation logic
@Observable
class PetListViewModel {
private let petStore: PetStore
var pets: [Pet] { petStore.myPets }
var searchText: String = ""
var selectedSort: SortOption = .name
var filteredSortedPets: [Pet] {
let filtered = pets.filter { pet in
searchText.isEmpty || pet.name.contains(searchText)
}
return filtered.sorted { lhs, rhs in
switch selectedSort {
case .name: lhs.name < rhs.name
case .kind: lhs.kind.rawValue < rhs.kind.rawValue
}
}
}
init(petStore: PetStore) {
self.petStore = petStore
}
func awardPet(_ pet: Pet) {
petStore.awardPet(pet.id)
}
}
// View — UI only
struct PetListView: View {
@Bindable var viewModel: PetListViewModel
var body: some View {
List {
ForEach(viewModel.filteredSortedPets) { pet in
PetRow(pet: pet) {
viewModel.awardPet(pet)
}
}
}
.searchable(text: $viewModel.searchText)
}
}
// ❌ @State + @Observable is redundant — @State creates its own storage
struct MyView: View {
@State private var viewModel = MyViewModel() // ❌ Redundant wrapper
}
// ✅ Pass @Observable directly — use @State only if the view OWNS the lifecycle
struct MyView: View {
let viewModel: MyViewModel // ✅ Or @State if view creates it
}
// ❌ Don't do this
@Observable
class AppViewModel {
// Settings
var isDarkMode = false
var notificationsEnabled = true
// User
var userName = ""
var userEmail = ""
// Content
var posts: [Post] = []
var comments: [Comment] = []
// ... 50 more properties
}
// ✅ Correct: Separate concerns
@Observable
class SettingsViewModel {
var isDarkMode = false
var notificationsEnabled = true
}
@Observable
class UserProfileViewModel {
var user: User
}
@Observable
class FeedViewModel {
var posts: [Post] = []
}
// ❌ Business rules belong in the Model, not the ViewModel
@Observable class OrderViewModel {
func calculateDiscount(for order: Order) -> Double { /* ... */ } // ❌ Business logic
}
// ✅ Model owns business logic; ViewModel only formats for display
struct Order {
func calculateDiscount() -> Double { /* business rules */ }
}
@Observable class OrderViewModel {
let order: Order
var displayDiscount: String {
"$\(order.calculateDiscount(), specifier: "%.2f")" // ✅ Just formatting
}
}
TCA is a third-party architecture from Point-Free. Consider it when:
✅ Rigorous testability is critical — TestStore makes testing deterministic ✅ Large team needs consistency — Strict patterns reduce variation ✅ Complex state management — Side effects, dependencies, composition ✅ You value Redux-like patterns — Unidirectional data flow
❌ Avoid TCA when :
TCA has 4 building blocks — State (data), Action (events), Reducer (state evolution), and Store (runtime engine). Here they are in a single feature:
// STATE — Data your feature needs
@ObservableState
struct CounterFeature {
var count = 0
var fact: String?
var isLoading = false
}
// ACTION — All possible events
enum Action {
case incrementButtonTapped
case decrementButtonTapped
case factButtonTapped
case factResponse(String)
}
// REDUCER — How state evolves in response to actions
struct CounterFeature: Reducer {
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .incrementButtonTapped:
state.count += 1
return .none
case .decrementButtonTapped:
state.count -= 1
return .none
case .factButtonTapped:
state.isLoading = true
return .run { [count = state.count] send in
let fact = try await numberFact(count)
await send(.factResponse(fact))
}
case let .factResponse(fact):
state.isLoading = false
state.fact = fact
return .none
}
}
}
}
// STORE — Runtime that receives actions and executes reducer
struct CounterView: View {
let store: StoreOf<CounterFeature>
var body: some View {
VStack {
Text("\(store.count)")
Button("Increment") { store.send(.incrementButtonTapped) }
}
}
}
| Benefit | Description |
|---|---|
| Testability | TestStore makes testing deterministic and exhaustive |
| Consistency | One pattern for all features reduces cognitive load |
| Composition | Small reducers combine into larger features |
| Side effects | Structured effect management (networking, timers, etc.) |
| Cost | Description |
|---|---|
| Boilerplate | State/Action/Reducer for every feature |
| Learning curve | Concepts from functional programming (effects, dependencies) |
| Dependency | Third-party library, not Apple-supported |
| Iteration speed | More code to write for simple features |
| Scenario | Recommendation |
|---|---|
| Small app (< 10 screens) | Apple patterns (simpler) |
| Medium app, experienced team | TCA if testability is priority |
| Large app, multiple teams | TCA for consistency |
| Rapid prototyping | Apple patterns (faster) |
| Mission-critical (banking, health) | TCA for rigorous testing |
Coordinators extract navigation logic from views. Use when:
✅ Complex navigation — Multiple paths, conditional flows ✅ Deep linking — URL-driven navigation to any screen ✅ Multiple entry points — Same screen from different contexts ✅ Testable navigation — Isolate navigation from UI
// Minimal coordinator — Route enum + @Observable coordinator + NavigationStack binding
enum Route: Hashable {
case detail(Pet)
case settings
}
@Observable
class AppCoordinator {
var path: [Route] = []
func showDetail(for pet: Pet) { path.append(.detail(pet)) }
func popToRoot() { path.removeAll() }
}
// Root view binds NavigationStack to coordinator's path
struct AppView: View {
@State private var coordinator = AppCoordinator()
var body: some View {
NavigationStack(path: $coordinator.path) {
PetListView(coordinator: coordinator)
.navigationDestination(for: Route.self) { route in
switch route {
case .detail(let pet): PetDetailView(pet: pet, coordinator: coordinator)
case .settings: SettingsView(coordinator: coordinator)
}
}
}
}
}
Add deep linking with .onOpenURL and URL-to-route parsing:
// Add to AppCoordinator
func handleDeepLink(_ url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return }
// Parse URL path into routes
if components.path.hasPrefix("/pets/"), let id = components.path.split(separator: "/").last {
path = [.detail(loadPet(id: String(id)))]
}
}
// Add to AppView's body
.onOpenURL { url in coordinator.handleDeepLink(url) }
Coordinators are testable without SwiftUI — assert path state directly:
func testDeepLink() {
let coordinator = AppCoordinator()
coordinator.handleDeepLink(URL(string: "myapp://pets/123")!)
XCTAssertEqual(coordinator.path.count, 1) // Navigated to detail
}
For state restoration, advanced URL routing, and tab-based coordination, see axiom-swiftui-nav — Pattern 7 (Coordinator) for structure, Pattern 1b for URL-based deep linking.
You can combine Coordinators with any architecture:
| Pattern | Coordinator Role |
|---|---|
| Apple Native | Coordinator manages path, @Observable models for data |
| MVVM | Coordinator manages path, ViewModels for presentation |
| TCA | Coordinator manages path, Reducers for features |
Run this checklist on your views:
View body contains:
If ANY of these are present, that logic should likely move out.
Use this decision tree:
Where does this logic belong?
│
├─ Pure domain logic (discounts, validation, business rules)?
│ └─ Extract to Model
│ Example: Order.calculateDiscount()
│
├─ Presentation logic (filtering, sorting, formatting)?
│ └─ Extract to ViewModel or computed property
│ Example: filteredItems, displayPrice
│
├─ External side effects (API, database, file system)?
│ └─ Extract to Service
│ Example: APIClient, DatabaseManager
│
└─ Just expensive computation?
└─ Cache with @State or create once
Example: let formatter = DateFormatter()
// ❌ Before: Logic in view body
struct OrderListView: View {
let orders: [Order]
var body: some View {
let formatter = NumberFormatter() // ❌ Created every render
formatter.numberStyle = .currency
let discounted = orders.filter { order in // ❌ Computed every render
let discount = order.total * 0.1 // ❌ Business logic in view
return discount > 10.0
}
return List(discounted) { order in
Text(formatter.string(from: order.total)!) // ❌ Force unwrap
}
}
}
// ✅ After: Logic extracted
// Model — Business logic
struct Order {
let id: UUID
let total: Decimal
var discount: Decimal {
total * 0.1
}
var qualifiesForDiscount: Bool {
discount > 10.0
}
}
// ViewModel — Presentation logic
@Observable
class OrderListViewModel {
let orders: [Order]
private let formatter: NumberFormatter // ✅ Created once
var discountedOrders: [Order] { // ✅ Computed property
orders.filter { $0.qualifiesForDiscount }
}
init(orders: [Order]) {
self.orders = orders
self.formatter = NumberFormatter()
formatter.numberStyle = .currency
}
func formattedTotal(_ order: Order) -> String {
formatter.string(from: order.total as NSNumber) ?? "$0.00"
}
}
// View — UI only
struct OrderListView: View {
let viewModel: OrderListViewModel
var body: some View {
List(viewModel.discountedOrders) { order in
Text(viewModel.formattedTotal(order))
}
}
}
Your refactoring succeeded if:
// ✅ Can test without importing SwiftUI
import XCTest
final class OrderTests: XCTestCase {
func testDiscountCalculation() {
let order = Order(id: UUID(), total: 100)
XCTAssertEqual(order.discount, 10)
}
func testQualifiesForDiscount() {
let order = Order(id: UUID(), total: 100)
XCTAssertTrue(order.qualifiesForDiscount)
}
}
final class OrderViewModelTests: XCTestCase {
func testFilteredOrders() {
let orders = [
Order(id: UUID(), total: 50), // Discount: 5 ❌
Order(id: UUID(), total: 200), // Discount: 20 ✅
]
let viewModel = OrderListViewModel(orders: orders)
XCTAssertEqual(viewModel.discountedOrders.count, 1)
}
}
After extraction, update property wrappers:
// Before refactoring
struct OrderListView: View {
@State private var orders: [Order] = [] // View owned
// ... logic in body
}
// After refactoring
struct OrderListView: View {
@State private var viewModel: OrderListViewModel // View owns ViewModel
init(orders: [Order]) {
_viewModel = State(initialValue: OrderListViewModel(orders: orders))
}
}
// Or if parent owns it
struct OrderListView: View {
let viewModel: OrderListViewModel // Parent owns, just reading
}
// Or if need bindings
struct OrderListView: View {
@Bindable var viewModel: OrderListViewModel // Parent owns, need $
}
// ❌ Don't do this
struct ProductListView: View {
let products: [Product]
var body: some View {
let formatter = NumberFormatter() // ❌ Created every render!
formatter.numberStyle = .currency
let sorted = products.sorted { $0.price > $1.price } // ❌ Sorted every render!
return List(sorted) { product in
Text("\(product.name): \(formatter.string(from: product.price)!)")
}
}
}
Why it's wrong :
formatter created on every render (performance)
sorted computed on every render (performance)
Business logic (sorted) lives in view (not testable)
Force unwrap ('!') can crash
// ✅ Correct @Observable class ProductListViewModel { let products: [Product] private let formatter = NumberFormatter()
var sortedProducts: [Product] {
products.sorted { $0.price > $1.price }
}
init(products: [Product]) {
self.products = products
formatter.numberStyle = .currency
}
func formattedPrice(_ product: Product) -> String {
formatter.string(from: product.price as NSNumber) ?? "$0.00"
}
}
struct ProductListView: View { let viewModel: ProductListViewModel
var body: some View {
List(viewModel.sortedProducts) { product in
Text("\(product.name): \(viewModel.formattedPrice(product))")
}
}
}
See the State-as-Bridge pattern in Part 1 above — keep UI state changes synchronous (inside withAnimation), launch async work separately via Task.
// ❌ Don't use @State for passed-in models
struct DetailView: View {
@State var item: Item // ❌ Creates a copy, loses parent changes
}
// ✅ Correct: No wrapper for passed-in models
struct DetailView: View {
let item: Item // ✅ Or @Bindable if you need $item
}
// ❌ Don't use @Environment for view-local state
struct FormView: View {
@Environment(FormData.self) var formData // ❌ Overkill for local form
}
// ✅ Correct: @State for view-local
struct FormView: View {
@State private var formData = FormData() // ✅ View owns it
}
See MVVM Mistake 2 in Part 2 above — split by concern into separate ViewModels.
Never use@AppStorage inside an @Observable class — it silently breaks observation. @AppStorage is a property wrapper designed for SwiftUI views, not model classes.
// ❌ BROKEN — @AppStorage silently breaks @Observable
@Observable
class Settings {
@AppStorage("theme") var theme = "light" // Changes won't trigger view updates
}
// ✅ Read @AppStorage in view, pass to model
struct SettingsView: View {
@AppStorage("theme") private var theme = "light"
// ...
}
Creating Binding(get:set:) in the view body creates a new binding on every evaluation, breaking SwiftUI's identity tracking.
// ❌ New Binding created every body evaluation
var body: some View {
TextField("Name", text: Binding(
get: { model.name },
set: { model.name = $0 }
))
}
// ✅ Use @Bindable or computed binding
var body: some View {
@Bindable var model = model
TextField("Name", text: $model.name)
}
Before merging SwiftUI code, verify:
await between withAnimation { } blocksManager : "We need this feature by Friday. Just put the logic in the view for now, we'll refactor later."
If you hear:
Option A: Put logic in view
Option B: Extract logic properly
Step 1 : Acknowledge the deadline
"I understand Friday is the deadline. Let me show you why proper separation is actually faster."
Step 2 : Show the time comparison
"Putting logic in views takes 5 hours with no tests. Extracting it properly takes 3.5 hours with full tests. We save 1.5 hours AND get tests."
Step 3 : Offer the compromise
"If we're truly out of time, I can extract 80% now and mark the remaining 20% as tech debt with a ticket. But let's not skip extraction entirely."
Step 4 : Document if pressured to proceed
// TODO: TECH DEBT - Extract business logic to ViewModel
// Ticket: PROJ-123
// Added: 2025-12-14
// Reason: Deadline pressure from manager
// Estimated refactor time: 2 hours
Only skip extraction if:
Tech Lead : "TCA is too complex for this project. Just use vanilla SwiftUI with @Observable."
Ask these questions:
| Question | TCA | Vanilla |
|---|---|---|
| Is testability critical (medical, financial)? | ✅ | ❌ |
| Do you have < 5 screens? | ❌ | ✅ |
| Is team experienced with functional programming? | ✅ | ❌ |
| Do you need rapid prototyping? | ❌ | ✅ |
| Is consistency across large team critical? | ✅ | ❌ |
| Do you have complex side effects (sockets, timers)? | ✅ | ~ |
Recommendation matrix :
If arguing FOR TCA :
"I understand TCA feels heavy. But we're building a banking app. The TestStore gives us exhaustive testing that catches bugs before production. The 2-week learning curve is worth it for 2 years of maintenance."
If arguing AGAINST TCA :
"I agree TCA is powerful, but we're prototyping features weekly. The boilerplate will slow us down. Let's use @Observable now and migrate to TCA if we prove the features are worth building."
PM : "We have 3 features to ship this month. We can't spend 2 weeks refactoring existing views."
You don't have to refactor everything at once:
Week 1 : Extract 1 view
Week 2 : Extract 2 views
Week 3 : New features use proper architecture
Month 2 : Gradually refactor as you touch files
"I'm not proposing we stop feature work for 2 weeks. I'm proposing:
- Week 1: Extract our worst view (the OrdersView with 500 lines)
- Week 2: Extract 2 more problematic views
- Going forward: All NEW features use proper architecture
- We refactor old views when we touch them anyway
This costs 10 hours upfront and saves us 2+ hours per feature going forward."
// 😰 200 lines of pain
struct OrderListView: View {
@State private var orders: [Order] = []
@State private var searchText = ""
@State private var selectedFilter: FilterType = .all
var body: some View {
// ❌ Formatters created every render
let currencyFormatter = NumberFormatter()
currencyFormatter.numberStyle = .currency
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
// ❌ Business logic in view
let filtered = orders.filter { order in
if !searchText.isEmpty && !order.customerName.contains(searchText) {
return false
}
switch selectedFilter {
case .all: return true
case .pending: return !order.isCompleted
case .completed: return order.isCompleted
case .highValue: return order.total > 1000
}
}
// ❌ More business logic
let sorted = filtered.sorted { lhs, rhs in
if selectedFilter == .highValue {
return lhs.total > rhs.total
} else {
return lhs.date > rhs.date
}
}
return List(sorted) { order in
VStack(alignment: .leading) {
Text(order.customerName)
Text(currencyFormatter.string(from: order.total as NSNumber)!)
Text(dateFormatter.string(from: order.date))
if order.isCompleted {
Image(systemName: "checkmark.circle.fill")
} else {
Button("Complete") {
// ❌ Async logic in view
Task {
do {
try await completeOrder(order)
await loadOrders()
} catch {
print(error) // ❌ No error handling
}
}
}
}
}
}
.searchable(text: $searchText)
.task {
await loadOrders()
}
}
func loadOrders() async {
// ❌ API call in view
// ... 50 more lines
}
func completeOrder(_ order: Order) async throws {
// ❌ API call in view
// ... 30 more lines
}
}
Problems :
// Model — 30 lines
struct Order {
let id: UUID
let customerName: String
let total: Decimal
let date: Date
var isCompleted: Bool
var isHighValue: Bool {
total > 1000
}
}
// ViewModel — 60 lines
@Observable
class OrderListViewModel {
private let orderService: OrderService
private let currencyFormatter = NumberFormatter()
private let dateFormatter = DateFormatter()
var orders: [Order] = []
var searchText = ""
var selectedFilter: FilterType = .all
var error: Error?
var filteredOrders: [Order] {
orders
.filter(matchesSearch)
.filter(matchesFilter)
.sorted(by: sortComparator)
}
init(orderService: OrderService) {
self.orderService = orderService
currencyFormatter.numberStyle = .currency
dateFormatter.dateStyle = .medium
}
func loadOrders() async {
do {
orders = try await orderService.fetchOrders()
} catch {
self.error = error
}
}
func completeOrder(_ order: Order) async {
do {
try await orderService.complete(order.id)
await loadOrders()
} catch {
self.error = error
}
}
func formattedTotal(_ order: Order) -> String {
currencyFormatter.string(from: order.total as NSNumber) ?? "$0.00"
}
func formattedDate(_ order: Order) -> String {
dateFormatter.string(from: order.date)
}
private func matchesSearch(_ order: Order) -> Bool {
searchText.isEmpty || order.customerName.contains(searchText)
}
private func matchesFilter(_ order: Order) -> Bool {
switch selectedFilter {
case .all: true
case .pending: !order.isCompleted
case .completed: order.isCompleted
case .highValue: order.isHighValue
}
}
private func sortComparator(_ lhs: Order, _ rhs: Order) -> Bool {
selectedFilter == .highValue
? lhs.total > rhs.total
: lhs.date > rhs.date
}
}
// View — 40 lines
struct OrderListView: View {
@Bindable var viewModel: OrderListViewModel
var body: some View {
List(viewModel.filteredOrders) { order in
OrderRow(order: order, viewModel: viewModel)
}
.searchable(text: $viewModel.searchText)
.task {
await viewModel.loadOrders()
}
.alert("Error", error: $viewModel.error) { }
}
}
struct OrderRow: View {
let order: Order
let viewModel: OrderListViewModel
var body: some View {
VStack(alignment: .leading) {
Text(order.customerName)
Text(viewModel.formattedTotal(order))
Text(viewModel.formattedDate(order))
if order.isCompleted {
Image(systemName: "checkmark.circle.fill")
} else {
Button("Complete") {
Task {
await viewModel.completeOrder(order)
}
}
}
}
}
}
// Tests — 100 lines
final class OrderViewModelTests: XCTestCase {
func testFilterBySearch() async {
let viewModel = OrderListViewModel(orderService: MockOrderService())
await viewModel.loadOrders()
viewModel.searchText = "John"
XCTAssertEqual(viewModel.filteredOrders.count, 1)
}
func testFilterByHighValue() async {
let viewModel = OrderListViewModel(orderService: MockOrderService())
await viewModel.loadOrders()
viewModel.selectedFilter = .highValue
XCTAssertTrue(viewModel.filteredOrders.allSatisfy { $0.isHighValue })
}
// ... 10 more tests
}
Benefits :
WWDC : 2025-266, 2024-10150, 2023-10149, 2023-10160
Docs : /swiftui/managing-model-data-in-your-app
External : github.com/pointfreeco/swift-composable-architecture
Platforms : iOS 26+, iPadOS 26+, macOS Tahoe+, watchOS 26+, axiom-visionOS 26+ Xcode : 26+ Status : Production-ready (v1.0)
Weekly Installs
172
Repository
GitHub Stars
601
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode151
codex145
gemini-cli139
cursor133
claude-code133
github-copilot132
Apple Reminders CLI (remindctl) - 终端管理苹果提醒事项,同步iPhone/iPad
804 周安装