swiftui-patterns
Use when implementing iOS 17+ SwiftUI patterns: @Observable/@Bindable, MVVM architecture, NavigationStack, lazy loading, UIKit interop, accessibility (VoiceOver/Dynamic Type), async operations (.task/.refreshable), or migrating from ObservableObject/@StateObject.
Packaged view
This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.
Install command
npx @skill-hub/cli install johnrogers-claude-swift-engineering-swiftui-patterns
Repository
Skill path: plugins/swift-engineering/skills/swiftui-patterns
Use when implementing iOS 17+ SwiftUI patterns: @Observable/@Bindable, MVVM architecture, NavigationStack, lazy loading, UIKit interop, accessibility (VoiceOver/Dynamic Type), async operations (.task/.refreshable), or migrating from ObservableObject/@StateObject.
Open repositoryBest for
Primary workflow: Build Mobile.
Technical facets: Full Stack, Mobile.
Target audience: everyone.
License: Unknown.
Original source
Catalog source: SkillHub Club.
Repository owner: johnrogers.
This is still a mirrored public skill entry. Review the repository before installing into production workflows.
What it helps with
- Install swiftui-patterns into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
- Review https://github.com/johnrogers/claude-swift-engineering before adding swiftui-patterns to shared team environments
- Use swiftui-patterns for development workflows
Works across
Favorites: 0.
Sub-skills: 0.
Aggregator: No.
Original source / Raw SKILL.md
---
name: swiftui-patterns
description: >-
Use when implementing iOS 17+ SwiftUI patterns: @Observable/@Bindable, MVVM architecture, NavigationStack, lazy loading, UIKit interop, accessibility (VoiceOver/Dynamic Type), async operations (.task/.refreshable), or migrating from ObservableObject/@StateObject.
---
# 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.
---
## Referenced Files
> The following files are referenced in this skill and included for context.
### references/observable.md
```markdown
# @Observable — NOT ObservableObject
**iOS 17+ Pattern**
## ✅ Modern Pattern
```swift
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
```swift
// NEVER use ObservableObject for new code
class UserProfileModel: ObservableObject {
@Published var name: String = ""
@Published var email: String = ""
}
```
## Why @Observable?
**Benefits:**
- **Less boilerplate** — no `@Published` needed
- **Better performance** — fine-grained observation (only tracks accessed properties)
- **Type-safe environment** — `@Environment(Type.self)` instead of `@EnvironmentObject`
- **Simpler bindings** — `@Bindable` instead of `@ObservedObject`
**Requirement:** iOS 17.0+ / macOS 14.0+
```
### references/state-management.md
```markdown
# State Management (iOS 17+)
## @State — NOT @StateObject
### ✅ Modern Pattern
```swift
struct ProfileView: View {
@State private var model = UserProfileModel()
var body: some View {
TextField("Name", text: $model.name)
}
}
```
### ❌ Deprecated Pattern
```swift
// NEVER use @StateObject with @Observable
@StateObject private var model = UserProfileModel()
```
## @Bindable — NOT @ObservedObject
### ✅ Modern Pattern
```swift
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
```swift
// NEVER use @ObservedObject with @Observable
@ObservedObject var model: UserProfileModel
```
## Common Patterns
### Navigation with Observable
```swift
@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
```swift
@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
```swift
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
}
}
}
}
```
```
### references/environment.md
```markdown
# Environment (iOS 17+)
## environment(_:) — NOT environmentObject(_:)
### ✅ Modern Pattern
```swift
@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
```swift
// NEVER use .environmentObject(_:) with @Observable
.environmentObject(settings)
// NEVER use @EnvironmentObject with @Observable
@EnvironmentObject var settings: AppSettings
```
```
### references/view-modifiers.md
```markdown
# Modern View Modifiers (iOS 17+)
## onChange(of:initial:_:) — New Signature
### ✅ Modern Pattern
```swift
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
```swift
// DEPRECATED: onChange(of:perform:)
.onChange(of: searchText) { newValue in
performSearch(query: newValue)
}
```
## task(priority:_:) — Async Work
### ✅ Modern Pattern
```swift
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
```swift
// NEVER use .onAppear with Task
.onAppear {
Task {
await loadUsers()
}
}
```
```
### references/migration-guide.md
```markdown
# Migration Checklist
When updating legacy SwiftUI code to iOS 17+:
- [ ] Replace `ObservableObject` with `@Observable`
- [ ] Remove all `@Published` (regular properties auto-publish)
- [ ] Replace `@StateObject` with `@State`
- [ ] Replace `@ObservedObject` with `@Bindable`
- [ ] Replace `environmentObject(_:)` with `environment(_:)`
- [ ] Replace `@EnvironmentObject` with `@Environment(Type.self)`
- [ ] Update `onChange(of:perform:)` to `onChange(of:initial:_:)`
- [ ] Replace `.onAppear { Task {} }` with `.task`
## Before & After Example
### Before (iOS 16)
```swift
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+)
```swift
@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()
}
}
}
```
```
### references/mvvm-observable.md
```markdown
# MVVM with @Observable (iOS 17+)
**Use for:** View models with reactive state
**Problem:** Need reactive view models without @Published boilerplate.
**Solution:**
```swift
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 `@Published` needed
- Fine-grained observation (only tracks accessed properties)
- Better performance than ObservableObject
- Less boilerplate
```
### references/navigation.md
```markdown
# NavigationStack Patterns (iOS 16+)
**Use for:** Type-safe programmatic navigation
**Problem:** Need programmatic navigation with deep linking support.
**Solution:**
```swift
// 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
```
### references/performance.md
```markdown
# Performance Optimization Patterns
## Lazy Loading
**Use for:** Long scrollable lists
```swift
// 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
```swift
// 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
```swift
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
```swift
@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)
}
}
}
}
```
```
### references/uikit-interop.md
```markdown
# UIKit Interoperability
## UIViewRepresentable
**Use for:** Wrapping UIKit views in SwiftUI
```swift
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
```swift
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)
}
}
}
```
```
### references/accessibility.md
```markdown
# Accessibility Patterns
## VoiceOver Support
**Use for:** Screen reader accessibility
```swift
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
```swift
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
```swift
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
}
}
}
```
```
### references/async-patterns.md
```markdown
# Async Operation Patterns
## Task Modifier
**Use for:** Loading data when view appears
```swift
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
```swift
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
```swift
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)")
}
}
}
```
```
### references/composition.md
```markdown
# View Composition Patterns
## ViewBuilder for Conditional Content
**Use for:** Complex conditional UI
```swift
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
```swift
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()
}
}
```
```