RSS Feed ↗
VOL. 1 • NO. 14 • IOSBUFFET.COM OCT 25, 2026
Inside Page — A7 Continued from Front
The Daily Recipe
Sep 29, 2026 • Page A7

Contacts-Style Alphabet Index

The A–Z scrubber down the side of a long list finally comes built in. Group, label, and let the reader jump straight to the letter they want.

By M. HOSSAIN — Staff Writer — Sep 29, 2026 1330 words
Recipe Card — No. 9
Serves
iOS 26+ · iPhone and iPad
Prep time
15 minutes
Kitchen-tested
Compiled with Xcode 26.6 · iOS 26.5 simulator · Oct 26, 2026
Difficulty
★☆☆
Ingredients
  • A sectioned List
  • .sectionIndexLabel(_:) on each Section
  • .listSectionIndexVisibility(_:)
  • Dictionary(grouping:by:)
  • .searchable(text:)

For years, the one thing that sent SwiftUI developers back to UITableView was the little column of letters down the right edge of Contacts. Tap “M” and you land on the Ms. SwiftUI never had it, so a long alphabetical list meant a lot of scrolling or a UIKit bridge. In iOS 26 it is a single modifier on each section.

This recipe builds a contacts list with the index, handles names that start with accents or digits, and hides the index while searching.

Method

1. Group the data into sections

The index points at sections, so start by grouping rows under the letter they belong to. Folding diacritics means “Élodie” files under E rather than getting a section of her own, and anything that doesn’t start with a letter goes under “#”, just as it does in Contacts.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
struct Contact: Identifiable {
    let id = UUID()
    let name: String
}

struct ContactSection: Identifiable {
    let letter: String
    let contacts: [Contact]
    var id: String { letter }
}

extension Array where Element == Contact {
    func sectioned() -> [ContactSection] {
        let groups = Dictionary(grouping: self) { contact -> String in
            let first = contact.name.prefix(1)
                .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
                .uppercased()
            return first.first?.isLetter == true ? first : "#"
        }
        return groups.keys
            .sorted { lhs, rhs in
                if lhs == "#" { return false }
                if rhs == "#" { return true }
                return lhs < rhs
            }
            .map { letter in
                ContactSection(
                    letter: letter,
                    contacts: groups[letter]!.sorted {
                        $0.name.localizedStandardCompare($1.name) == .orderedAscending
                    }
                )
            }
    }
}

2. Label each section

Now the new part. .sectionIndexLabel(_:) goes on the Section itself, and tells the list what to show for it in the index. The label is typically one character long.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
struct ContactsView: View {
    let contacts: [Contact]
    @State private var query = ""

    private var sections: [ContactSection] {
        let visible = query.isEmpty
            ? contacts
            : contacts.filter { $0.name.localizedStandardContains(query) }
        return visible.sectioned()
    }

    var body: some View {
        NavigationStack {
            List {
                ForEach(sections) { section in
                    Section(section.letter) {
                        ForEach(section.contacts) { contact in
                            Text(contact.name)
                        }
                    }
                    .sectionIndexLabel(Text(section.letter))
                }
            }
            .listStyle(.plain)
            .listSectionIndexVisibility(query.isEmpty ? .visible : .hidden)
            .navigationTitle("Contacts")
            .searchable(text: $query)
        }
    }
}

The index is visible by default as soon as any section has a label, so .listSectionIndexVisibility is optional. Here it hides the index while the reader is searching, when the results are short and the letters would only get in the way.

3. Keep a full A–Z, even for empty letters

With only a few dozen contacts, the index shows only the letters you have, which makes it jump around in height as data changes. Apple’s documentation describes the fix: give empty sections an index label but no header, and the index keeps its full shape without drawing empty headings in the list.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct AlphabetIndexFullView: View {
    let contacts: [Contact]
    private let alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ#".map(String.init)

    var body: some View {
        let sections = contacts.sectioned()

        NavigationStack {
            List {
                ForEach(alphabet, id: \.self) { letter in
                    let rows = sections.first { $0.letter == letter }?.contacts ?? []
                    if rows.isEmpty {
                        Section { }
                            .sectionIndexLabel(Text(letter))
                    } else {
                        Section(letter) {
                            ForEach(rows) { Text($0.name) }
                        }
                        .sectionIndexLabel(Text(letter))
                    }
                }
            }
            .listStyle(.plain)
            .navigationTitle("Contacts")
        }
    }
}

Plating

Load a few hundred names and the letters appear, stacked vertically, down the trailing edge of the list. Tap a letter and the list jumps straight to that section. Start typing in the search field and the index steps aside until the query is cleared.

Index
Index
Full A–Z
Full A–Z
iPad
iPad
FIG. 1 — The built-in section index, and the full A–Z variation where letters without contacts still appear.

Kitchen notes

  • Lists only. The index is a feature of List. A ScrollView with a LazyVStack won’t show it; use ScrollViewReader and your own buttons there.
  • Leave letters out on purpose. Only sections with a sectionIndexLabel appear in the index, so a “Favourites” section pinned at the top can stay out of the alphabet simply by not having a label.
  • Collation isn’t universal. Grouping by first letter suits Latin-script names. For Japanese, Chinese, or mixed-script contacts, group by a locale-appropriate key (reading, pinyin initial) and use that as the label instead.
  • Stable identity. ContactSection uses its letter as its id, so sections keep their identity as data changes and SwiftUI animates insertions instead of rebuilding the list.

— 30 —

— END OF EDITION 2026 — SET IN GARAMOND & COURIER ON NEWSPRINT STOCK — iOS BUFFET PRESS — RSS —