SwiftUI List Patterns Reference
SwiftUI List Patterns Reference
Section titled “SwiftUI List Patterns Reference”Table of Contents
Section titled “Table of Contents”- ForEach Identity and Stability
- Enumerated Sequences
- List with Custom Styling
- List with Pull-to-Refresh
- Empty States with ContentUnavailableView (iOS 17+)
- Custom List Backgrounds
- Table
- Summary Checklist
ForEach Identity and Stability
Section titled “ForEach Identity and Stability”Always provide stable identity for ForEach. Never use .indices for dynamic content.
// Good - stable identity via Identifiableextension User: Identifiable { var id: String { userId }}
ForEach(users) { user in UserRow(user: user)}
// Good - stable identity via keypathForEach(users, id: \.userId) { user in UserRow(user: user)}
// Wrong - indices create static contentForEach(users.indices, id: \.self) { index in UserRow(user: users[index]) // Can crash on removal!}
// Wrong - unstable identityForEach(users, id: \.self) { user in UserRow(user: user) // Only works if User is Hashable and stable}Critical: Ensure constant number of views per element in ForEach:
// Good - consistent view countForEach(items) { item in ItemRow(item: item)}
// Bad - variable view count breaks identityForEach(items) { item in if item.isSpecial { SpecialRow(item: item) DetailRow(item: item) } else { RegularRow(item: item) }}Avoid inline filtering:
// Bad - unstable identity, changes on every updateForEach(items.filter { $0.isEnabled }) { item in ItemRow(item: item)}
// Good - prefilter and cache@State private var enabledItems: [Item] = []
var body: some View { ForEach(enabledItems) { item in ItemRow(item: item) } .onChange(of: items) { _, newItems in enabledItems = newItems.filter { $0.isEnabled } }}Avoid AnyView in list rows:
// Bad - hides identity, increases costForEach(items) { item in AnyView(item.isSpecial ? SpecialRow(item: item) : RegularRow(item: item))}
// Good - Create a unified row viewForEach(items) { item in ItemRow(item: item)}
struct ItemRow: View { let item: Item
var body: some View { if item.isSpecial { SpecialRow(item: item) } else { RegularRow(item: item) } }}Why: Stable identity is critical for performance and animations. Unstable identity causes excessive diffing, broken animations, and potential crashes.
Identifiable ID Must Be Truly Unique
Section titled “Identifiable ID Must Be Truly Unique”Non-unique IDs cause SwiftUI to treat different items as identical, leading to duplicate rendering or missing views:
// Bug -- two articles with the same URL show identical contentstruct Article: Identifiable { let title: String let url: URL var id: String { url.absoluteString } // Not unique if URLs repeat!}
// Fix -- use a genuinely unique identifierstruct Article: Identifiable { let id: UUID let title: String let url: URL}Classes get a default ObjectIdentifier-based id when conforming to Identifiable without providing one. This is only unique for the object’s lifetime and can be recycled after deallocation.
Enumerated Sequences
Section titled “Enumerated Sequences”Always convert enumerated sequences to arrays. To be able to use them in a ForEach.
let items = ["A", "B", "C"]
// CorrectForEach(Array(items.enumerated()), id: \.offset) { index, item in Text("\(index): \(item)")}
// Wrong - Doesn't compile, enumerated() isn't an arrayForEach(items.enumerated(), id: \.offset) { index, item in Text("\(index): \(item)")}List with Custom Styling
Section titled “List with Custom Styling”// Remove default background and separatorsList(items) { item in ItemRow(item: item) .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) .listRowSeparator(.hidden)}.listStyle(.plain).scrollContentBackground(.hidden).background(Color.customBackground).environment(\.defaultMinListRowHeight, 1) // Allows custom row heightsList with Pull-to-Refresh
Section titled “List with Pull-to-Refresh”List(items) { item in ItemRow(item: item)}.refreshable { await loadItems()}Empty States with ContentUnavailableView (iOS 17+)
Section titled “Empty States with ContentUnavailableView (iOS 17+)”Use ContentUnavailableView for empty list/search states. The built-in .search variant is auto-localized:
List { ForEach(searchResults) { item in ItemRow(item: item) }}.overlay { if searchResults.isEmpty, !searchText.isEmpty { ContentUnavailableView.search(text: searchText) }}For non-search empty states, use a custom instance:
ContentUnavailableView( "No Articles", systemImage: "doc.richtext.fill", description: Text("Articles you save will appear here."))Custom List Backgrounds
Section titled “Custom List Backgrounds”Use .scrollContentBackground(.hidden) to replace the default list background:
List(items) { item in ItemRow(item: item)}.scrollContentBackground(.hidden).background(Color.customBackground)Without .scrollContentBackground(.hidden), a custom .background() has no visible effect on List.
Availability: iOS 16.0+, iPadOS 16.0+, visionOS 1.0+
A multi-column data container that presents rows of Identifiable data with sortable, selectable columns. On compact size classes (iPhone, iPad Slide Over), columns after the first are automatically hidden.
Basic Table
Section titled “Basic Table”struct Person: Identifiable { let givenName: String let familyName: String let emailAddress: String let id = UUID() var fullName: String { givenName + " " + familyName }}
struct PeopleTable: View { @State private var people: [Person] = [ /* ... */ ]
var body: some View { Table(people) { TableColumn("Given Name", value: \.givenName) TableColumn("Family Name", value: \.familyName) TableColumn("E-Mail Address", value: \.emailAddress) } }}Table with Selection
Section titled “Table with Selection”Bind to a single ID for single-selection, or a Set<ID> for multi-selection:
struct SelectableTable: View { @State private var people: [Person] = [ /* ... */ ] @State private var selectedPeople = Set<Person.ID>()
var body: some View { Table(people, selection: $selectedPeople) { TableColumn("Given Name", value: \.givenName) TableColumn("Family Name", value: \.familyName) TableColumn("E-Mail Address", value: \.emailAddress) } Text("\(selectedPeople.count) people selected") }}Sortable Table
Section titled “Sortable Table”Provide a binding to [KeyPathComparator] and re-sort the data in .onChange(of:):
struct SortableTable: View { @State private var people: [Person] = [ /* ... */ ] @State private var sortOrder = [KeyPathComparator(\Person.givenName)]
var body: some View { Table(people, sortOrder: $sortOrder) { TableColumn("Given Name", value: \.givenName) TableColumn("Family Name", value: \.familyName) TableColumn("E-Mail Address", value: \.emailAddress) } .onChange(of: sortOrder) { _, newOrder in people.sort(using: newOrder) } }}Important: The table does not sort data itself — you must re-sort the collection when sortOrder changes.
Adaptive Table for Compact Size Classes
Section titled “Adaptive Table for Compact Size Classes”On iPhone or iPad in Slide Over, only the first column is shown. Customize it to display combined information:
struct AdaptiveTable: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass private var isCompact: Bool { horizontalSizeClass == .compact }
@State private var people: [Person] = [ /* ... */ ] @State private var sortOrder = [KeyPathComparator(\Person.givenName)]
var body: some View { Table(people, sortOrder: $sortOrder) { TableColumn("Given Name", value: \.givenName) { person in VStack(alignment: .leading) { Text(isCompact ? person.fullName : person.givenName) if isCompact { Text(person.emailAddress) .foregroundStyle(.secondary) } } } TableColumn("Family Name", value: \.familyName) TableColumn("E-Mail Address", value: \.emailAddress) } .onChange(of: sortOrder) { _, newOrder in people.sort(using: newOrder) } }}Table with Static Rows
Section titled “Table with Static Rows”Use init(of:columns:rows:) when rows are known at compile time:
struct Purchase: Identifiable { let price: Decimal let id = UUID()}
struct TipTable: View { let currencyStyle = Decimal.FormatStyle.Currency(code: "USD")
var body: some View { Table(of: Purchase.self) { TableColumn("Base price") { purchase in Text(purchase.price, format: currencyStyle) } TableColumn("With 15% tip") { purchase in Text(purchase.price * 1.15, format: currencyStyle) } TableColumn("With 20% tip") { purchase in Text(purchase.price * 1.2, format: currencyStyle) } } rows: { TableRow(Purchase(price: 20)) TableRow(Purchase(price: 50)) TableRow(Purchase(price: 75)) } }}Table with Dynamic Number of Columns
Section titled “Table with Dynamic Number of Columns”Availability: iOS 17.4+, iPadOS 17.4+, Mac Catalyst 17.4+, macOS 14.4+, visionOS 1.1+
If the number of columns is not known at runtime use TableColumnForEach to create columns based on a RandomAccessCollection of some data type. Either the collection’s elements must conform to Identifiable or you need to provide an id parameter to the TableColumnForEach initializer.
This can be mixed with static compile time known TableColumn usage.
struct AudioChannel: Identifiable { let name: String let id: UUID}
struct AudioSample: Identifiable { let id: UUID let timestamp: TimeInterval func level(channel: AudioChannel.ID) -> Double { 1 }}
@Observableclass AudioSampleTrack { let channels: [AudioChannel] var samples: [AudioSample]}
struct ContentView: View { var track: AudioSampleTrack
var body: some View { Table(track.samples) { TableColumn("Timestamp (ms)") { sample in Text(sample.timestamp, format: .number.scale(1000)) .monospacedDigit() } TableColumnForEach(track.channels) { channel in TableColumn(channel.name) { sample in Text(sample.level(channel: channel.id), format: .number.precision(.fractionLength(2)) ) .monospacedDigit() } .width(ideal: 70) .alignment(.numeric) } } }}Table Styles
Section titled “Table Styles”// Inset (no borders)Table(people) { /* columns */ } .tableStyle(.inset)
// Hide column headersTable(people) { /* columns */ } .tableColumnHeaders(.hidden)Platform Behavior
Section titled “Platform Behavior”| Platform | Behavior |
|---|---|
| iPadOS (regular) | Full multi-column layout; headers and all columns visible |
| iPadOS (compact) | Only the first column shown; headers hidden |
| iPhone (all sizes) | Only the first column shown; headers hidden; list-like appearance |
Best Practice: Prefer handling the compact size class by showing combined info in the first column. This provides a seamless transition when the size class changes (e.g., entering/exiting Slide Over on iPad).
Summary Checklist
Section titled “Summary Checklist”- ForEach uses stable identity (never
.indicesfor dynamic content) - Identifiable IDs are truly unique across all items
- Constant number of views per ForEach element
- No inline filtering in ForEach (prefilter and cache instead)
- No
AnyViewin list rows - Don’t convert enumerated sequences to arrays
- Use
.refreshablefor pull-to-refresh - Use
ContentUnavailableViewfor empty states (iOS 17+) - Use
.scrollContentBackground(.hidden)for custom list backgrounds -
Tableadapts for compact size classes (first column shows combined info) -
Tablesorting re-sorts data in.onChange(of: sortOrder)(table doesn’t sort itself) -
Tabledata conforms toIdentifiable