Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Reviews, improves, and writes SwiftUI code following state management, view composition, performance, and iOS 26+ Liquid Glass best practices.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
references/list-patterns.md
1# SwiftUI List Patterns Reference23## Table of Contents45- [ForEach Identity and Stability](#foreach-identity-and-stability)6- [Enumerated Sequences](#enumerated-sequences)7- [List with Custom Styling](#list-with-custom-styling)8- [List with Pull-to-Refresh](#list-with-pull-to-refresh)9- [Empty States with ContentUnavailableView (iOS 17+)](#empty-states-with-contentunavailableview-ios-17)10- [Custom List Backgrounds](#custom-list-backgrounds)11- [Table](#table)12- [Summary Checklist](#summary-checklist)1314## ForEach Identity and Stability1516**Always provide stable identity for `ForEach`.** Never use `.indices` for dynamic content.1718```swift19// Good - stable identity via Identifiable20extension User: Identifiable {21var id: String { userId }22}2324ForEach(users) { user in25UserRow(user: user)26}2728// Good - stable identity via keypath29ForEach(users, id: \.userId) { user in30UserRow(user: user)31}3233// Wrong - indices create static content34ForEach(users.indices, id: \.self) { index in35UserRow(user: users[index]) // Can crash on removal!36}3738// Wrong - unstable identity39ForEach(users, id: \.self) { user in40UserRow(user: user) // Only works if User is Hashable and stable41}42```4344**Critical**: Ensure **constant number of views per element** in `ForEach`:4546```swift47// Good - consistent view count48ForEach(items) { item in49ItemRow(item: item)50}5152// Bad - variable view count breaks identity53ForEach(items) { item in54if item.isSpecial {55SpecialRow(item: item)56DetailRow(item: item)57} else {58RegularRow(item: item)59}60}61```6263**Avoid inline filtering:**6465```swift66// Bad - unstable identity, changes on every update67ForEach(items.filter { $0.isEnabled }) { item in68ItemRow(item: item)69}7071// Good - prefilter and cache72@State private var enabledItems: [Item] = []7374var body: some View {75ForEach(enabledItems) { item in76ItemRow(item: item)77}78.onChange(of: items) { _, newItems in79enabledItems = newItems.filter { $0.isEnabled }80}81}82```8384**Avoid `AnyView` in list rows:**8586```swift87// Bad - hides identity, increases cost88ForEach(items) { item in89AnyView(item.isSpecial ? SpecialRow(item: item) : RegularRow(item: item))90}9192// Good - Create a unified row view93ForEach(items) { item in94ItemRow(item: item)95}9697struct ItemRow: View {98let item: Item99100var body: some View {101if item.isSpecial {102SpecialRow(item: item)103} else {104RegularRow(item: item)105}106}107}108```109110**Why**: Stable identity is critical for performance and animations. Unstable identity causes excessive diffing, broken animations, and potential crashes.111112### Identifiable ID Must Be Truly Unique113114Non-unique IDs cause SwiftUI to treat different items as identical, leading to duplicate rendering or missing views:115116```swift117// Bug -- two articles with the same URL show identical content118struct Article: Identifiable {119let title: String120let url: URL121var id: String { url.absoluteString } // Not unique if URLs repeat!122}123124// Fix -- use a genuinely unique identifier125struct Article: Identifiable {126let id: UUID127let title: String128let url: URL129}130```131132**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.133134## Enumerated Sequences135136**Always convert enumerated sequences to arrays. To be able to use them in a ForEach.**137138```swift139let items = ["A", "B", "C"]140141// Correct142ForEach(Array(items.enumerated()), id: \.offset) { index, item in143Text("\(index): \(item)")144}145146// Wrong - Doesn't compile, enumerated() isn't an array147ForEach(items.enumerated(), id: \.offset) { index, item in148Text("\(index): \(item)")149}150```151152## List with Custom Styling153154```swift155// Remove default background and separators156List(items) { item in157ItemRow(item: item)158.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))159.listRowSeparator(.hidden)160}161.listStyle(.plain)162.scrollContentBackground(.hidden)163.background(Color.customBackground)164.environment(\.defaultMinListRowHeight, 1) // Allows custom row heights165```166167## List with Pull-to-Refresh168169```swift170List(items) { item in171ItemRow(item: item)172}173.refreshable {174await loadItems()175}176```177178## Empty States with ContentUnavailableView (iOS 17+)179180Use `ContentUnavailableView` for empty list/search states. The built-in `.search` variant is auto-localized:181182```swift183List {184ForEach(searchResults) { item in185ItemRow(item: item)186}187}188.overlay {189if searchResults.isEmpty, !searchText.isEmpty {190ContentUnavailableView.search(text: searchText)191}192}193```194195For non-search empty states, use a custom instance:196197```swift198ContentUnavailableView(199"No Articles",200systemImage: "doc.richtext.fill",201description: Text("Articles you save will appear here.")202)203```204205## Custom List Backgrounds206207Use `.scrollContentBackground(.hidden)` to replace the default list background:208209```swift210List(items) { item in211ItemRow(item: item)212}213.scrollContentBackground(.hidden)214.background(Color.customBackground)215```216217Without `.scrollContentBackground(.hidden)`, a custom `.background()` has no visible effect on `List`.218219## Table220221> **Availability:** iOS 16.0+, iPadOS 16.0+, visionOS 1.0+222223A 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.224225### Basic Table226227```swift228struct Person: Identifiable {229let givenName: String230let familyName: String231let emailAddress: String232let id = UUID()233var fullName: String { givenName + " " + familyName }234}235236struct PeopleTable: View {237@State private var people: [Person] = [ /* ... */ ]238239var body: some View {240Table(people) {241TableColumn("Given Name", value: \.givenName)242TableColumn("Family Name", value: \.familyName)243TableColumn("E-Mail Address", value: \.emailAddress)244}245}246}247```248249### Table with Selection250251Bind to a single `ID` for single-selection, or a `Set<ID>` for multi-selection:252253```swift254struct SelectableTable: View {255@State private var people: [Person] = [ /* ... */ ]256@State private var selectedPeople = Set<Person.ID>()257258var body: some View {259Table(people, selection: $selectedPeople) {260TableColumn("Given Name", value: \.givenName)261TableColumn("Family Name", value: \.familyName)262TableColumn("E-Mail Address", value: \.emailAddress)263}264Text("\(selectedPeople.count) people selected")265}266}267```268269### Sortable Table270271Provide a binding to `[KeyPathComparator]` and re-sort the data in `.onChange(of:)`:272273```swift274struct SortableTable: View {275@State private var people: [Person] = [ /* ... */ ]276@State private var sortOrder = [KeyPathComparator(\Person.givenName)]277278var body: some View {279Table(people, sortOrder: $sortOrder) {280TableColumn("Given Name", value: \.givenName)281TableColumn("Family Name", value: \.familyName)282TableColumn("E-Mail Address", value: \.emailAddress)283}284.onChange(of: sortOrder) { _, newOrder in285people.sort(using: newOrder)286}287}288}289```290291**Important:** The table does **not** sort data itself — you must re-sort the collection when `sortOrder` changes.292293### Adaptive Table for Compact Size Classes294295On iPhone or iPad in Slide Over, only the first column is shown. Customize it to display combined information:296297```swift298struct AdaptiveTable: View {299@Environment(\.horizontalSizeClass) private var horizontalSizeClass300private var isCompact: Bool { horizontalSizeClass == .compact }301302@State private var people: [Person] = [ /* ... */ ]303@State private var sortOrder = [KeyPathComparator(\Person.givenName)]304305var body: some View {306Table(people, sortOrder: $sortOrder) {307TableColumn("Given Name", value: \.givenName) { person in308VStack(alignment: .leading) {309Text(isCompact ? person.fullName : person.givenName)310if isCompact {311Text(person.emailAddress)312.foregroundStyle(.secondary)313}314}315}316TableColumn("Family Name", value: \.familyName)317TableColumn("E-Mail Address", value: \.emailAddress)318}319.onChange(of: sortOrder) { _, newOrder in320people.sort(using: newOrder)321}322}323}324```325326### Table with Static Rows327328Use `init(of:columns:rows:)` when rows are known at compile time:329330```swift331struct Purchase: Identifiable {332let price: Decimal333let id = UUID()334}335336struct TipTable: View {337let currencyStyle = Decimal.FormatStyle.Currency(code: "USD")338339var body: some View {340Table(of: Purchase.self) {341TableColumn("Base price") { purchase in342Text(purchase.price, format: currencyStyle)343}344TableColumn("With 15% tip") { purchase in345Text(purchase.price * 1.15, format: currencyStyle)346}347TableColumn("With 20% tip") { purchase in348Text(purchase.price * 1.2, format: currencyStyle)349}350} rows: {351TableRow(Purchase(price: 20))352TableRow(Purchase(price: 50))353TableRow(Purchase(price: 75))354}355}356}357```358359### Table with Dynamic Number of Columns360361> **Availability:** iOS 17.4+, iPadOS 17.4+, Mac Catalyst 17.4+, macOS 14.4+, visionOS 1.1+362363If 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.364365This can be mixed with static compile time known `TableColumn` usage.366367```swift368struct AudioChannel: Identifiable {369let name: String370let id: UUID371}372373struct AudioSample: Identifiable {374let id: UUID375let timestamp: TimeInterval376func level(channel: AudioChannel.ID) -> Double {3771378}379}380381@Observable382class AudioSampleTrack {383let channels: [AudioChannel]384var samples: [AudioSample]385}386387struct ContentView: View {388var track: AudioSampleTrack389390var body: some View {391Table(track.samples) {392TableColumn("Timestamp (ms)") { sample in393Text(sample.timestamp, format: .number.scale(1000))394.monospacedDigit()395}396TableColumnForEach(track.channels) { channel in397TableColumn(channel.name) { sample in398Text(sample.level(channel: channel.id),399format: .number.precision(.fractionLength(2))400)401.monospacedDigit()402}403.width(ideal: 70)404.alignment(.numeric)405}406}407}408}409```410411### Table Styles412413```swift414// Inset (no borders)415Table(people) { /* columns */ }416.tableStyle(.inset)417418// Hide column headers419Table(people) { /* columns */ }420.tableColumnHeaders(.hidden)421```422423### Platform Behavior424425| Platform | Behavior |426|----------|----------|427| **iPadOS (regular)** | Full multi-column layout; headers and all columns visible |428| **iPadOS (compact)** | Only the first column shown; headers hidden |429| **iPhone (all sizes)** | Only the first column shown; headers hidden; list-like appearance |430431> **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).432433## Summary Checklist434435- [ ] ForEach uses stable identity (never `.indices` for dynamic content)436- [ ] Identifiable IDs are truly unique across all items437- [ ] Constant number of views per ForEach element438- [ ] No inline filtering in ForEach (prefilter and cache instead)439- [ ] No `AnyView` in list rows440- [ ] Don't convert enumerated sequences to arrays441- [ ] Use `.refreshable` for pull-to-refresh442- [ ] Use `ContentUnavailableView` for empty states (iOS 17+)443- [ ] Use `.scrollContentBackground(.hidden)` for custom list backgrounds444- [ ] `Table` adapts for compact size classes (first column shows combined info)445- [ ] `Table` sorting re-sorts data in `.onChange(of: sortOrder)` (table doesn't sort itself)446- [ ] `Table` data conforms to `Identifiable`447