SwiftUI Lists and Grids¶
List, ForEach, LazyVGrid, and ScrollView are the primary containers for displaying collections of data in SwiftUI. This entry covers dynamic lists, grid layouts, swipe-to-delete, and the differences between scrollable containers.
Key Facts¶
Listdisplays scrollable rows with built-in separators and swipe-to-deleteForEachgenerates views from a collection but is not scrollable by itself- Items in
ForEach/Listmust conform toIdentifiableor provideid:parameter LazyVGridcreates scrollable grid layouts with flexible or fixed columnsScrollViewprovides custom scrollable layout without List's built-in features- Range syntax in
ForEach: only half-open ranges (0..<5), not closed ranges (0...4)
Patterns¶
Static List¶
Dynamic List from Array¶
struct ContentView: View {
var dogs = ["Fido", "Sarah", "Billy"]
var body: some View {
List(dogs, id: \.self) { dog in
Text(dog)
}
}
}
id: \.self uses the value itself as identifier. Works for String, Int, etc.
List with Identifiable Model¶
class JournalEntry: Identifiable {
var id = UUID()
var title: String
var text: String
}
List(journalEntries) { entry in
Text(entry.title)
}
Identifiable conformance provides auto-generated id property.
ForEach Inside List (with onDelete)¶
List {
ForEach(entries) { entry in
Text(entry.title)
}
.onDelete { indices in
entries.remove(atOffsets: indices)
}
}
ForEach Variants¶
// Range-based (underscore when not using index)
ForEach(0..<5) { _ in
CurrencyIcon(currency: .copperPenny)
}
// Named index
ForEach(0..<5) { index in
Text("Row \(index)")
}
// Collection-based with Identifiable
ForEach(journalEntries) { entry in
Text(entry.title)
}
// Non-Identifiable with id parameter
ForEach(predator.movies, id: \.self) { movie in
Text(movie)
}
// Enum cases
ForEach(Currency.allCases, id: \.self) { currency in
CurrencyIcon(currency: currency)
}
LazyVGrid¶
let columns = [GridItem(), GridItem(), GridItem()] // 3 flexible columns
LazyVGrid(columns: columns) {
ForEach(currencies, id: \.self) { currency in
CurrencyIcon(currency: currency)
}
}
.padding()
Column types: - GridItem() - flexible (default), fills available space - GridItem(.fixed(100)) - fixed width - Column count = number of GridItem in array
ScrollView vs List¶
// List - built-in rows, separators, swipe-to-delete, selection
List(items) { item in Text(item.name) }
// ScrollView - custom layout, no separators
ScrollView {
VStack {
ForEach(items) { item in Text(item.name) }
}
}
ScrollView content starts at top. Use ScrollView for full custom layout; use List for standard rows.
ScrollViewReader (Programmatic Scrolling)¶
ScrollViewReader { proxy in
ScrollView {
ForEach(items) { item in
Text(item.name).id(item.id)
}
}
Button("Scroll to last") {
proxy.scrollTo(items.last?.id, anchor: .bottom)
}
}
Sort, Filter, and Search Pattern¶
@Observable
class Predators {
var apexPredators: [ApexPredator] = []
var allApexPredators: [ApexPredator] = [] // master list, never modified
func sortByName(alphabetical: Bool) {
apexPredators.sort { pred1, pred2 in
alphabetical ? pred1.name < pred2.name : pred1.id < pred2.id
}
}
func filterBy(_ type: APType?) {
if let type {
apexPredators = allApexPredators.filter { $0.type == type }
} else {
apexPredators = allApexPredators
}
}
func search(for text: String) -> [ApexPredator] {
if text.isEmpty {
return apexPredators
} else {
return apexPredators.filter {
$0.name.localizedCaseInsensitiveContains(text)
}
}
}
}
Critical: always filter from allApexPredators (master list), not from the already-filtered apexPredators. Otherwise, applying a second filter produces empty results.
Animating List Changes¶
List with Section Footer¶
List {
Section {
ForEach(pokedex) { pokemon in ... }
} footer: {
if pokedex.count < 151 {
ContentUnavailableView { ... }
}
}
}
Gotchas¶
ForEachonly accepts half-open ranges (0..<5), not closed ranges (0...4)id: \.selfworks when values are unique - duplicate values cause undefined behaviorListautomatically providesIdentifiable-like behavior but still requires conformance on model types- When filtering, always filter from the original master list, not from an already-filtered copy
LazyVGridrenders cells lazily (only when visible) - good for performance with large datasetsScrollViewfills all available space by default, unlikeVStackwhich only takes needed space.onDeleteonly works withForEachinside aList, not withList(items)directly
See Also¶
- [[swiftui-navigation]] - NavigationLink inside List rows
- [[swiftui-views-and-modifiers]] - modifier reference for styling list content
- [[swiftui-forms-and-input]] - Form as a specialized list-like container
- [[swiftdata-persistence]] - @Query to populate lists from database