SwiftUI List Patterns Reference
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
Always provide stable identity for ForEach. Never use .indices for dynamic content.
// Good - stable identity via Identifiable
extension User: Identifiable {
var id: String { userId }
}
ForEach(users) { user in
UserRow(user: user)
}
// Good - stable identity via keypath
ForEach(users, id: \.userId) { user in
UserRow(user: user)
}
// Wrong - indices create static content
ForEach(users.indices, id: \.self) { index in
UserRow(user: users[index]) // Can crash on removal!
}
// Wrong - unstable identity
ForEach(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 count
ForEach(items) { item in
ItemRow(item: item)
}
// Bad - variable view count breaks identity
ForEach(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 update
ForEach(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 cost
ForEach(items) { item in
AnyView(item.isSpecial ? SpecialRow(item: item) : RegularRow(item: item))
}
// Good - Create a unified row view
ForEach(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
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 content
struct Article: Identifiable {
let title: String
let url: URL
var id: String { url.absoluteString } // Not unique if URLs repeat!
}
// Fix -- use a genuinely unique identifier
struct 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
Always convert enumerated sequences to arrays. To be able to use them in a ForEach.
let items = ["A", "B", "C"]
// Correct
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
Text("\(index): \(item)")
}
// Wrong - Doesn't compile, enumerated() isn't an array
ForEach(items.enumerated(), id: \.offset) { index, item in
Text("\(index): \(item)")
}List with Custom Styling
// Remove default background and separators
List(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
List(items) { item in
ItemRow(item: item)
}
.refreshable {
await loadItems()
}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
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.
Table
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
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
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
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
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
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
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
}
}
@Observable
class 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
// Inset (no borders)
Table(people) { /* columns */ }
.tableStyle(.inset)
// Hide column headers
Table(people) { /* columns */ }
.tableColumnHeaders(.hidden)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
- [ ] 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