11 KiB
11 KiB
SwiftUI Patterns
Modern SwiftUI patterns for iOS 26 with iOS 18 compatibility.
View Composition
Small, Focused Views
// Bad: Massive view
struct ContentView: View {
var body: some View {
VStack {
// 200 lines of UI code
}
}
}
// Good: Composed from smaller views
struct ContentView: View {
var body: some View {
VStack {
HeaderView()
ItemList()
ActionBar()
}
}
}
struct HeaderView: View {
var body: some View {
// Focused implementation
}
}
Extract Subviews
struct ItemRow: View {
let item: Item
var body: some View {
HStack {
iconView
contentView
Spacer()
chevronView
}
}
private var iconView: some View {
Image(systemName: item.icon)
.foregroundStyle(.accent)
.frame(width: 30)
}
private var contentView: some View {
VStack(alignment: .leading) {
Text(item.name)
.font(.headline)
Text(item.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var chevronView: some View {
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
.font(.caption)
}
}
Async Data Loading
Task Modifier
struct ItemList: View {
@State private var items: [Item] = []
@State private var isLoading = true
@State private var error: Error?
var body: some View {
Group {
if isLoading {
ProgressView()
} else if let error {
ErrorView(error: error, retry: load)
} else {
List(items) { item in
ItemRow(item: item)
}
}
}
.task {
await load()
}
}
private func load() async {
isLoading = true
defer { isLoading = false }
do {
items = try await fetchItems()
} catch {
self.error = error
}
}
}
Refresh Control
struct ItemList: View {
@State private var items: [Item] = []
var body: some View {
List(items) { item in
ItemRow(item: item)
}
.refreshable {
items = try? await fetchItems()
}
}
}
Task with ID
Reload when identifier changes:
struct ItemDetail: View {
let itemID: UUID
@State private var item: Item?
var body: some View {
Group {
if let item {
ItemContent(item: item)
} else {
ProgressView()
}
}
.task(id: itemID) {
item = try? await fetchItem(id: itemID)
}
}
}
Lists and Grids
Swipe Actions
List {
ForEach(items) { item in
ItemRow(item: item)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
delete(item)
} label: {
Label("Delete", systemImage: "trash")
}
Button {
archive(item)
} label: {
Label("Archive", systemImage: "archivebox")
}
.tint(.orange)
}
.swipeActions(edge: .leading) {
Button {
toggleFavorite(item)
} label: {
Label("Favorite", systemImage: item.isFavorite ? "star.fill" : "star")
}
.tint(.yellow)
}
}
}
Lazy Grids
struct PhotoGrid: View {
let photos: [Photo]
let columns = [GridItem(.adaptive(minimum: 100), spacing: 2)]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 2) {
ForEach(photos) { photo in
AsyncImage(url: photo.thumbnailURL) { image in
image
.resizable()
.aspectRatio(1, contentMode: .fill)
} placeholder: {
Color.gray.opacity(0.3)
}
.clipped()
}
}
}
}
}
Sections with Headers
List {
ForEach(groupedItems, id: \.key) { section in
Section(section.key) {
ForEach(section.items) { item in
ItemRow(item: item)
}
}
}
}
.listStyle(.insetGrouped)
Forms and Input
Form with Validation
struct ProfileForm: View {
@State private var name = ""
@State private var email = ""
@State private var bio = ""
private var isValid: Bool {
!name.isEmpty && email.contains("@") && email.contains(".")
}
var body: some View {
Form {
Section("Personal Info") {
TextField("Name", text: $name)
.textContentType(.name)
TextField("Email", text: $email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
}
Section("About") {
TextField("Bio", text: $bio, axis: .vertical)
.lineLimit(3...6)
}
Section {
Button("Save") {
save()
}
.disabled(!isValid)
}
}
}
}
Pickers
struct SettingsView: View {
@State private var selectedTheme = Theme.system
@State private var fontSize = 16.0
var body: some View {
Form {
Picker("Theme", selection: $selectedTheme) {
ForEach(Theme.allCases) { theme in
Text(theme.rawValue).tag(theme)
}
}
Section("Text Size") {
Slider(value: $fontSize, in: 12...24, step: 1) {
Text("Font Size")
} minimumValueLabel: {
Text("A").font(.caption)
} maximumValueLabel: {
Text("A").font(.title)
}
.padding(.vertical)
}
}
}
}
Sheets and Alerts
Sheet Presentation
struct ContentView: View {
@State private var showingSettings = false
@State private var selectedItem: Item?
var body: some View {
List(items) { item in
Button(item.name) {
selectedItem = item
}
}
.toolbar {
Button {
showingSettings = true
} label: {
Image(systemName: "gear")
}
}
.sheet(isPresented: $showingSettings) {
SettingsView()
}
.sheet(item: $selectedItem) { item in
ItemDetail(item: item)
}
}
}
Confirmation Dialogs
struct ItemRow: View {
let item: Item
@State private var showingDeleteConfirmation = false
var body: some View {
HStack {
Text(item.name)
Spacer()
Button(role: .destructive) {
showingDeleteConfirmation = true
} label: {
Image(systemName: "trash")
}
}
.confirmationDialog(
"Delete \(item.name)?",
isPresented: $showingDeleteConfirmation,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
delete(item)
}
} message: {
Text("This action cannot be undone.")
}
}
}
iOS 26 Features
Liquid Glass
struct GlassCard: View {
var body: some View {
VStack {
Text("Premium Content")
.font(.headline)
}
.padding()
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 16))
// iOS 26 glass effect
.glassEffect()
}
}
// Availability check
struct AdaptiveCard: View {
var body: some View {
if #available(iOS 26, *) {
GlassCard()
} else {
StandardCard()
}
}
}
WebView
import WebKit
// iOS 26+ native WebView
struct WebContent: View {
let url: URL
var body: some View {
if #available(iOS 26, *) {
WebView(url: url)
.ignoresSafeArea()
} else {
WebViewRepresentable(url: url)
}
}
}
// Fallback for iOS 18
struct WebViewRepresentable: UIViewRepresentable {
let url: URL
func makeUIView(context: Context) -> WKWebView {
WKWebView()
}
func updateUIView(_ webView: WKWebView, context: Context) {
webView.load(URLRequest(url: url))
}
}
@Animatable Macro
// iOS 26+
@available(iOS 26, *)
@Animatable
struct PulsingCircle: View {
var scale: Double
var body: some View {
Circle()
.scaleEffect(scale)
}
}
Custom Modifiers
Reusable Styling
struct CardModifier: ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background(.background)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.1), radius: 4, y: 2)
}
}
extension View {
func cardStyle() -> some View {
modifier(CardModifier())
}
}
// Usage
Text("Content")
.cardStyle()
Conditional Modifiers
extension View {
@ViewBuilder
func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition {
transform(self)
} else {
self
}
}
}
// Usage
Text("Item")
.if(isHighlighted) { view in
view.foregroundStyle(.accent)
}
Preview Techniques
Multiple Configurations
#Preview("Light Mode") {
ItemRow(item: .sample)
.preferredColorScheme(.light)
}
#Preview("Dark Mode") {
ItemRow(item: .sample)
.preferredColorScheme(.dark)
}
#Preview("Large Text") {
ItemRow(item: .sample)
.environment(\.sizeCategory, .accessibilityExtraLarge)
}
Interactive Previews
#Preview {
@Previewable @State var isOn = false
Toggle("Setting", isOn: $isOn)
.padding()
}
Preview with Mock Data
extension Item {
static let sample = Item(
name: "Sample Item",
subtitle: "Sample subtitle",
icon: "star"
)
static let samples: [Item] = [
Item(name: "First", subtitle: "One", icon: "1.circle"),
Item(name: "Second", subtitle: "Two", icon: "2.circle"),
Item(name: "Third", subtitle: "Three", icon: "3.circle")
]
}
#Preview {
List(Item.samples) { item in
ItemRow(item: item)
}
}