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 view with a single top-level container93ForEach(items) { item in94ItemRow(item: item)95}9697struct ItemRow: View {98let item: Item99100var body: some View {101// The VStack keeps the row "unary" (one top-level view) so the102// List can template row ids without evaluating every row's body.103VStack {104if item.isSpecial {105SpecialRow(item: item)106} else {107RegularRow(item: item)108}109}110}111}112```113114**Why**: Stable identity is critical for performance and animations. Unstable identity causes excessive diffing, broken animations, and potential crashes.115116### Prefer unary rows in `List`117118`List` needs the identity of every row up front. When each row's body produces a **single top-level view** (a "unary" row), SwiftUI can template the row id from the `ForEach` element's id alone, without running each row's `body`. When the body branches between different top-level shapes — a bare top-level `switch`, a top-level `if` without `else`, or an `AnyView` — structural identity varies per row, so SwiftUI falls back to evaluating every row's body just to compute ids. That cost scales with the number of rows.119120The fix is to wrap branching content in any single-root container (`VStack`, `HStack`, `ZStack`, or a custom wrapper) so the row is always exactly one top-level view, as shown above. A top-level `if` without an `else` is also "multi" (0 or 1 views); if some elements shouldn't be rows at all, filter the collection before it reaches the `ForEach` rather than producing a zero-view row.121122To find non-constant row builders in an existing app, launch with `-LogForEachSlowPath YES`; SwiftUI logs each `ForEach` inside a lazy container whose row body produces a non-constant number of views.123124### Keep ids stable, unique, and cheap125126Three more identity rules that prevent subtle bugs:127128- **The id must outlive the view and not change on edit.** Don't derive `id` from a mutable property (e.g. `var id: String { title }`). Editing the title changes the id, so SwiftUI treats it as a removal plus insertion — focus and per-row state are lost mid-edit. Use a stable `let id: UUID` or a server-assigned key.129- **Don't synthesize a fresh id inside `body`.** `ForEach(items.map { Item(title: $0) })` creates new `UUID`s on every body pass, so the whole collection reads as replaced every update. Create ids once in storage that outlives `body` (the model layer), not inline.130- **Keep the id cheap to hash.** Avoid `id: \.self` on a large `Hashable` struct; hashing walks every field on every diff. Use a small primitive (`UUID`, `Int`, short `String`, `URL`) and still pass the full element to the row.131132### Identifiable ID Must Be Truly Unique133134Non-unique IDs cause SwiftUI to treat different items as identical, leading to duplicate rendering or missing views:135136```swift137// Bug -- two articles with the same URL show identical content138struct Article: Identifiable {139let title: String140let url: URL141var id: String { url.absoluteString } // Not unique if URLs repeat!142}143144// Fix -- use a genuinely unique identifier145struct Article: Identifiable {146let id: UUID147let title: String148let url: URL149}150```151152**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.153154## Enumerated Sequences155156**Using `.enumerated()` is fine; the index just must not be the identity.** Using `\.offset` as the id is the same anti-pattern as `\.self` on `items.indices` — the id becomes the position, not the element, so inserts and reorders reset row state and break animations. Keep the element's own identity as the id and treat the index as ordinary row data.157158```swift159// Wrong - offset is the position, not the element160ForEach(items.enumerated(), id: \.offset) { index, item in161ItemRow(number: index + 1, item: item)162}163164// Correct - id comes from the element; index is just data165ForEach(items.enumerated(), id: \.element.id) { index, item in166ItemRow(number: index + 1, item: item)167}168```169170**No `Array(...)` wrapper is needed on Swift 6.1+.** As of Swift 6.1, the sequence returned by `.enumerated()` conditionally conforms to `RandomAccessCollection` when the base collection does, so `ForEach` accepts it directly. On earlier toolchains, wrap it in `Array(...)`. Favor the direct form in new code — it avoids an eager copy on every body evaluation.171172## List with Custom Styling173174```swift175// Remove default background and separators176List(items) { item in177ItemRow(item: item)178.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))179.listRowSeparator(.hidden)180}181.listStyle(.plain)182.scrollContentBackground(.hidden)183.background(Color.customBackground)184.environment(\.defaultMinListRowHeight, 1) // Allows custom row heights185```186187## List with Pull-to-Refresh188189```swift190List(items) { item in191ItemRow(item: item)192}193.refreshable {194await loadItems()195}196```197198## Empty States with ContentUnavailableView (iOS 17+)199200Use `ContentUnavailableView` for empty list/search states. The built-in `.search` variant is auto-localized:201202```swift203List {204ForEach(searchResults) { item in205ItemRow(item: item)206}207}208.overlay {209if searchResults.isEmpty, !searchText.isEmpty {210ContentUnavailableView.search(text: searchText)211}212}213```214215For non-search empty states, use a custom instance:216217```swift218ContentUnavailableView(219"No Articles",220systemImage: "doc.richtext.fill",221description: Text("Articles you save will appear here.")222)223```224225## Custom List Backgrounds226227Use `.scrollContentBackground(.hidden)` to replace the default list background:228229```swift230List(items) { item in231ItemRow(item: item)232}233.scrollContentBackground(.hidden)234.background(Color.customBackground)235```236237Without `.scrollContentBackground(.hidden)`, a custom `.background()` has no visible effect on `List`.238239## Table240241> **Availability:** iOS 16.0+, iPadOS 16.0+, visionOS 1.0+242243A 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.244245### Basic Table246247```swift248struct Person: Identifiable {249let givenName: String250let familyName: String251let emailAddress: String252let id = UUID()253var fullName: String { givenName + " " + familyName }254}255256struct PeopleTable: View {257@State private var people: [Person] = [ /* ... */ ]258259var body: some View {260Table(people) {261TableColumn("Given Name", value: \.givenName)262TableColumn("Family Name", value: \.familyName)263TableColumn("E-Mail Address", value: \.emailAddress)264}265}266}267```268269### Table with Selection270271Bind to a single `ID` for single-selection, or a `Set<ID>` for multi-selection:272273```swift274struct SelectableTable: View {275@State private var people: [Person] = [ /* ... */ ]276@State private var selectedPeople = Set<Person.ID>()277278var body: some View {279Table(people, selection: $selectedPeople) {280TableColumn("Given Name", value: \.givenName)281TableColumn("Family Name", value: \.familyName)282TableColumn("E-Mail Address", value: \.emailAddress)283}284Text("\(selectedPeople.count) people selected")285}286}287```288289### Sortable Table290291Provide a binding to `[KeyPathComparator]` and re-sort the data in `.onChange(of:)`:292293```swift294struct SortableTable: View {295@State private var people: [Person] = [ /* ... */ ]296@State private var sortOrder = [KeyPathComparator(\Person.givenName)]297298var body: some View {299Table(people, sortOrder: $sortOrder) {300TableColumn("Given Name", value: \.givenName)301TableColumn("Family Name", value: \.familyName)302TableColumn("E-Mail Address", value: \.emailAddress)303}304.onChange(of: sortOrder) { _, newOrder in305people.sort(using: newOrder)306}307}308}309```310311**Important:** The table does **not** sort data itself — you must re-sort the collection when `sortOrder` changes.312313### Adaptive Table for Compact Size Classes314315On iPhone or iPad in Slide Over, only the first column is shown. Customize it to display combined information:316317```swift318struct AdaptiveTable: View {319@Environment(\.horizontalSizeClass) private var horizontalSizeClass320private var isCompact: Bool { horizontalSizeClass == .compact }321322@State private var people: [Person] = [ /* ... */ ]323@State private var sortOrder = [KeyPathComparator(\Person.givenName)]324325var body: some View {326Table(people, sortOrder: $sortOrder) {327TableColumn("Given Name", value: \.givenName) { person in328VStack(alignment: .leading) {329Text(isCompact ? person.fullName : person.givenName)330if isCompact {331Text(person.emailAddress)332.foregroundStyle(.secondary)333}334}335}336TableColumn("Family Name", value: \.familyName)337TableColumn("E-Mail Address", value: \.emailAddress)338}339.onChange(of: sortOrder) { _, newOrder in340people.sort(using: newOrder)341}342}343}344```345346### Table with Static Rows347348Use `init(of:columns:rows:)` when rows are known at compile time:349350```swift351struct Purchase: Identifiable {352let price: Decimal353let id = UUID()354}355356struct TipTable: View {357let currencyStyle = Decimal.FormatStyle.Currency(code: "USD")358359var body: some View {360Table(of: Purchase.self) {361TableColumn("Base price") { purchase in362Text(purchase.price, format: currencyStyle)363}364TableColumn("With 15% tip") { purchase in365Text(purchase.price * 1.15, format: currencyStyle)366}367TableColumn("With 20% tip") { purchase in368Text(purchase.price * 1.2, format: currencyStyle)369}370} rows: {371TableRow(Purchase(price: 20))372TableRow(Purchase(price: 50))373TableRow(Purchase(price: 75))374}375}376}377```378379### Table with Dynamic Number of Columns380381> **Availability:** iOS 17.4+, iPadOS 17.4+, Mac Catalyst 17.4+, macOS 14.4+, visionOS 1.1+382383If 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.384385This can be mixed with static compile time known `TableColumn` usage.386387```swift388struct AudioChannel: Identifiable {389let name: String390let id: UUID391}392393struct AudioSample: Identifiable {394let id: UUID395let timestamp: TimeInterval396func level(channel: AudioChannel.ID) -> Double {3971398}399}400401@Observable402class AudioSampleTrack {403let channels: [AudioChannel]404var samples: [AudioSample]405}406407struct ContentView: View {408var track: AudioSampleTrack409410var body: some View {411Table(track.samples) {412TableColumn("Timestamp (ms)") { sample in413Text(sample.timestamp, format: .number.scale(1000))414.monospacedDigit()415}416TableColumnForEach(track.channels) { channel in417TableColumn(channel.name) { sample in418Text(sample.level(channel: channel.id),419format: .number.precision(.fractionLength(2))420)421.monospacedDigit()422}423.width(ideal: 70)424.alignment(.numeric)425}426}427}428}429```430431### Table Styles432433```swift434// Inset (no borders)435Table(people) { /* columns */ }436.tableStyle(.inset)437438// Hide column headers439Table(people) { /* columns */ }440.tableColumnHeaders(.hidden)441```442443### Platform Behavior444445| Platform | Behavior |446|----------|----------|447| **iPadOS (regular)** | Full multi-column layout; headers and all columns visible |448| **iPadOS (compact)** | Only the first column shown; headers hidden |449| **iPhone (all sizes)** | Only the first column shown; headers hidden; list-like appearance |450451> **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).452453## Summary Checklist454455- [ ] ForEach uses stable identity (never `.indices` or `\.offset` for dynamic content)456- [ ] Identifiable IDs are truly unique across all items457- [ ] id is stable across edits (not derived from a mutable property), created outside `body`, and cheap to hash458- [ ] Constant number of views per ForEach element; rows are unary (single top-level view)459- [ ] No inline filtering in ForEach (prefilter and cache instead)460- [ ] No `AnyView` in list rows461- [ ] `.enumerated()` uses the element's id (not `\.offset`); no `Array(...)` wrapper needed on Swift 6.1+462- [ ] Use `.refreshable` for pull-to-refresh463- [ ] Use `ContentUnavailableView` for empty states (iOS 17+)464- [ ] Use `.scrollContentBackground(.hidden)` for custom list backgrounds465- [ ] `Table` adapts for compact size classes (first column shows combined info)466- [ ] `Table` sorting re-sorts data in `.onChange(of: sortOrder)` (table doesn't sort itself)467- [ ] `Table` data conforms to `Identifiable`468