
Swiftui Patterns
- 244 installs
- 222 repo stars
- Updated January 18, 2026
- johnrogers/claude-swift-engineering
Apply idiomatic SwiftUI architecture, state management, navigation, and reusable component patterns for polished Apple app interfaces.
About
Teaches idiomatic SwiftUI design patterns for building maintainable Apple app interfaces, including state ownership, view composition, navigation stacks, modifiers, accessibility, and scalable UI architecture.
- SwiftUI architecture
- State management
- Navigation patterns
- Reusable components
- Accessibility
Swiftui Patterns by the numbers
- 244 all-time installs (skills.sh)
- +3 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #881 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/johnrogers/claude-swift-engineering --skill swiftui-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 244 |
|---|---|
| repo stars | ★ 222 |
| Last updated | January 18, 2026 |
| Repository | johnrogers/claude-swift-engineering ↗ |
What it does
Apply idiomatic SwiftUI architecture, state management, navigation, and reusable component patterns for polished Apple app interfaces.
Files
SwiftUI Patterns (iOS 17+)
SwiftUI 17+ removes ObservableObject boilerplate with @Observable, simplifies environment injection with @Environment, and introduces task-based async patterns. The core principle: use Apple's modern APIs instead of reactive libraries.
Overview
Quick Reference
| Need | Use (iOS 17+) | NOT |
|---|---|---|
| Observable model | @Observable | ObservableObject |
| Published property | Regular property | @Published |
| Own state | @State | @StateObject |
| Passed model (binding) | @Bindable | @ObservedObject |
| Environment injection | environment(_:) | environmentObject(_:) |
| Environment access | @Environment(Type.self) | @EnvironmentObject |
| Async on appear | .task { } | .onAppear { Task {} } |
| Value change | onChange(of:initial:_:) | onChange(of:perform:) |
Core Workflow
1. Use @Observable for model classes (no @Published needed) 2. Use @State for view-owned models, @Bindable for passed models 3. Use .task { } for async work (auto-cancels on disappear) 4. Use NavigationStack with NavigationPath for programmatic navigation 5. Apply .accessibilityLabel() and .accessibilityHint() to interactive elements
Reference Loading Guide
ALWAYS load reference files if there is even a small chance the content may be required. It's better to have the context than to miss a pattern or make a mistake.
| Reference | Load When |
|---|---|
| [Observable](references/observable.md) | Creating new @Observable model classes |
| [State Management](references/state-management.md) | Deciding between @State, @Bindable, @Environment |
| [Environment](references/environment.md) | Injecting dependencies into view hierarchy |
| [View Modifiers](references/view-modifiers.md) | Using onChange, task, or iOS 17+ modifiers |
| [Migration Guide](references/migration-guide.md) | Updating iOS 16 code to iOS 17+ |
| [MVVM Observable](references/mvvm-observable.md) | Setting up view model architecture |
| [Navigation](references/navigation.md) | Programmatic or deep-link navigation |
| [Performance](references/performance.md) | Lists with 100+ items or excessive re-renders |
| [UIKit Interop](references/uikit-interop.md) | Wrapping UIKit components (WKWebView, PHPicker) |
| [Accessibility](references/accessibility.md) | VoiceOver, Dynamic Type, accessibility actions |
| [Async Patterns](references/async-patterns.md) | Loading states, refresh, background tasks |
| [Composition](references/composition.md) | Reusable view modifiers or complex conditional UI |
Common Mistakes
1. Over-using `@Bindable` for passed models — Creating @Bindable for every property causes unnecessary view reloads. Use @Bindable only for mutable model properties that need two-way binding. Read-only computed properties should use regular properties.
2. State placement errors — Putting model state in the view instead of a dedicated @Observable model causes view logic to become tangled. Always separate model and view concerns.
3. NavigationPath state corruption — Mutating NavigationPath incorrectly can leave it in inconsistent state. Use navigationDestination(for:destination:) with proper state management to avoid path corruption.
4. Missing `.task` cancellation — .task handles cancellation on disappear automatically, but nested Tasks don't. Complex async flows need explicit cancellation tracking to avoid zombie tasks.
5. Ignoring environment invalidation — Changing environment values at parent doesn't invalidate child views automatically. Use @Environment consistently and understand when re-renders happen based on observation.
6. UIKit interop memory leaks — UIViewRepresentable and UIViewControllerRepresentable can leak if delegate cycles aren't broken. Weak references and explicit cleanup are required.
Accessibility Patterns
VoiceOver Support
Use for: Screen reader accessibility
struct ArticleRow: View {
let article: Article
var body: some View {
HStack {
AsyncImage(url: article.imageURL) { image in
image.resizable()
} placeholder: {
ProgressView()
}
.frame(width: 80, height: 80)
.accessibilityHidden(true) // Decorative image
VStack(alignment: .leading) {
Text(article.title)
.font(.headline)
Text(article.author)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(article.title), by \(article.author)")
.accessibilityHint("Double tap to read article")
}
}Dynamic Type Support
Use for: Text that scales with user preferences
struct ArticleContent: View {
let article: Article
@ScaledMetric private var imageHeight: CGFloat = 200
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
AsyncImage(url: article.imageURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
ProgressView()
}
.frame(height: imageHeight) // Scales with Dynamic Type
.clipped()
Text(article.title)
.font(.title)
Text(article.content)
.font(.body)
}
}
}
}Accessibility Actions
Use for: Custom VoiceOver actions
struct ArticleCard: View {
let article: Article
@State private var isSaved = false
@State private var isShared = false
var body: some View {
VStack {
Text(article.title)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(article.title)
.accessibilityAction(named: "Save") {
isSaved.toggle()
}
.accessibilityAction(named: "Share") {
isShared = true
}
}
}Async Operation Patterns
Task Modifier
Use for: Loading data when view appears
struct ArticleDetailView: View {
let articleId: String
@State private var article: Article?
@State private var isLoading = true
var body: some View {
Group {
if let article {
ArticleContent(article: article)
} else if isLoading {
ProgressView()
} else {
ContentUnavailableView("Article Not Found", systemImage: "doc.text")
}
}
.task {
await loadArticle()
}
}
private func loadArticle() async {
isLoading = true
defer { isLoading = false }
do {
article = try await articleService.fetchArticle(id: articleId)
} catch {
print("Error loading article: \(error)")
}
}
}Refreshable Content
Use for: Pull-to-refresh lists
struct ArticleListView: View {
@State private var articles: [Article] = []
var body: some View {
List(articles) { article in
ArticleRow(article: article)
}
.refreshable {
await refreshArticles()
}
}
private func refreshArticles() async {
do {
articles = try await articleService.fetchArticles()
} catch {
print("Error refreshing: \(error)")
}
}
}Background Tasks
Use for: Non-blocking async operations
struct ArticleDetailView: View {
let article: Article
@State private var isSaved = false
var body: some View {
ArticleContent(article: article)
.toolbar {
Button(isSaved ? "Saved" : "Save") {
Task {
await saveArticle()
}
}
}
}
private func saveArticle() async {
do {
try await articleService.saveArticle(article)
isSaved = true
} catch {
print("Error saving: \(error)")
}
}
}View Composition Patterns
ViewBuilder for Conditional Content
Use for: Complex conditional UI
struct ArticleCard: View {
let article: Article
let style: CardStyle
var body: some View {
VStack(alignment: .leading, spacing: 8) {
headerView
contentView
footerView
}
.padding()
.background(backgroundView)
}
@ViewBuilder
private var headerView: some View {
if let imageURL = article.imageURL {
AsyncImage(url: imageURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
ProgressView()
}
.frame(height: 200)
.clipped()
}
}
@ViewBuilder
private var contentView: some View {
Text(article.title)
.font(.headline)
if style == .detailed {
Text(article.summary)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(3)
}
}
@ViewBuilder
private var footerView: some View {
HStack {
Text(article.author)
.font(.caption)
Spacer()
Text(article.publishedAt, style: .relative)
.font(.caption)
.foregroundColor(.secondary)
}
}
@ViewBuilder
private var backgroundView: some View {
RoundedRectangle(cornerRadius: 12)
.fill(.background)
.shadow(radius: 2)
}
}Custom View Modifiers
Use for: Reusable styling
struct CardStyle: ViewModifier {
let cornerRadius: CGFloat
let shadowRadius: CGFloat
func body(content: Content) -> some View {
content
.padding()
.background(
RoundedRectangle(cornerRadius: cornerRadius)
.fill(.background)
.shadow(radius: shadowRadius)
)
}
}
extension View {
func cardStyle(cornerRadius: CGFloat = 12, shadowRadius: CGFloat = 2) -> some View {
modifier(CardStyle(cornerRadius: cornerRadius, shadowRadius: shadowRadius))
}
}
// Usage
struct ArticleRow: View {
let article: Article
var body: some View {
VStack {
Text(article.title)
}
.cardStyle()
}
}Environment (iOS 17+)
environment(_:) — NOT environmentObject(_:)
✅ Modern Pattern
@Observable
class AppSettings {
var isDarkMode: Bool = false
}
// Inject into environment
struct MyApp: App {
@State private var settings = AppSettings()
var body: some Scene {
WindowGroup {
ContentView()
.environment(settings)
}
}
}
// Access in child view
struct SettingsView: View {
@Environment(AppSettings.self) private var settings
var body: some View {
Toggle("Dark Mode", isOn: $settings.isDarkMode)
}
}❌ Deprecated Pattern
// NEVER use .environmentObject(_:) with @Observable
.environmentObject(settings)
// NEVER use @EnvironmentObject with @Observable
@EnvironmentObject var settings: AppSettingsMigration Checklist
When updating legacy SwiftUI code to iOS 17+:
- [ ] Replace
ObservableObjectwith@Observable - [ ] Remove all
@Published(regular properties auto-publish) - [ ] Replace
@StateObjectwith@State - [ ] Replace
@ObservedObjectwith@Bindable - [ ] Replace
environmentObject(_:)withenvironment(_:) - [ ] Replace
@EnvironmentObjectwith@Environment(Type.self) - [ ] Update
onChange(of:perform:)toonChange(of:initial:_:) - [ ] Replace
.onAppear { Task {} }with.task
Before & After Example
Before (iOS 16)
class UserProfileModel: ObservableObject {
@Published var name: String = ""
@Published var email: String = ""
}
struct ProfileView: View {
@StateObject private var model = UserProfileModel()
var body: some View {
TextField("Name", text: $model.name)
.onAppear {
Task {
await model.load()
}
}
}
}After (iOS 17+)
@Observable
class UserProfileModel {
var name: String = ""
var email: String = ""
}
struct ProfileView: View {
@State private var model = UserProfileModel()
var body: some View {
TextField("Name", text: $model.name)
.task {
await model.load()
}
}
}MVVM with @Observable (iOS 17+)
Use for: View models with reactive state
Problem: Need reactive view models without @Published boilerplate.
Solution:
import Observation
@Observable
@MainActor
final class ArticleListViewModel {
var articles: [Article] = []
var isLoading = false
var errorMessage: String?
private let articleService: ArticleService
init(articleService: ArticleService) {
self.articleService = articleService
}
func loadArticles() async {
isLoading = true
errorMessage = nil
do {
articles = try await articleService.fetchArticles()
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
struct ArticleListView: View {
@State private var viewModel: ArticleListViewModel
init(articleService: ArticleService) {
_viewModel = State(wrappedValue: ArticleListViewModel(articleService: articleService))
}
var body: some View {
List(viewModel.articles) { article in
ArticleRow(article: article)
}
.overlay {
if viewModel.isLoading {
ProgressView()
}
}
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK") { viewModel.errorMessage = nil }
} message: {
if let message = viewModel.errorMessage {
Text(message)
}
}
.task {
await viewModel.loadArticles()
}
}
}Benefits:
- No
@Publishedneeded - Fine-grained observation (only tracks accessed properties)
- Better performance than ObservableObject
- Less boilerplate
NavigationStack Patterns (iOS 16+)
Use for: Type-safe programmatic navigation
Problem: Need programmatic navigation with deep linking support.
Solution:
// Navigation coordinator
@Observable
@MainActor
final class NavigationCoordinator {
var path = NavigationPath()
func navigateTo(_ article: Article) {
path.append(article)
}
func navigateToAuthor(_ author: Author) {
path.append(author)
}
func navigateToRoot() {
path.removeLast(path.count)
}
func pop() {
if !path.isEmpty {
path.removeLast()
}
}
}
// App navigation
struct AppNavigationView: View {
@State private var coordinator = NavigationCoordinator()
var body: some View {
NavigationStack(path: $coordinator.path) {
ArticleListView()
.navigationDestination(for: Article.self) { article in
ArticleDetailView(article: article)
}
.navigationDestination(for: Author.self) { author in
AuthorProfileView(author: author)
}
.environment(coordinator)
}
}
}
// Usage in views
struct ArticleListView: View {
@Environment(NavigationCoordinator.self) private var coordinator
var body: some View {
List(articles) { article in
Button {
coordinator.navigateTo(article)
} label: {
ArticleRow(article: article)
}
}
}
}Benefits:
- Type-safe navigation
- Programmatic control
- Deep linking ready
- Centralized navigation logic
@Observable — NOT ObservableObject
iOS 17+ Pattern
✅ Modern Pattern
import Observation
@Observable
class UserProfileModel {
var name: String = ""
var email: String = ""
var isLoading: Bool = false
func save() async {
isLoading = true
// Save logic
isLoading = false
}
}
// In SwiftUI view
struct ProfileView: View {
let model: UserProfileModel
var body: some View {
TextField("Name", text: $model.name)
}
}❌ Deprecated Pattern
// NEVER use ObservableObject for new code
class UserProfileModel: ObservableObject {
@Published var name: String = ""
@Published var email: String = ""
}Why @Observable?
Benefits:
- Less boilerplate — no
@Publishedneeded - Better performance — fine-grained observation (only tracks accessed properties)
- Type-safe environment —
@Environment(Type.self)instead of@EnvironmentObject - Simpler bindings —
@Bindableinstead of@ObservedObject
Requirement: iOS 17.0+ / macOS 14.0+
Performance Optimization Patterns
Lazy Loading
Use for: Long scrollable lists
// Bad: Loads all items immediately
ScrollView {
VStack {
ForEach(articles) { article in
ArticleCard(article: article)
}
}
}
// Good: Loads items on-demand
ScrollView {
LazyVStack(spacing: 16) {
ForEach(articles) { article in
ArticleCard(article: article)
.onAppear {
// Pagination trigger
if article == articles.last {
loadMoreArticles()
}
}
}
}
}View Identity
Use for: Efficient list rendering
// Ensure all items are Identifiable
struct Article: Identifiable {
let id: String
let title: String
}
// SwiftUI can efficiently diff changes
ForEach(articles) { article in
ArticleRow(article: article)
}
// Or provide manual ID
ForEach(articles, id: \.id) { article in
ArticleRow(article: article)
}Equatable Views
Use for: Skipping unnecessary re-renders
struct ArticleRow: View, Equatable {
let article: Article
static func == (lhs: ArticleRow, rhs: ArticleRow) -> Bool {
lhs.article.id == rhs.article.id
}
var body: some View {
HStack {
Text(article.title)
Spacer()
Text(article.author)
.foregroundColor(.secondary)
}
}
}
// Usage: SwiftUI skips re-rendering if article ID unchanged
ForEach(articles) { article in
ArticleRow(article: article)
.equatable()
}Debounced Search
Use for: Search fields with live filtering
@Observable
@MainActor
final class SearchViewModel {
var searchText = ""
var results: [Article] = []
private var searchTask: Task<Void, Never>?
func updateSearch(_ text: String) {
searchText = text
searchTask?.cancel()
searchTask = Task {
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
results = await performSearch(text)
}
}
private func performSearch(_ query: String) async -> [Article] {
// Search logic
[]
}
}
struct SearchView: View {
@State private var viewModel = SearchViewModel()
var body: some View {
VStack {
TextField("Search", text: $viewModel.searchText)
.onChange(of: viewModel.searchText) { oldValue, newValue in
viewModel.updateSearch(newValue)
}
List(viewModel.results) { article in
ArticleRow(article: article)
}
}
}
}State Management (iOS 17+)
@State — NOT @StateObject
✅ Modern Pattern
struct ProfileView: View {
@State private var model = UserProfileModel()
var body: some View {
TextField("Name", text: $model.name)
}
}❌ Deprecated Pattern
// NEVER use @StateObject with @Observable
@StateObject private var model = UserProfileModel()@Bindable — NOT @ObservedObject
✅ Modern Pattern
struct ProfileEditView: View {
@Bindable var model: UserProfileModel
var body: some View {
Form {
TextField("Name", text: $model.name)
TextField("Email", text: $model.email)
}
}
}
// Usage
struct ProfileView: View {
@State private var model = UserProfileModel()
var body: some View {
ProfileEditView(model: model)
}
}❌ Deprecated Pattern
// NEVER use @ObservedObject with @Observable
@ObservedObject var model: UserProfileModelCommon Patterns
Navigation with Observable
@Observable
class NavigationModel {
var path = NavigationPath()
var selectedItem: Item?
func navigateTo(_ item: Item) {
selectedItem = item
}
}
struct ContentView: View {
@State private var navigation = NavigationModel()
var body: some View {
NavigationStack(path: $navigation.path) {
ItemList()
.environment(navigation)
}
}
}Form with Validation
@Observable
class FormModel {
var email: String = ""
var isValid: Bool { email.contains("@") }
}
struct FormView: View {
@State private var model = FormModel()
var body: some View {
Form {
TextField("Email", text: $model.email)
Button("Submit") { }
.disabled(!model.isValid)
}
}
}Loading State
struct DataView: View {
@State private var data: [Item] = []
@State private var isLoading = false
@State private var error: Error?
var body: some View {
List(data) { item in
Text(item.name)
}
.overlay {
if isLoading {
ProgressView()
}
}
.task {
isLoading = true
defer { isLoading = false }
do {
data = try await fetchData()
} catch {
self.error = error
}
}
}
}UIKit Interoperability
UIViewRepresentable
Use for: Wrapping UIKit views in SwiftUI
struct WebView: UIViewRepresentable {
let url: URL
@Binding var isLoading: Bool
func makeUIView(context: Context) -> WKWebView {
let webView = WKWebView()
webView.navigationDelegate = context.coordinator
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
let request = URLRequest(url: url)
webView.load(request)
}
func makeCoordinator() -> Coordinator {
Coordinator(isLoading: $isLoading)
}
class Coordinator: NSObject, WKNavigationDelegate {
@Binding var isLoading: Bool
init(isLoading: Binding<Bool>) {
_isLoading = isLoading
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
isLoading = true
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
isLoading = false
}
}
}
// Usage
struct ArticleWebView: View {
let url: URL
@State private var isLoading = false
var body: some View {
WebView(url: url, isLoading: $isLoading)
.overlay {
if isLoading {
ProgressView()
}
}
}
}UIViewControllerRepresentable
Use for: Presenting UIKit view controllers
struct ImagePicker: UIViewControllerRepresentable {
@Binding var image: UIImage?
@Environment(\.dismiss) private var dismiss
func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration()
config.filter = .images
config.selectionLimit = 1
let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {
// No updates needed
}
func makeCoordinator() -> Coordinator {
Coordinator(image: $image, dismiss: dismiss)
}
class Coordinator: NSObject, PHPickerViewControllerDelegate {
@Binding var image: UIImage?
let dismiss: DismissAction
init(image: Binding<UIImage?>, dismiss: DismissAction) {
_image = image
self.dismiss = dismiss
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
dismiss()
guard let provider = results.first?.itemProvider else { return }
if provider.canLoadObject(ofClass: UIImage.self) {
provider.loadObject(ofClass: UIImage.self) { image, _ in
DispatchQueue.main.async {
self.image = image as? UIImage
}
}
}
}
}
}
// Usage
struct ProfileEditView: View {
@State private var profileImage: UIImage?
@State private var showImagePicker = false
var body: some View {
VStack {
if let image = profileImage {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 200, height: 200)
.clipShape(Circle())
}
Button("Choose Photo") {
showImagePicker = true
}
}
.sheet(isPresented: $showImagePicker) {
ImagePicker(image: $profileImage)
}
}
}Modern View Modifiers (iOS 17+)
onChange(of:initial:_:) — New Signature
✅ Modern Pattern
struct SearchView: View {
@State private var searchText = ""
var body: some View {
TextField("Search", text: $searchText)
.onChange(of: searchText) { oldValue, newValue in
performSearch(query: newValue)
}
// Run on appear with initial: true
.onChange(of: searchText, initial: true) { oldValue, newValue in
validateInput(newValue)
}
}
}❌ Deprecated Pattern
// DEPRECATED: onChange(of:perform:)
.onChange(of: searchText) { newValue in
performSearch(query: newValue)
}task(priority:_:) — Async Work
✅ Modern Pattern
struct UserListView: View {
@State private var users: [User] = []
@State private var isLoading = false
var body: some View {
List(users) { user in
UserRow(user: user)
}
.task {
await loadUsers()
}
.task(id: selectedFilter) {
// Cancelled and restarted when selectedFilter changes
await loadUsers(filter: selectedFilter)
}
}
func loadUsers() async {
isLoading = true
users = try? await fetchUsers()
isLoading = false
}
}❌ Deprecated Pattern
// NEVER use .onAppear with Task
.onAppear {
Task {
await loadUsers()
}
}