一键导入
swiftui-optimistic-ui-pattern
MANDATORY for any like, bookmark, follow, favorite, or similar toggle. Use before writing a mutation method on a ViewModel.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
MANDATORY for any like, bookmark, follow, favorite, or similar toggle. Use before writing a mutation method on a ViewModel.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
MANDATORY for API client and Services work. Use before writing APIClient, any file under Services/, or any networking code.
MANDATORY when adding or updating icons in AppIcon.appiconset/. Enforces 1024x1024 sizing and the per-variant alpha rules (primary opaque, dark transparent, tinted opaque grayscale).
MANDATORY for auth token storage. Use before writing any code that stores, reads, or deletes authentication credentials.
MANDATORY when the user asks to add or scaffold a new feature or screen. Chains Model + ViewModel + View + APIClient extension + test stub in one pass.
MANDATORY for Info.plist work. Use before adding imports that require user permission (PhotosUI, AVFoundation camera, CoreLocation, ATT, HealthKit, Contacts, EventKit).
MANDATORY for iOS project initialization and project.yml work. Use before creating or editing project.yml, Package.swift, or the top-level directory layout.
| name | swiftui-optimistic-ui-pattern |
| description | MANDATORY for any like, bookmark, follow, favorite, or similar toggle. Use before writing a mutation method on a ViewModel. |
Task { }. Never await the API call before touching the UI — that's perceptible lag.Task { _ = try? await APIClient.shared.post(...) as EmptyResponse }. The return value is discarded because the UI already reflects the desired state.max(0, ...). Like count, comment count, follower count — all must be floored at zero. A race or stale state can otherwise produce -1 likes.await happens before the mutation.@MainActor
@Observable
final class FeedViewModel {
var posts: [Post] = []
func toggleLike(_ post: Post) {
guard let index = posts.firstIndex(where: { $0.id == post.id }) else { return }
let wasLiked = posts[index].likedByCurrentUser
posts[index].likedByCurrentUser.toggle()
posts[index].likeCount = max(0, wasLiked
? posts[index].likeCount - 1
: posts[index].likeCount + 1)
Task {
if wasLiked {
_ = try? await APIClient.shared.delete(path: "/posts/\(post.id)/like") as EmptyResponse
} else {
_ = try? await APIClient.shared.post(path: "/posts/\(post.id)/like") as EmptyResponse
}
}
}
}
What happens on tap:
likedByCurrentUser and adjust likeCount immediately.swiftui-equatable-hashable-for-diffing), and re-renders the card. This all happens within a frame.posts[index].likeCount = max(0, wasLiked ? ... : ...)
Without the floor: a stale post with likeCount: 0 that the user already "liked" elsewhere gets a second unlike tap → count goes to -1 → the view shows "-1 likes" which looks like a bug report waiting to happen.
func toggleBookmark(_ post: Post) {
guard let idx = posts.firstIndex(where: { $0.id == post.id }) else { return }
posts[idx].isBookmarked.toggle()
Task {
_ = try? await APIClient.shared.post(path: "/posts/\(post.id)/bookmark") as EmptyResponse
}
}
func toggleFollow(_ user: User) {
guard let idx = users.firstIndex(where: { $0.id == user.id }) else { return }
users[idx].isFollowedByCurrentUser.toggle()
Task {
_ = try? await APIClient.shared.post(path: "/users/\(user.id)/follow") as EmptyResponse
}
}
The View just wires the tap:
Button {
viewModel.toggleLike(post)
} label: {
Image(systemName: post.likedByCurrentUser ? "heart.fill" : "heart")
}
.buttonStyle(.borderless) // critical if inside a NavigationLink — see swiftui-navigation-foundations
No Task in the View. No await. The ViewModel owns all of that.
// ❌
func toggleLike(_ post: Post) async {
_ = try? await APIClient.shared.post(...) as EmptyResponse
// ... then mutate
}
The user sees 200-800ms of no feedback after the tap. Even on a good network. On 3G it's a full second. Reverse the order.
max(0, ...)Produces negative counts on race conditions. Almost inevitable at scale.
// ❌ At MVP scale — wastes a day getting right
Task {
do {
_ = try await APIClient.shared.post(...) as EmptyResponse
} catch {
// revert — but what about intermediate states? races? UI loops?
self.posts[index].likedByCurrentUser = wasLiked
}
}
Do this in 1.1 or later, not 1.0. Write a comment: // MVP: do not revert on failure — reconcile on next fetch.
Post has custom id-only ==Doesn't work. See swiftui-equatable-hashable-for-diffing. The fix is in the model type, not in the mutation code.
post snapshot instead of posts[index]// ❌ mutates a local copy, not the array
var mutated = post
mutated.likedByCurrentUser.toggle()
post is a let parameter; the mutation has no effect on the published array. Always go via posts[index].
Usually acceptable for MVP — the server accepts both. If it becomes a problem, throttle in the ViewModel:
if isLikePending[post.id] == true { return }
isLikePending[post.id] = true
defer { isLikePending[post.id] = false }
No dedicated template — this is a ViewModel pattern. ios-feature-scaffold drops the canonical toggle skeleton into generated ViewModels.
swiftui-observable-viewmodel-boilerplate — shape of the ViewModel.swiftui-equatable-hashable-for-diffing — why structural equality on the model is required for re-renders.ios-api-client-foundation — the APIClient.shared.post/delete calls.