Reading time: 3 min

This recipe shows how to add section index with titles to a SwiftUI List. This will render a vertical list of custom shortcuts on the right-hand side of the list, allowing you to quickly navigate to any section by pressing or just moving your finger over it. The same functionality in UITableView is implemented with sectionIndexTitles(for:) and tableView(_:sectionForSectionIndexTitle:at:).

The end result looks like this:

preview

This recipe relies on the touch enter/exit detector. Be sure to check it out!

The component is implemented as a ViewModifier that attaches the section index to the right-hand side of the screen, above the list. (Actually, this works for any view wrapped in a ScrollViewReader, but List is the most common use-case.) It relies on a ScrollViewProxy to navigate to the target section and allows for custom views representing section shortcuts. Here's the code:

struct SectionIndex<ID, TitleContent>: ViewModifier where ID : Hashable, TitleContent : View {
  let proxy: ScrollViewProxy
  let sections: [ID]
  @ViewBuilder let titleContent: (ID, Bool) -> TitleContent

  @State private var selection: ID? = nil

  func body(content: Content) -> some View {
    ZStack {
      content
      // TouchEnterExitReader allows for tracking of finger movement over index shortcuts
      TouchEnterExitReader(ID.self,
                           onEnter: { id in
        selection = id
        withAnimation {
          proxy.scrollTo(id)
        }
      },
                           onExit: { id in
        selection = nil
      }) { touchEnterExitProxy in
        HStack {
          Spacer() // right-align the index
          VStack { // the index itself
            ForEach(sections, id: \.self) { section in
              titleContent(section, selection == section)
                .touchEnterExit(id: section, proxy: touchEnterExitProxy)
                .onTapGesture {
                  withAnimation {
                    proxy.scrollTo(section)
                  }
                }
              }
            }
          }
      }
    }
  }
}

extension View {
  func sectionIndex<ID, TitleContent>(proxy: ScrollViewProxy,
                                      sections: [ID],
                                      @ViewBuilder titleContent: @escaping (ID, Bool) -> TitleContent) -> some View
  where ID : Hashable, TitleContent : View {
    self.modifier(SectionIndex(proxy: proxy, sections: sections, titleContent: titleContent))
  }
}

If you're targeting SwiftUI 3 (iOS 15, macOS 12), you can improve the scrolling precision by supplying the anchor parameter to scrollTo:

proxy.scrollTo(section, anchor: .center) // only on SwiftUI 3
Alphabetical section index

The most common use case for the section index is to show the first letter of the section title as the shortcut, just like in the Contacts app:

extension View {
  func firstLetterSectionIndex(proxy: ScrollViewProxy, sections: [String]) -> some View {
    self.modifier(SectionIndex(proxy: proxy, sections: sections, titleContent: { title, isSelected in
      Text(title.prefix(1))
        .font(.system(size: isSelected ? 32 : 16))
        .fontWeight(isSelected ? .bold : .regular)
        .foregroundColor(.blue)
        .padding(.trailing, 3)
    }))
  }
}

Finally, here's a sample view to test that shows it in action:

struct SectionIndexTest: View {
  let contacts = [
    "A": ["Anne", "Alfred", "Allie"],
    "B": ["Beth", "Bert", "Bart"],
    "C": ["Chad"],
    "D": ["Don", "Dona", "Dierdre"],
    "E": ["Euphrasius", "Elmo"],
    "G": ["Gordan", "Gordon", "Goran"],
    "H": ["Herb", "Herbert", "Hertie"],
    "I": ["Ion"],
    "K": ["Kurt", "Kurtrus", "Kent"],
    "O": ["Oprah", "Oswald"],
    "P": ["Peter", "Percy", "Princess"],
    "R": ["Rand", "Ruth", "Rudy"],
    "S": ["Steve", "Stephen", "Stephanie"],
    "T": ["Tyrone", "Trevor", "Tundra"],
    "W": ["Wilfred", "Wynonna"],
  ]

  var body: some View {
    let sections = Array(contacts.keys.sorted())
    ScrollViewReader { proxy in
      List {
        ForEach(sections, id: \.self) { letter in
          Section(header: Text(letter)) {
            ForEach(contacts[letter]!, id: \.self) { contact in
              Text(contact)
            }
          }
          .id(letter) // necessary for ScrollViewProxy to work
        }
      }
      .firstLetterSectionIndex(proxy: proxy, sections: sections) // as simple as this
    }
  }
}

Next Post Previous Post