Wesley de Groot's Blog
SwiftUI Lists

Back

Discover List in SwiftUI, a powerful way to display collections of data in a scrollable format. In this post, we will explore how to create lists, customize their appearance, and handle user interactions.

What is List?

List is a container that presents rows of data in a single-column layout. It can display static or dynamic content and supports various customization options, making it a versatile choice for presenting data in your SwiftUI applications.

Built-in List Styles

SwiftUI provides several built-in styles for lists, allowing you to change their appearance and behavior.

List Style Description
.plain A minimal style with no separators or grouping. Great for clean designs.
.grouped Groups items into visually distinct sections, often used in settings views.
.insetGrouped Like grouped, but with inset padding on larger screens (iPad, macOS).
.sidebar Ideal for navigation sidebars. Collapsible groups are supported.
.inset Provides subtle padding and separator lines.
.automatic Lets the system choose the best style for the context.

I like to use the .grouped style for most of my lists, as it provides a good balance between aesthetics and usability.

Creating a Basic List

To create a basic list, you can use the List view with an array of data. Here's a simple example:

import SwiftUI

struct ContentView: View {
    let items = ["Apple", "Banana", "Cherry", "Date", "Elderberry"]

    var body: some View {
        List(items, id: \.self) { item in
            Text(item)
        }
        .navigationTitle("Fruits")
    }
}

This code creates a list of fruits, where each item is displayed as a Text view. The id: \.self parameter indicates that each item in the array is unique and can be used as an identifier.

Customizing List Appearance

You can customize the appearance of a list by applying modifiers. For example, you can change the font, add images, or apply background colors to each row:

import SwiftUI

struct ContentView: View {
    let items = ["Apple", "Banana", "Cherry", "Date", "Elderberry"]

    var body: some View {
        List(items, id: \.self) { item in
            Text(item)
                .font(.headline)
                .padding()
                .background(Color.yellow)
                .cornerRadius(8)
        }
        .navigationTitle("Fruits")
    }
}

This example applies a headline font, padding, a yellow background, and rounded corners to each item in the list.

Handling User Interactions

You can handle user interactions with list items by adding gestures or navigation links. For example, you
can navigate to a detail view when an item is tapped:

import SwiftUI
struct ContentView: View {
    let items = ["Apple", "Banana", "Cherry", "Date", "Elderberry"]

    var body: some View {
        NavigationView {
            List(items, id: \.self) { item in
                NavigationLink(destination: Text("Details for \(item)")) {
                    Text(item)
                }
            }
            .navigationTitle("Fruits")
        }
    }
}

In this example, tapping on an item navigates to a detail view that displays the selected fruit's name.

Customizing List Rows

You can create custom row views by defining a separate view for each row. This allows you to create complex layouts within each list item. Here's an example of a custom row view:

import SwiftUI

struct FruitRow: View {
    let fruit: String

    var body: some View {
        HStack {
            Text(fruit)
                .font(.headline)
            Spacer()
            Image(systemName: "chevron.right")
        }
        .padding()
    }
}
struct ContentView: View {
    let items = ["Apple", "Banana", "Cherry", "Date", "Elderberry"]

    var body: some View {
        NavigationView {
            List(items, id: \.self) { item in
                FruitRow(fruit: item)
            }
            .navigationTitle("Fruits")
        }
    }
}

In this example, the FruitRow view is used to create a custom row layout with a horizontal stack (HStack) that displays the fruit name and a chevron icon on the right side.

List Modifiers

SwiftUI provides several modifiers that you can apply to lists to customize their behavior and appearance. Here are some commonly used modifiers:

Here's an example of using some of these modifiers:

import SwiftUI

struct ContentView: View {
    @State private var items = ["Apple", "Banana", "Cherry", "Date", "Elderberry"]

    var body: some View {
        NavigationView {
            List {
                ForEach(items, id: \.self) { item in
                    Text(item)
                }
                .onDelete(perform: deleteItems)
                .onMove(perform: moveItems)
            }
            .navigationTitle("Fruits")
            .navigationBarItems(
                leading: EditButton(),
                trailing: Button(action: addItem) {
                    Image(systemName: "plus")
                }
            )
        }
    }

    private func deleteItems(at offsets: IndexSet) {
        items.remove(atOffsets: offsets)
    }

    private func moveItems(from source: IndexSet, to destination: Int) {
        items.move(fromOffsets: source, toOffset: destination)
    }

    private func addItem() {
        items.append("New Fruit \(items.count + 1)")
    }
}

This example demonstrates how to enable swipe-to-delete and drag-and-drop reordering of list items, as well as adding a button to add new items to the list.

Bonus: Indexed lists (SwiftUI)

In the code below, we will create an IndexedList view that displays a list of items grouped by their initial letter. This allows for quick navigation to sections of the list by tapping on the initial letters displayed on the right side of the view.

I've added a custom implementation of IndexedList that allows you to create a list with indexed letters on the right side, similar to the Contacts app on iOS. This implementation uses SwiftUI's List and ScrollViewReader to create a scrollable list with sections.

There are other ways to implement this, for example using a hosted UICollectionView or UITableView, but this implementation is purely in SwiftUI and leverages the power of SwiftUI's declarative syntax.

//
//  IndexedList.swift
//  SwiftExtras
//
//  Created by Wesley de Groot on 2025-07-22.
//  https://wesleydegroot.nl
//
//  https://github.com/0xWDG/SwiftExtras
//  MIT License
//

// This works in SwiftUI for both macOS and iOS, allowing you to create a list with indexed letters on the right side.
#if canImport(SwiftUI) && (os(macOS) || os(iOS))
import SwiftUI

/// IndexedList is a view that displays a list of items grouped by their initial letter.
/// It allows for quick navigation to sections of the list \
/// by tapping on the initial letters displayed on the right side of the view.
/// The list is scrollable and each item can be customized using a cell builder closure.
public struct IndexedList<Cell: View>: View {
    /// The data to be displayed in the list, grouped by initial letter.
    private let listData: [String: [String]]

    /// A closure that builds the view for each cell in the list.
    /// It takes a string (the item name) and returns a view of type `Cell
    private let cellBuilder: (String) -> Cell

    /// Initializes an IndexedList with a list of strings.
    /// The strings are grouped by their initial letter, \
    /// and each item is displayed using the provided cell builder closure.
    /// - Parameters:
    ///   - data: An array of strings to be displayed in the list.
    ///   - rowContent: A closure that takes a string and returns a \
    ///     view of type `Cell` to be used as the content for each row in the list.
    /// - Example:
    /// ```swift
    /// IndexedList(data: ["Apple", "Banana", "Cherry"]) { name in
    ///     Text(name)
    /// }
    /// ```
    public init(
        data: [String],
        @ViewBuilder rowContent: @escaping (String) -> Cell
    ) {
        // Grouping the data by the first letter of each string, converting it to uppercase
        // This creates a dictionary where the keys are the first letters and the values are arrays of strings
        // This allows for quick access to items based on their initial letter
        // The keys are sorted alphabetically to maintain a consistent order in the list
        listData = Dictionary(grouping: data) { name in
            String(name.prefix(1)).uppercased()
        }

        // Assigning the provided rowContent closure to the cellBuilder property
        // This closure will be used to build the view for each cell in the list
        self.cellBuilder = rowContent
    }

    /// The current index that is being hovered over or tapped.
    @State private var currentIndex: String?

    /// A list of index key frames used for determining the position of each indexed letter.
    /// This is used to handle hover and drag gestures for scrolling.
    @State private var indexKeyFrames: [IndexKeyInfo] = []

    /// The body of the IndexedList view.
    public var body: some View {
        // Sorting the keys of the listData dictionary to maintain a consistent order in the list
        // This ensures that the sections are displayed in alphabetical order based on the initial letter
        let sortedKeys = Array(listData.keys).sorted()

        // Using ScrollViewReader to allow programmatic scrolling to sections
        ScrollViewReader { proxy in
            // The main ZStack contains the background color, the List, and the indexed letter column
            ZStack(alignment: .topTrailing) {
                // MARK: - Background Color
                // Dynamic background color based on the platform
                bgColor

                // MARK: - List
                // List displaying the items grouped by their initial letter
                List {
                    // Using ForEach to iterate over the sorted keys of the listData dictionary
                    ForEach(sortedKeys, id: \.self) { key in
                        // Unwrapping the items for the current key
                        if let items = listData[key] {
                            // For each key, we create a section with the header as the key    
                            Section(header: Text(key).id(key)) {
                                // Using ForEach to iterate over the items in each section
                                ForEach(items, id: \.self) { name in
                                    // Building the cell using the provided cell builder closure
                                    cellBuilder(name)
                                }
                            }
                        }
                    }
                }
                // Add extra padding to the list to avoid clipping the indexed letter column
                .padding(.trailing, 10)
                // Hide the scroll indicators for a cleaner look
                .scrollIndicators(.hidden)

                // MARK: - Indexed Letter Column
                // Using a GeometryReader to create a vertical column of indexed letters
                // This column allows users to quickly navigate to sections of the list
                GeometryReader { geo in
                    // A vertical stack containing the indexed letters
                    VStack(spacing: 8) {
                        // Using ForEach to iterate over the sorted keys
                        // Each key is displayed as a tappable text view
                        ForEach(sortedKeys, id: \.self) { key in
                            // Displaying the key as a tappable text view
                            Text(key)
                                // Add a small font
                                .font(.caption2)
                                // Add accent color
                                .foregroundStyle(Color.accentColor)
                                // Add some padding
                                .padding(2)
                                // On tap gesture to scroll to the section corresponding to the key
                                .onTapGesture {
                                    proxy.scrollTo(key, anchor: .top)
                                }
                                // Zoom when hovering over the indexed letter
                                .scaleEffect(currentIndex == key ? 2.0 : 1.0)
                                // Adding a background to add the GeometryReader
                                .background(
                                    // Using GeometryReader to capture the frame of each index key
                                    // For enabling scrubbing on iOS
                                    GeometryReader { proxy in
                                        Color.clear.preference(
                                            key: IndexKeyPreferenceKey.self,
                                            value: [
                                                IndexKeyInfo(
                                                    key: key,
                                                    frame: proxy.frame(in: .global)
                                                )
                                            ]
                                        )
                                    }
                                )
                                // Accessibility traits to make it clear that this is a link
                                .accessibilityAddTraits(.isLink)
                                // Adding hover effect for macOS
                                .onHover { hovering in
                                    if hovering {
                                        currentIndex = key
                                        proxy.scrollTo(key, anchor: .top)
                                    }
                                }
                        }
                    }
                    // Add some trailing padding to the indexed letter column, \
                    // to avoid the text being too close to the edge
                    .padding(.trailing, 8)
                    // Make the height 100% of the available space, \
                    // so it will be centered vertically
                    .frame(maxHeight: .infinity)
                    // Add a drag gesture to allow scrolling through the index
                    // This is particularly useful for iOS where scrubbing is common
                    // The drag gesture updates the current index based on the location of the drag
                    // and scrolls the list to the corresponding section
                    .gesture(
                        // Drag gesture to allow scrolling through the index
                        // For enabling scrubbing on iOS
                        DragGesture(minimumDistance: 1)
                            .onChanged { value in
                                // Check if the current index is nil or if it has changed
                                if let match = indexForLocation(
                                    value.location,
                                    in: geo.frame(in: .global),
                                    keyFrames: indexKeyFrames
                                ) {
                                    // If the current index is nil or has changed, update it and scroll to the section
                                    if currentIndex != match {
                                        currentIndex = match
                                        proxy.scrollTo(match, anchor: .top)
                                    }
                                }
                            }
                            .onEnded { _ in
                                // Reset the current index when the gesture ends
                                currentIndex = nil
                            }
                    )
                }
                // Add a preference key to capture the frames of the indexed letters
                .onPreferenceChange(IndexKeyPreferenceKey.self) { values in
                    self.indexKeyFrames = values
                }
                // Set the frame width of the indexed letter column
                // This ensures that the column has a fixed width for consistency
                // and to avoid layout issues
                .frame(width: 24)
            }
        }
    }

    /// A background color for the IndexedList view.
    /// This color adapts to the platform.
    /// - Returns: A `Color` that represents the background color of the IndexedList view.
    private var bgColor: some View {
#if os(macOS)
        Color(NSColor.windowBackgroundColor)
#else
        Color(UIColor.systemGroupedBackground)
#endif
    }

    /// Determines the index key for a given location in the indexed letter column.
    /// This function checks if the location falls within the frame of any indexed letter.
    /// - Parameters:
    ///   - location: The CGPoint representing the location of the gesture.
    ///   - container: The CGRect representing the frame of the indexed letter column.
    ///   - keyFrames: An array of `IndexKeyInfo` containing the keys and their corresponding frames.
    /// - Returns: The key of the indexed letter that corresponds to the location, or `nil` if no match is found.
    private func indexForLocation(
        _ location: CGPoint,
        in container: CGRect,
        keyFrames: [IndexKeyInfo]
    ) -> String? {
        // Check if the location is within the bounds of the indexed letter column
        for info in keyFrames where info.frame.contains(CGPoint(x: container.midX, y: location.y)) {
            return info.key
        }

        return nil
    }
}

// MARK: - IndexKeyInfo and PreferenceKey
/// A struct that holds information about an indexed letter key and its frame.
/// This is used to capture the frames of the indexed letters for scrubbing functionality.
/// It conforms to `Equatable` to allow comparison between instances.
struct IndexKeyInfo: Equatable {
    let key: String
    let frame: CGRect
}

/// A preference key that collects `IndexKeyInfo` instances.
/// This is used to store the frames of the indexed letters in the IndexedList view.
/// It conforms to `PreferenceKey` to allow the IndexedList view to update its state based \
/// on the frames of the indexed letters.
struct IndexKeyPreferenceKey: PreferenceKey {
    static var defaultValue: [IndexKeyInfo] = []
    static func reduce(value: inout [IndexKeyInfo], nextValue: () -> [IndexKeyInfo]) {
        value.append(contentsOf: nextValue())
    }
}

// MARK: - Preview
// To Preview the IndexedList in SwiftUI previews, you can use the following code snippet:
#Preview {
    IndexedList(data: [
        "Wesley", "Carlo", "Uneata", "Anne", "Bram", "Sanne", "Daan", "Lieke", "Milan",
        "Femke", "Joris", "Tess", "Thijs", "Noa", "Sem", "Lars", "Lotte", "Max", "Eva",
        "Luuk", "Nina", "Finn", "Roos", "Janneke", "Gijs", "Isa", "Koen", "Sofie", "Tijn",
        "Maud", "Ruben", "Evi", "Siem", "Luca", "Bo", "Jelle", "Fleur", "Mees", "Yara",
        "Pim", "Elin", "Stijn", "Mare", "Noud", "Saar", "Tim", "Bente", "Jochem", "Ilse",
        "Pepijn", "Marit", "Teun", "Milou", "Jip", "Jet", "Bas", "Anouk", "Timo", "Veerle",
        "Floris", "Lana", "Jens", "Mila", "Job", "Loes", "Cas", "Tirza", "Nick", "Fenna",
        "Hidde", "Lynn", "Dirk", "Jade", "Mark", "Iris", "Bart", "Elise", "Wout", "Norah",
        "Maarten", "Nora", "Kees", "Tessa", "Rik", "Amber", "Nathan", "Vera", "Roel", "Zara",
        "Jan", "Esmee", "Tom", "Britt", "Stef", "Demi", "Arjen", "Floor", "Johan", "Liv",
        "Harm", "Romy", "Martijn", "Suze", "Kees"
    ]) { name in
        Text(name)
    }
}
#endif

Conclusion

You can easily create lists in SwiftUI using the List view. With its built-in styles and customization options, you can present data in a visually appealing and interactive way. Whether you're displaying simple text or complex custom views, List provides a powerful tool for building user interfaces in SwiftUI.

Read more

Share


Share Bluesky Mastodon Twitter LinkedIn Facebook
x-twitter mastodon github linkedin discord threads instagram whatsapp bluesky square-rss sitemap