Lists in SwiftUI: Swift & iOS best practices explained
Building responsive, efficient list interfaces is fundamental to modern iOS app development. Whether you're displaying a product catalog, user feed, or configuration options, how you structure and optimize your lists directly impacts both user experience and app performance. In this article, we'll explore the practical techniques for implementing lists in SwiftUI that we've refined across production e-commerce and SaaS platforms.
Understanding List and ForEach with Identifiable data
At the core of any SwiftUI list is the List container paired with ForEach to iterate over collections. The key to building efficient lists is ensuring your data models conform to the Identifiable protocol, which tells SwiftUI how to uniquely identify each row during rendering and updates.
Consider a real-world scenario: you're building a checkout summary screen in an e-commerce app where users see their cart items. Each item needs a stable identifier that persists across state changes and network updates.
struct CartItem: Identifiable {
let id: UUID
let productName: String
let price: Double
var quantity: Int
}
struct CartView: View {
@State private var items: [CartItem] = [
CartItem(id: UUID(), productName: "Wireless Headphones", price: 79.99, quantity: 1),
CartItem(id: UUID(), productName: "Phone Case", price: 19.99, quantity: 2)
]
var body: some View {
List {
ForEach(items) { item in
HStack {
VStack(alignment: .leading) {
Text(item.productName)
.font(.headline)
Text(String(format: "$%.2f", item.price))
.font(.caption)
.foregroundColor(.gray)
}
Spacer()
Text("×\(item.quantity)")
.fontWeight(.semibold)
}
}
}
.listStyle(.plain)
}
}By conforming to Identifiable with a stable id property, SwiftUI can correctly diff your collection when items are added, removed, or reordered. Without this, you risk animation glitches and incorrect row updates. In production systems handling thousands of items, this distinction becomes critical for smooth scrolling performance.
When your model doesn't naturally have an id property, you can pass a key path to ForEach directly:
ForEach(items, id: \.productName) { item in
Text(item.productName)
}However, relying on string-based identifiers is fragile if duplicates exist. We recommend always defining an explicit id field on your model.
Swipe actions and edit mode for user interactions
Real-world apps need more than static lists. Users expect intuitive interactions like swiping to delete, marking items complete, or triggering custom actions. SwiftUI provides swipeActions for context menu-style interactions and editMode for bulk operations.
In a task management SaaS app, we frequently implement swipe-to-delete alongside an edit mode that lets users rearrange or bulk-delete items. Here's how that looks:
struct Task: Identifiable {
let id: UUID
var title: String
var isCompleted: Bool
}
struct TaskListView: View {
@State private var tasks: [Task] = [
Task(id: UUID(), title: "Review pull requests", isCompleted: false),
Task(id: UUID(), title: "Update documentation", isCompleted: true)
]
@Environment(\.editMode) var editMode
var body: some View {
List(selection: $selectedIds) {
ForEach(tasks) { task in
HStack {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundColor(task.isCompleted ? .green : .gray)
Text(task.title)
.strikethrough(task.isCompleted)
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
deleteTask(task)
} label: {
Label("Delete", systemImage: "trash")
}
Button {
toggleTask(task)
} label: {
Label("Toggle", systemImage: "checkmark")
}
.tint(.blue)
}
}
.onDelete(perform: deleteAtOffsets)
}
.toolbar {
EditButton()
}
}
private func deleteTask(_ task: Task) {
tasks.removeAll { $0.id == task.id }
}
private func toggleTask(_ task: Task) {
if let index = tasks.firstIndex(where: { $0.id == task.id }) {
tasks[index].isCompleted.toggle()
}
}
private func deleteAtOffsets(offsets: IndexSet) {
tasks.remove(atOffsets: offsets)
}
}The swipeActions modifier provides a natural, discoverable way for users to trigger destructive or primary actions. The role: .destructive parameter colors the action red, signaling danger. Secondary actions appear with a swipe-harder gesture. When you combine this with EditButton and onDelete, you give power users a keyboard-style bulk edit mode while casual users get intuitive gesture-based controls.
Note that swipeActions and onDelete serve different purposes: swipe actions are always visible, while delete on edit mode is only active when the user taps Edit. Choose based on your app's workflow.
Sections for grouping and organizing data
As lists grow, organizing them into sections dramatically improves readability and navigation. SwiftUI's Section component lets you group rows logically and add custom headers and footers.
We've found sections especially valuable in settings-heavy SaaS interfaces and detail screens in travel apps. Here's a practical example from a user settings panel:
struct SettingsView: View {
@State private var notificationsEnabled = true
@State private var darkModeEnabled = false
@State private var analyticsOptIn = true
var body: some View {
List {
Section(header: Text("Display")) {
Toggle("Dark Mode", isOn: $darkModeEnabled)
Picker("Font Size", selection: .constant("Medium")) {
Text("Small").tag("Small")
Text("Medium").tag("Medium")
Text("Large").tag("Large")
}
}
Section(header: Text("Notifications"), footer: Text("Manage how you receive updates")) {
Toggle("Enable Notifications", isOn: $notificationsEnabled)
if notificationsEnabled {
Toggle("Email Digests", isOn: .constant(true))
Toggle("Push Alerts", isOn: .constant(true))
}
}
Section(header: Text("Privacy")) {
Toggle("Share Analytics", isOn: $analyticsOptIn)
NavigationLink("Privacy Policy", destination: PrivacyView())
NavigationLink("Terms of Service", destination: TermsView())
}
}
.listStyle(.insetGrouped)
}
}Notice how we conditionally show the email and push toggles only when notifications are enabled. This keeps the interface clean and contextual. The footer text provides helpful guidance. The .insetGrouped list style works beautifully on all device sizes, adapting margins and separation automatically.
Sections also work seamlessly with edit mode and swipe actions, inheriting all their behaviors without extra configuration.
LazyVStack for custom scrolling layouts
Sometimes you need list-like scrolling behavior but with a custom layout that List can't provide. That's where LazyVStack (and its horizontal counterpart LazyHStack) shines. These containers only render visible rows, preserving performance while giving you full layout control.
In a ride-hailing app's driver history view, we needed to display a mixed layout: some rows are full-width trip summaries, others are card-style receipts. A standard List was too restrictive, so we switched to LazyVStack inside a ScrollView:
struct TripsView: View {
@State private var trips: [Trip] = loadTrips()
var body: some View {
ScrollView {
LazyVStack(spacing: 12, pinnedViews: [.sectionHeaders]) {
ForEach(tripsByDate.keys.sorted(by: >), id: \.self) { date in
Section(header: Text(dateFormatter.string(from: date))
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)) {
ForEach(tripsByDate[date] ?? []) { trip in
TripCardView(trip: trip)
.padding(.horizontal)
}
}
}
}
.padding(.vertical)
}
}
private var tripsByDate: [Date: [Trip]] {
Dictionary(grouping: trips) { Calendar.current.startOfDay(for: $0.date) }
}
}LazyVStack defers rendering of rows until they're visible in the viewport. This is crucial when you have hundreds or thousands of items: memory usage stays bounded even if your collection is enormous. The pinnedViews: [.sectionHeaders] parameter keeps headers sticky during scrolling, a feature many users expect.
The tradeoff is that you must explicitly manage scrolling, spacing, and padding rather than relying on List's opinionated defaults. For custom layouts, this flexibility is worth it. For standard lists, stick with List.
Common mistakes
- Using array indices as identifiers: Passing
id: \.selffor simple types or relying on array position breaks animations and causes incorrect updates when items move. Always use stable, unique identifiers. - Mutating state inside ForEach: Modifying a binding or state variable directly in a row's body can cause unpredictable re-renders. Instead, call a method on the parent view and let it manage state updates.
- Combining List with NavigationStack carelessly: List rows with navigation links can create confusing dual-selection states. Be explicit about whether a row itself navigates or if tapping actions within it do.
- Ignoring LazyVStack's rendering limitations: Unlike
List,LazyVStackdoesn't automatically handle keyboard navigation or accessibility focus management. Test thoroughly on assistive technologies. - Over-nesting Sections: While Sections are powerful, deeply nesting them (Section inside Section) isn't supported and leads to compiler errors. Keep hierarchy flat or use custom views instead.
Key takeaways
- Always conform data models to
Identifiablewith a stable, uniqueidproperty to ensure correct list behavior and smooth animations. - Use
swipeActionsfor contextual one-off operations andeditModewithonDeletefor bulk actions, choosing based on your app's workflow. - Organize lists with
Sectionheaders and footers to improve readability and provide user guidance without code complexity. - Reach for
LazyVStackinsideScrollViewonly when you need custom layouts or mixed row types; otherwise,Listhandles most production scenarios more elegantly. - Test list performance with realistic data volumes; lazy rendering and stable identifiers are the difference between smooth scrolling and janky interfaces.






Comments
Loading comments…