Feedback States
MANDATORY: Every Async View Must Handle All 4 States
Every view displaying async data MUST use a switch on Loadable<T> covering ALL 4 states. This is not optional.
switch viewModel.items {
case .notInitiated, .loading:
ProgressView("Loading...")
case .success(let items) where items.isEmpty:
ContentUnavailableView("No Items Yet", systemImage: "tray",
description: Text("Tap + to create your first item"))
case .success(let items):
List(items) { item in ItemRow(item: item) }
case .failure(let error):
ContentUnavailableView {
Label("Load Failed", systemImage: "exclamationmark.triangle")
} description: {
Text(error.localizedDescription)
} actions: {
Button("Retry") { Task { await viewModel.loadItems() } }
}
}
Rules:
- NEVER use
if letto unwrap only the success case — all 4 states must be handled - Empty state MUST use
ContentUnavailableViewwith an action button - Error state MUST include a retry button
- Loading state MUST show
ProgressView
Upload / Mutation State Handling
Every mutation button (save, delete, upload, send) MUST:
- Disable the trigger while in-progress
- Show an inline spinner replacing the button label
- Provide success/failure feedback after completion
Button {
Task { await viewModel.save() }
} label: {
if viewModel.saveState.isLoading {
ProgressView()
.controlSize(.small)
} else {
Text("Save")
}
}
.disabled(viewModel.saveState.isLoading)
LOADING PATTERNS:
- Inline button spinner (action on single element):
Button {
Task { await save() }
} label: {
if saveState.isLoading {
ProgressView()
.controlSize(.small)
} else {
Text("Save")
}
}
.disabled(saveState.isLoading)
- Full-screen loading (initial data load using Loadable):
switch viewModel.items {
case .notInitiated, .loading:
ProgressView("Loading...")
case .success(let items):
ContentListView(items: items)
case .failure(let error):
ErrorView(error: error) {
Task { await viewModel.loadItems() }
}
}
- Skeleton loading (content placeholders):
ForEach(Item.sampleData) { item in
ItemRow(item: item)
}
.redacted(reason: .placeholder)
- Pull-to-refresh (list content):
List { ... }
.refreshable { await viewModel.refresh() }
- Overlay loading (blocking operation):
.overlay {
if operationState.isLoading {
ZStack {
Color.black.opacity(0.3)
ProgressView()
.controlSize(.large)
.tint(.white)
}
.ignoresSafeArea()
}
}
LOADING RULES:
- Show indicator for operations > 300ms.
- ALWAYS disable the triggering button while loading (prevents double-taps).
- Never block the entire UI for a partial operation — use inline spinner.
- Match loading style to scope: button-level → inline, screen-level → full-screen.
- Use
Loadable<T>for all async state — nevervar isLoading: Bool+var errorMessage: String?.
ERROR HANDLING UI:
- Inline validation (below form fields):
if let error = emailError {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.circle.fill")
Text(error)
}
.font(AppTheme.Fonts.caption)
.foregroundStyle(AppTheme.Colors.error)
}
- Alert for blocking errors (require user acknowledgment):
.alert("Error", isPresented: $showError) {
Button("Retry") { Task { await retry() } }
Button("Cancel", role: .cancel) { }
} message: {
Text(errorMessage)
}
- Banner for non-blocking errors (dismissible):
if let error = bannerError {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(AppTheme.Colors.warning)
Text(error)
.font(AppTheme.Fonts.subheadline)
Spacer()
Button("Dismiss") { bannerError = nil }
.font(AppTheme.Fonts.caption)
}
.padding(AppTheme.Spacing.small)
.background(.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.horizontal, AppTheme.Spacing.medium)
}
ERROR HANDLING RULES:
- Inline validation: show immediately as user types or on field blur.
- Alert: use for errors that block progress (network failure, permission denied).
- Banner: use for non-critical errors (sync failed, partial data).
- ALWAYS provide a retry path — never leave users stuck.
- Error messages: describe what happened + what the user can do.
SUCCESS FEEDBACK:
- Haptic: UINotificationFeedbackGenerator().notificationOccurred(.success).
- Visual: brief animation (checkmark, scale bounce, color flash).
- NEVER use modal alert for success — too disruptive.
- Subtle confirmation: toast, inline checkmark, or haptic alone.
// Brief success animation
withAnimation(.spring(response: 0.3)) {
showSuccess = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation { showSuccess = false }
}
DISABLED STATE:
- .disabled(condition) — SwiftUI auto-handles opacity reduction.
- Always explain WHY something is disabled (tooltip, caption text, or label).
- Example: "Fill in all required fields to continue" below a disabled button.
- Don't hide actions — show them disabled with explanation.
NETWORK/SYSTEM ERROR PATTERN (ViewModel with Loadable):
@MainActor @Observable
class ItemViewModel {
var items: Loadable<[Item]> = .notInitiated
func loadItems() async {
items = .loading
do {
items = .success(try await fetchItems())
} catch {
items = .failure(error)
}
}
var userFacingError: String? {
if case .failure = items {
return "Couldn't load items. Pull to refresh to try again."
}
return nil
}
}
EMPTY VS ERROR VS LOADING (switch on Loadable):
switch viewModel.items {
case .notInitiated, .loading:
ProgressView("Loading...")
case .success(let items) where items.isEmpty:
ContentUnavailableView("No Items Yet", systemImage: "tray",
description: Text("Tap + to create your first item"))
case .success(let items):
List(items) { item in ItemRow(item: item) }
case .failure(let error):
ContentUnavailableView {
Label("Load Failed", systemImage: "exclamationmark.triangle")
} description: {
Text(error.localizedDescription)
} actions: {
Button("Retry") { Task { await viewModel.loadItems() } }
}
}
- These are four distinct states — never conflate them.
Loadable<T>makes each state explicit and compiler-enforced viaswitch.
微信扫一扫