SwiftUI List Complete Guide: Move, Delete, Pin, and Custom Actions in 2025
From basic lists to advanced custom actions — master SwiftUI List creation with complete code examples and real-world patterns

🚀 Introduction
Look, I’ve been there. You’re building what seems like a simple list in SwiftUI, everything’s working fine, and then your PM drops the bomb: “Can users delete items? Oh, and we need them to pin favorites too.”
Suddenly your nice, clean List becomes this tangled mess of state management, custom actions, and "why isn't this animation working?" moments. Trust me, we've all been down that rabbit hole.
Here’s the thing though — SwiftUI’s List is actually pretty powerful once you know the patterns. Whether you’re building a todo app, a contacts list, or something more complex, there are specific ways to handle each type of interaction that’ll save you hours of debugging.
In this guide, we’re covering everything: basic list creation, built-in actions like move and delete, custom actions (including that pinning system your designer keeps asking for), and the performance tricks that’ll keep your lists smooth even with thousands of items.
📺 Complete video walkthrough coming soon to Swift Pal: **https://youtube.com/@swift-pal**
But first, let’s talk about something crucial — state management. Because here’s what I learned the hard way: you can have the most beautiful list in the world, but if your state management is wrong, those delete and move actions will either crash your app or behave in weird, unpredictable ways. Understanding @State, @Binding, and @ObservedObject isn't just helpful — it's essential for any interactive list.
📝 List Creation Fundamentals
Alright, let’s start with the basics — but not the “hello world” basics. I’m talking about the practical ways you’ll actually create lists in real projects.
The Classic Dynamic List
Most of the time, you’re working with data that changes. Here’s the pattern I use 90% of the time:
struct ContentView: View {
@State private var items = [
TodoItem(id: UUID(), title: "Learn SwiftUI Lists", isCompleted: false),
TodoItem(id: UUID(), title: "Build awesome app", isCompleted: false),
TodoItem(id: UUID(), title: "Ship to App Store", isCompleted: false)
]
var body: some View {
NavigationView {
List(items) { item in
HStack {
Image(systemName: item.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundColor(item.isCompleted ? .green : .gray)
Text(item.title)
Spacer()
}
.padding(.vertical, 4)
}
.navigationTitle("My Tasks")
}
}
}
struct TodoItem: Identifiable {
let id: UUID
let title: String
let isCompleted: Bool
}
Now here’s where it gets interesting — see that HStack inside our list row? That's where most developers start getting creative, and honestly, that's where things can go wrong fast. If you're building complex row layouts with multiple VStacks and HStacks, you'll want to check out the complete layout guide because understanding how these containers work together is crucial for list performance.
**SwiftUI Layout Guide: VStack, HStack, ZStack & Grids Explained (2025 Edition)** _Confused about SwiftUI layouts? 🤔 This complete 2025 guide breaks down VStack, HStack, ZStack, and Apple’s official…_swift-pal.comhttps://swift-pal.com/swiftui-layout-guide-vstack-hstack-zstack-grids-explained-2025-edition-285fb89b5de5
Static Lists (Yes, They Have Their Place)
Sometimes you just need a settings screen or a simple menu. Static lists are perfect for this:
struct SettingsView: View {
var body: some View {
List {
Section("Account") {
Label("Profile", systemImage: "person.circle")
Label("Notifications", systemImage: "bell")
Label("Privacy", systemImage: "lock")
}
Section("App") {
Label("About", systemImage: "info.circle")
Label("Help", systemImage: "questionmark.circle")
}
}
.navigationTitle("Settings")
}
}
The ForEach Pattern (When You Need More Control)
Here’s something that trips up a lot of developers — when should you use List(items) vs List { ForEach(items) }?
Use the ForEach pattern when you need to mix static and dynamic content:
struct MixedContentView: View {
@State private var recentItems = ["Document 1", "Document 2", "Document 3"]
var body: some View {
List {
Section("Quick Actions") {
Label("New Document", systemImage: "plus")
Label("Scan Document", systemImage: "camera")
}
Section("Recent") {
ForEach(recentItems, id: \.self) { item in
Label(item, systemImage: "doc.text")
}
}
}
}
}
The key difference? When you use List(items), the entire list is dynamic. With List { ForEach(...) }, you can mix static sections with dynamic content.
Custom Row Views (Keep It Clean)
As your rows get more complex, extract them into separate views. Trust me on this one — your future self will thank you:
struct TaskRowView: View {
let task: TodoItem
var body: some View {
HStack {
Button(action: toggleCompletion) {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundColor(task.isCompleted ? .green : .gray)
}
.buttonStyle(.plain)
VStack(alignment: .leading, spacing: 4) {
Text(task.title)
.strikethrough(task.isCompleted)
if let dueDate = task.dueDate {
Text(dueDate, style: .date)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
if task.isPinned {
Image(systemName: "pin.fill")
.foregroundColor(.orange)
}
}
.padding(.vertical, 2)
}
private func toggleCompletion() {
// We'll handle this with proper state management
}
}
Notice how I’m keeping the row logic separate? This makes testing easier and keeps your main view clean.
One more thing before we move on — if you’re coming from UIKit, you might be tempted to think of SwiftUI lists like UITableView. Don’t. They’re similar in purpose but completely different in implementation. SwiftUI lists are much more declarative, which means less boilerplate but a different mental model.
🔄 Built-in List Actions
Okay, here’s where things get fun. SwiftUI gives us some really nice built-in actions, but — and this is important — they only work well if your state management is solid. I’ve seen so many developers struggle with delete actions that don’t actually remove items, or move actions that revert back.
The secret? Understanding how SwiftUI tracks changes and updates the UI. If you haven’t nailed down @State, @Binding, and @ObservedObject yet, seriously, go read this state management guide first. I'll wait.
Got it? Cool, let’s build some interactive lists.
The Delete Action (It’s Easier Than You Think)
Here’s the basic delete implementation that actually works:
struct TodoListView: View {
@State private var tasks = [
TodoItem(id: UUID(), title: "Learn SwiftUI Lists", isCompleted: false),
TodoItem(id: UUID(), title: "Master delete actions", isCompleted: false),
TodoItem(id: UUID(), title: "Build something awesome", isCompleted: false)
]
var body: some View {
NavigationView {
List {
ForEach(tasks) { task in
TaskRowView(task: task)
}
.onDelete(perform: deleteTask)
}
.navigationTitle("My Tasks")
.toolbar {
EditButton()
}
}
}
private func deleteTask(at indexSet: IndexSet) {
tasks.remove(atOffsets: indexSet)
}
}
struct TodoItem: Identifiable {
let id: UUID
let title: String
let isCompleted: Bool
}
See that EditButton() in the toolbar? That's SwiftUI magic — it automatically toggles the list's edit mode. Users can either swipe to delete individual items or tap Edit and select multiple items.

But here’s where it gets tricky — what if you’re using an ObservableObject or @Observable for your data? The pattern changes slightly:
class TaskManager: ObservableObject {
@Published var tasks = [
TodoItem(id: UUID(), title: "Learn SwiftUI Lists", isCompleted: false),
TodoItem(id: UUID(), title: "Master delete actions", isCompleted: false),
TodoItem(id: UUID(), title: "Build something awesome", isCompleted: false)
]
func deleteTask(at indexSet: IndexSet) {
tasks.remove(atOffsets: indexSet)
}
}
struct TodoListView: View {
@StateObject private var taskManager = TaskManager()
var body: some View {
NavigationView {
List {
ForEach(taskManager.tasks) { task in
TaskRowView(task: task)
}
.onDelete(perform: taskManager.deleteTask)
}
.navigationTitle("My Tasks")
.toolbar {
EditButton()
}
}
}
}The key difference? We’re calling the delete method on our ObservableObject, which automatically triggers UI updates thanks to @Published.
Move Actions (Drag to Reorder)
Adding move functionality is just as straightforward:
struct TodoListView: View {
@StateObject private var taskManager = TaskManager()
var body: some View {
NavigationView {
List {
ForEach(taskManager.tasks) { task in
TaskRowView(task: task)
}
.onDelete(perform: taskManager.deleteTask)
.onMove(perform: taskManager.moveTask)
}
.navigationTitle("My Tasks")
.toolbar {
EditButton()
}
}
}
}
// Updated TaskManager
class TaskManager: ObservableObject {
@Published var tasks = [
TodoItem(id: UUID(), title: "Learn SwiftUI Lists", isCompleted: false),
TodoItem(id: UUID(), title: "Master delete actions", isCompleted: false),
TodoItem(id: UUID(), title: "Build something awesome", isCompleted: false)
]
func deleteTask(at indexSet: IndexSet) {
tasks.remove(atOffsets: indexSet)
}
func moveTask(from source: IndexSet, to destination: Int) {
tasks.move(fromOffsets: source, toOffset: destination)
}
}Now users can long-press and drag to reorder items. The EditButton() will show drag handles when in edit mode, but users can also drag without entering edit mode.
📌 Custom Actions (Pinning System)
Alright, here’s where we get into the fun stuff. Built-in delete and move actions are nice, but let’s be honest — your designer probably wants something more creative. A pinning system, favorite actions, archive buttons… the works.
Now, here’s the thing about custom list actions in SwiftUI — there are actually several ways to implement them, and the “right” approach depends on your specific needs. Let me show you the patterns that actually work in production.
Swipe Actions (The iOS-Native Way)
First up, SwiftUI’s swipeActions modifier. This feels most natural to iOS users because it behaves exactly like Mail, Messages, and other system apps:
struct TaskRowWithSwipeActions: View {
@ObservedObject var taskManager: TaskManager
let task: TodoItem
var body: some View {
HStack {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundColor(task.isCompleted ? .green : .gray)
VStack(alignment: .leading, spacing: 4) {
Text(task.title)
.strikethrough(task.isCompleted)
if let dueDate = task.dueDate {
Text(dueDate, style: .date)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
if task.isPinned {
Image(systemName: "pin.fill")
.foregroundColor(.orange)
.font(.caption)
}
}
.swipeActions(edge: .trailing) {
Button("Delete") {
taskManager.deleteTask(task)
}
.tint(.red)
Button(task.isPinned ? "Unpin" : "Pin") {
taskManager.togglePin(for: task)
}
.tint(.orange)
}
.swipeActions(edge: .leading) {
Button("Complete") {
taskManager.toggleCompletion(for: task)
}
.tint(.green)
}
}
}
// Updated TodoItem to support pinning
struct TodoItem: Identifiable {
let id: UUID
var title: String
var isCompleted: Bool
var isPinned: Bool = false
var dueDate: Date?
init(id: UUID = UUID(), title: String, isCompleted: Bool = false, isPinned: Bool = false, dueDate: Date? = nil) {
self.id = id
self.title = title
self.isCompleted = isCompleted
self.isPinned = isPinned
self.dueDate = dueDate
}
}And here’s the updated TaskManager to handle these new actions:
class TaskManager: ObservableObject {
@Published var tasks: [TodoItem] = [
TodoItem(title: "Learn SwiftUI Lists", isPinned: true),
TodoItem(title: "Master delete actions"),
TodoItem(title: "Build custom actions"),
TodoItem(title: "Ship awesome app", isPinned: true)
]
// Computed property to show pinned items first
var sortedTasks: [TodoItem] {
tasks.sorted { first, second in
if first.isPinned != second.isPinned {
return first.isPinned // Pinned items first
}
return false // Maintain original order for same-priority items
}
}
func deleteTask(at indexSet: IndexSet) {
tasks.remove(atOffsets: indexSet)
}
func deleteTask(_ task: TodoItem) {
tasks.removeAll { $0.id == task.id }
}
func moveTask(from source: IndexSet, to destination: Int) {
tasks.move(fromOffsets: source, toOffset: destination)
}
func togglePin(for task: TodoItem) {
if let index = tasks.firstIndex(where: { $0.id == task.id }) {
tasks[index].isPinned.toggle()
}
}
func toggleCompletion(for task: TodoItem) {
if let index = tasks.firstIndex(where: { $0.id == task.id }) {
tasks[index].isCompleted.toggle()
}
}
}Context Menu Actions (Right-Click/Long-Press)
Sometimes swipe actions aren’t enough. Context menus give you more space and feel more natural for complex actions:
struct TaskRowWithContextMenu: View {
@ObservedObject var taskManager: TaskManager
let task: TodoItem
var body: some View {
HStack {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundColor(task.isCompleted ? .green : .gray)
VStack(alignment: .leading, spacing: 4) {
Text(task.title)
.strikethrough(task.isCompleted)
if let dueDate = task.dueDate {
Text(dueDate, style: .date)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
if task.isPinned {
Image(systemName: "pin.fill")
.foregroundColor(.orange)
.font(.caption)
}
}
.contextMenu {
Button(action: { taskManager.toggleCompletion(for: task) }) {
Label(task.isCompleted ? "Mark Incomplete" : "Mark Complete",
systemImage: task.isCompleted ? "circle" : "checkmark.circle")
}
Button(action: { taskManager.togglePin(for: task) }) {
Label(task.isPinned ? "Unpin" : "Pin to Top",
systemImage: task.isPinned ? "pin.slash" : "pin")
}
Divider()
Button(action: { taskManager.duplicateTask(task) }) {
Label("Duplicate", systemImage: "doc.on.doc")
}
Button(action: { taskManager.deleteTask(task) }) {
Label("Delete", systemImage: "trash")
}
.foregroundColor(.red)
}
}
}Custom Button Actions (For Obvious Interactions)
Sometimes you want the action to be immediately visible. Custom buttons work great for this:
struct TaskRowWithButtons: View {
@ObservedObject var taskManager: TaskManager
let task: TodoItem
var body: some View {
HStack {
Button(action: { taskManager.toggleCompletion(for: task) }) {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundColor(task.isCompleted ? .green : .gray)
}
.buttonStyle(.plain)
VStack(alignment: .leading, spacing: 4) {
Text(task.title)
.strikethrough(task.isCompleted)
if let dueDate = task.dueDate {
Text(dueDate, style: .date)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
// Pin button that's always visible
Button(action: { taskManager.togglePin(for: task) }) {
Image(systemName: task.isPinned ? "pin.fill" : "pin")
.foregroundColor(task.isPinned ? .orange : .gray)
.font(.caption)
}
.buttonStyle(.plain)
}
.padding(.vertical, 2)
}
}Putting It All Together (The Complete List View)
Here’s how you’d use these custom rows in your main list:
struct CompleteTaskListView: View {
@StateObject private var taskManager = TaskManager()
@State private var isEditing = false
var body: some View {
NavigationView {
List {
// Show pinned items in a separate section
if !taskManager.pinnedTasks.isEmpty {
Section("Pinned") {
ForEach(taskManager.pinnedTasks) { task in
TaskRowWithSwipeActions(taskManager: taskManager, task: task)
}
}
}
// Regular tasks
Section(taskManager.pinnedTasks.isEmpty ? "Tasks" : "Other Tasks") {
ForEach(taskManager.unpinnedTasks) { task in
TaskRowWithSwipeActions(taskManager: taskManager, task: task)
}
.onDelete(perform: isEditing ? deleteUnpinnedTasks : nil)
.onMove(perform: isEditing ? moveUnpinnedTasks : nil)
}
}
.environment(\.editMode, .constant(isEditing ? .active : .inactive))
.navigationTitle("My Tasks")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(isEditing ? "Done" : "Edit") {
withAnimation {
isEditing.toggle()
}
}
}
ToolbarItem(placement: .navigationBarLeading) {
Button("Add Task") {
taskManager.addSampleTask()
}
}
}
}
}
private func deleteUnpinnedTasks(at indexSet: IndexSet) {
let unpinnedTasks = taskManager.unpinnedTasks
for index in indexSet {
if index < unpinnedTasks.count {
taskManager.deleteTask(unpinnedTasks[index])
}
}
}
private func moveUnpinnedTasks(from source: IndexSet, to destination: Int) {
// This gets tricky with sections - you might want to disable move for pinned items
// or implement a more sophisticated move system
}
}
// Extended TaskManager with section support
extension TaskManager {
var pinnedTasks: [TodoItem] {
tasks.filter { $0.isPinned }
}
var unpinnedTasks: [TodoItem] {
tasks.filter { !$0.isPinned }
}
func addSampleTask() {
let newTask = TodoItem(title: "New Task \(tasks.count + 1)")
tasks.append(newTask)
}
func duplicateTask(_ task: TodoItem) {
let duplicatedTask = TodoItem(
title: task.title + " (Copy)",
isCompleted: task.isCompleted,
isPinned: false, // Don't auto-pin duplicates
dueDate: task.dueDate
)
tasks.append(duplicatedTask)
}
}Pro Tips for Custom Actions
- Keep swipe actions to 2–3 maximum — More than that feels overwhelming
- Use consistent colors — Red for destructive, green for positive, orange/blue for neutral
- Consider discoverability — Not all users know about swipe actions or context menus
- Test on device — Swipe actions feel different on real hardware vs simulator
The pinning system is cool, but what if you want to make your lists really stand out? Let’s talk about advanced customization.
🎨 Advanced List Customization
Okay, so you’ve got your basic lists working, actions are solid, but now comes the fun part — making your lists actually look good. And by “look good,” I mean not looking like every other SwiftUI app out there.
Here’s the thing about list customization in SwiftUI — it’s gotten way better since iOS 14, but there are still some quirks. Some things are surprisingly easy, others… well, let’s just say you’ll be googling “how to remove list separator SwiftUI” more than once.
List Styles (Beyond the Defaults)
First, let’s talk about the built-in styles. Most developers stick with the default, but there are actually several options:
struct StyledListsView: View {
@State private var selectedStyle = 0
private let tasks = [
TodoItem(title: "Try different list styles"),
TodoItem(title: "Customize separators"),
TodoItem(title: "Add some flair")
]
var body: some View {
NavigationView {
VStack {
Picker("List Style", selection: $selectedStyle) {
Text("Default").tag(0)
Text("Grouped").tag(1)
Text("Inset Grouped").tag(2)
Text("Plain").tag(3)
}
.pickerStyle(.segmented)
.padding()
Group {
switch selectedStyle {
case 1:
listContent.listStyle(.grouped)
case 2:
listContent.listStyle(.insetGrouped)
case 3:
listContent.listStyle(.plain)
default:
listContent.listStyle(.automatic)
}
}
}
.navigationTitle("List Styles")
}
}
private var listContent: some View {
List {
Section("Today") {
ForEach(tasks) { task in
Label(task.title, systemImage: "circle")
}
}
Section("This Week") {
ForEach(tasks) { task in
Label(task.title, systemImage: "calendar")
}
}
}
}
}Custom Row Separators (Or Removing Them Entirely)
Now here’s where SwiftUI gets a bit… interesting. You’d think customizing separators would be straightforward, right? Well:
struct CustomSeparatorsView: View {
private let tasks = [
TodoItem(title: "Custom separator color"),
TodoItem(title: "No separators at all"),
TodoItem(title: "Custom dividers")
]
var body: some View {
NavigationView {
VStack(spacing: 8) {
// List with custom separator color
VStack(alignment: .leading, spacing: 0) {
Text("Custom Separator Color")
.font(.headline)
.padding(.horizontal)
List(tasks) { task in
Text(task.title)
.listRowSeparatorTint(.orange)
}
}
// List with no separators
VStack(alignment: .leading, spacing: 0) {
Text("No Separators")
.font(.headline)
.padding(.horizontal)
List(tasks) { task in
Text(task.title)
.listRowSeparator(.hidden)
}
}
// Custom dividers (more control)
VStack(alignment: .leading, spacing: 0) {
Text("Custom Dividers")
.font(.headline)
.padding(.horizontal)
List {
ForEach(Array(tasks.enumerated()), id: \.element.id) { index, task in
VStack(spacing: 0) {
HStack {
Text(task.title)
Spacer()
}
.padding(16)
if index < tasks.count - 1 {
Rectangle()
.fill(
LinearGradient(
colors: [.clear, .blue, .clear],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(height: 1)
}
}
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets())
}
}
}
Spacer()
}
.navigationTitle("Custom Separators")
}
}
}
Background Customization
Want to change the entire list background? Here’s how to do it properly:
struct CustomBackgroundListView: View {
private let tasks = [
TodoItem(title: "Custom backgrounds"),
TodoItem(title: "Gradient effects"),
TodoItem(title: "Card-like appearance")
]
var body: some View {
NavigationView {
ZStack {
// Custom background
LinearGradient(
colors: [.blue.opacity(0.1), .purple.opacity(0.1)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
List(tasks) { task in
HStack {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
Text(task.title)
Spacer()
}
.padding()
.background(
Rectangle()
.fill(
LinearGradient(
colors: [.clear, .blue, .clear],
startPoint: .leading,
endPoint: .trailing
)
)
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
}
.scrollContentBackground(.hidden) // This is key for custom backgrounds
.listStyle(.plain)
}
.navigationTitle("Custom Background")
}
}
}
That .scrollContentBackground(.hidden) modifier is crucial — without it, your custom background won't show through.
🧭 Navigation & Lists
Alright, here’s where lists really shine in real apps — navigation. Think about it: almost every navigation pattern in iOS starts with a list. Settings screens, contact lists, file browsers, you name it.
But here’s where it gets tricky — SwiftUI’s navigation has changed significantly over the years. If you’re still using NavigationView and basic NavigationLink, you're probably missing out on some really powerful features. And if you're building anything more complex than a simple master-detail flow, you'll definitely want to understand the newer navigation patterns covered in this complete navigation guide.
Basic List Navigation (The Foundation)
Let’s start with the classic pattern — a list that navigates to detail views:
struct BasicNavigationListView: View {
@StateObject private var taskManager = TaskManager()
var body: some View {
NavigationStack {
List(taskManager.tasks) { task in
NavigationLink(destination: TaskDetailView(task: task)) {
HStack {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundColor(task.isCompleted ? .green : .gray)
VStack(alignment: .leading, spacing: 4) {
Text(task.title)
.font(.headline)
if let dueDate = task.dueDate {
Text("Due: \(dueDate, formatter: DateFormatter.short)")
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
if task.isPinned {
Image(systemName: "pin.fill")
.foregroundColor(.orange)
.font(.caption)
}
}
}
}
.navigationTitle("My Tasks")
}
}
}
struct TaskDetailView: View {
let task: TodoItem
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text(task.title)
.font(.largeTitle)
.bold()
if let dueDate = task.dueDate {
Label("Due: \(dueDate, formatter: DateFormatter.medium)", systemImage: "calendar")
}
Label(task.isCompleted ? "Completed" : "Not completed",
systemImage: task.isCompleted ? "checkmark.circle.fill" : "circle")
if task.isPinned {
Label("Pinned to top", systemImage: "pin.fill")
.foregroundColor(.orange)
}
Spacer()
}
.padding()
.navigationTitle("Task Details")
.navigationBarTitleDisplayMode(.inline)
}
}
extension DateFormatter {
static let short: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
return formatter
}()
static let medium: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter
}()
}Programmatic Navigation (Taking Control)
Sometimes you need more control over navigation than a simple NavigationLink provides. Maybe you want to navigate after an API call, or conditionally navigate based on user permissions:
struct ProgrammaticNavigationListView: View {
@StateObject private var taskManager = TaskManager()
@State private var navigationPath = NavigationPath()
@State private var selectedTask: TodoItem?
var body: some View {
NavigationStack(path: $navigationPath) {
List(taskManager.tasks) { task in
Button(action: {
handleTaskTap(task)
}) {
HStack {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundColor(task.isCompleted ? .green : .gray)
Text(task.title)
.foregroundColor(.primary)
Spacer()
if task.isPinned {
Image(systemName: "pin.fill")
.foregroundColor(.orange)
.font(.caption)
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
.buttonStyle(.plain)
}
.navigationTitle("My Tasks")
.navigationDestination(for: TodoItem.self) { task in
TaskDetailView(task: task)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Settings") {
navigationPath.append("settings")
}
}
}
.navigationDestination(for: String.self) { destination in
if destination == "settings" {
SettingsView()
}
}
}
}
private func handleTaskTap(_ task: TodoItem) {
// You could add logic here - check permissions, validate state, etc.
if task.title.contains("Private") {
// Maybe show an alert instead of navigating
return
}
navigationPath.append(task)
}
}
struct SettingsView: View {
var body: some View {
List {
Section("Preferences") {
Label("Notifications", systemImage: "bell")
Label("Privacy", systemImage: "lock")
Label("About", systemImage: "info.circle")
}
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
}
}Master-Detail Navigation (iPad and Large Screens)
For iPad apps or apps that need to work well on larger screens, master-detail navigation is essential:
struct MasterDetailNavigationView: View {
@StateObject private var taskManager = TaskManager()
@State private var selectedTask: TodoItem?
var body: some View {
NavigationSplitView {
// Master view (sidebar)
List(taskManager.tasks, selection: $selectedTask) { task in
NavigationLink(value: task) {
HStack {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundColor(task.isCompleted ? .green : .gray)
VStack(alignment: .leading, spacing: 2) {
Text(task.title)
.font(.headline)
.lineLimit(1)
if let dueDate = task.dueDate {
Text(dueDate, style: .date)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
if task.isPinned {
Image(systemName: "pin.fill")
.foregroundColor(.orange)
.font(.caption)
}
}
}
}
.navigationTitle("Tasks")
} detail: {
// Detail view
if let selectedTask = selectedTask {
TaskDetailView(task: selectedTask)
} else {
ContentUnavailableView(
"Select a Task",
systemImage: "list.bullet",
description: Text("Choose a task from the sidebar to view its details")
)
}
}
}
}Search in Lists
No modern list is complete without search functionality:
struct SearchableListView: View {
@StateObject private var taskManager = TaskManager()
@State private var searchText = ""
var filteredTasks: [TodoItem] {
if searchText.isEmpty {
return taskManager.tasks
} else {
return taskManager.tasks.filter { task in
task.title.localizedCaseInsensitiveContains(searchText)
}
}
}
var body: some View {
NavigationStack {
List(filteredTasks) { task in
NavigationLink(destination: TaskDetailView(task: task)) {
HStack {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundColor(task.isCompleted ? .green : .gray)
Text(task.title)
Spacer()
if task.isPinned {
Image(systemName: "pin.fill")
.foregroundColor(.orange)
.font(.caption)
}
}
}
}
.navigationTitle("Tasks")
.searchable(text: $searchText, prompt: "Search tasks...")
.overlay {
if filteredTasks.isEmpty && !searchText.isEmpty {
ContentUnavailableView.search
}
}
}
}
}The key thing about navigation and lists is that they should feel natural to your users. iOS users expect certain behaviors — swipe back, clear navigation hierarchies, and consistent patterns. The new NavigationStack and NavigationSplitView APIs make this much easier than the old NavigationView approach.
🎯 Conclusion
So there you have it — pretty much everything you need to know about SwiftUI Lists in 2025. From basic creation patterns to custom pinning systems, swipe actions to navigation integration.
Here’s what we covered:
- Multiple ways to create lists — static, dynamic, and mixed content
- Built-in actions — delete and move with proper state management
- Custom actions — swipe actions, context menus, and always-visible buttons
- Advanced customization — styles, separators, backgrounds, and that polished look
- Navigation patterns — basic linking, programmatic control, master-detail, and search
The thing is, lists are everywhere in iOS apps. Master these patterns, and you’re basically set for 80% of the UI work you’ll do. Whether you’re building a simple todo app or a complex data management system, these fundamentals will serve you well.
A couple of final thoughts:
- Start simple — Get the basic functionality working before adding fancy animations
- Think about state management early — It’ll save you headaches later
- Test on real devices — Lists feel different on actual hardware
- Consider accessibility — VoiceOver users need lists to work well too
📺 Want to see all of this in action? I’m putting together a comprehensive video tutorial covering every pattern in this article. Subscribe to Swift Pal: https://youtube.com/@swift-pal to catch it when it goes live!
The SwiftUI List component has come a long way since iOS 13. It’s not perfect — there are still some UIKit things that are easier — but for most use cases, it’s incredibly powerful and much less code than the old UITableView approach.
What are you planning to build with lists? Drop a comment and let me know — I’m always curious to see how developers are using these patterns in real projects.
Now go build something awesome! 🚀
🎉 Enjoyed this article? Your support means the world to me!
💬 Drop a comment below! I love hearing about your experiences and answering questions
🎬 Subscribe on Youtube and become early subscribers of my channel: https://www.youtube.com/@swift-pal
💼 Let’s connect on LinkedIn for more professional insights: https://www.linkedin.com/in/karan-pal
Happy coding! 🚀
New articles, straight to your inbox.
No spam, no filler — just new writing on iOS, the web, and AI when it ships. Unsubscribe anytime.
Keep reading
Why I'm Rebuilding My Blog From Scratch (and Leaving Medium)
After years of publishing on someone else's platform, I'm moving my writing to a home I actually own. Here's the reasoning, and what I'm building instead.
ReadData Is the Model: The Most Ignored Part of AI
A beginner-friendly guide to why data quality beats model hype.
Read