29

Is it possible to have a List with an index on the right hand side, like the example below in SwiftUI?

Example

5
  • If Apple can do it, it is possible. They almost always use code available to the public. The code you need is likely a class somewhere. Check out the documentation at Apple.com Commented Nov 11, 2019 at 22:13
  • It seems possible in UIKit, but not in SwiftUI directly? Commented Nov 11, 2019 at 22:17
  • I see. I think the same might be true, but perhaps a little obscure Commented Nov 11, 2019 at 22:22
  • Have you found an answer? That letter on the right side is called UILocalized​Indexed​Collation, but it seems like it's available in UIKit only and there's no way to make something like that in pure SwiftUI. At least, I haven't found any solution Commented Jan 20, 2020 at 13:55
  • Has anyone got this to work yet in SwiftUI? Commented Mar 19, 2020 at 0:15

11 Answers 11

18

I did this in SwiftUI

//
//  Contacts.swift
//  TestCalendar
//
//  Created by Christopher Riner on 9/11/20.
//

import SwiftUI

struct Contact: Identifiable, Comparable {
    static func < (lhs: Contact, rhs: Contact) -> Bool {
        return (lhs.lastName, lhs.firstName) < (rhs.lastName, rhs.firstName)
    }
    
    var id = UUID()
    let firstName: String
    let lastName: String
}

let alphabet = ["A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z"]

struct Contacts: View {
    @State private var searchText = ""
    
    var contacts = [Contact]()
    
    var body: some View {
        VStack {
            ScrollViewReader { scrollProxy in
                ZStack {
                    List {
                        SearchBar(searchText: $searchText)
                            .padding(EdgeInsets(top: 0, leading: -20, bottom: 0, trailing: -20))
                        ForEach(alphabet, id: \.self) { letter in
                            Section(header: Text(letter).id(letter)) {
                                ForEach(contacts.filter({ (contact) -> Bool in
                                    contact.lastName.prefix(1) == letter
                                })) { contact in
                                    HStack {
                                        Image(systemName: "person.circle.fill").font(.largeTitle).padding(.trailing, 5)
                                        Text(contact.firstName)
                                        Text(contact.lastName)
                                    }
                                }
                            }
                        }
                    }
                    .navigationTitle("Contacts")
                    .listStyle(PlainListStyle())
                    .resignKeyboardOnDragGesture()
                   
                    VStack {
                        ForEach(alphabet, id: \.self) { letter in
                            HStack {
                                Spacer()
                                Button(action: {
                                    print("letter = \(letter)")
                                    //need to figure out if there is a name in this section before I allow scrollto or it will crash
                                    if contacts.first(where: { $0.lastName.prefix(1) == letter }) != nil {
                                        withAnimation {
                                            scrollProxy.scrollTo(letter)
                                        }
                                    }
                                }, label: {
                                    Text(letter)
                                        .font(.system(size: 12))
                                        .padding(.trailing, 7)
                                })
                            }
                        }
                    }
                }
            }
        }
    }
    
    init() {
        contacts.append(Contact(firstName: "Chris", lastName: "Ryan"))
        contacts.append(Contact(firstName: "Allyson", lastName: "Ryan"))
        contacts.append(Contact(firstName: "Jonathan", lastName: "Ryan"))
        contacts.append(Contact(firstName: "Brendan", lastName: "Ryaan"))
        contacts.append(Contact(firstName: "Jaxon", lastName: "Riner"))
        contacts.append(Contact(firstName: "Leif", lastName: "Adams"))
        contacts.append(Contact(firstName: "Frank", lastName: "Conors"))
        contacts.append(Contact(firstName: "Allyssa", lastName: "Bishop"))
        contacts.append(Contact(firstName: "Justin", lastName: "Bishop"))
        contacts.append(Contact(firstName: "Johnny", lastName: "Appleseed"))
        contacts.append(Contact(firstName: "George", lastName: "Washingotn"))
        contacts.append(Contact(firstName: "Abraham", lastName: "Lincoln"))
        contacts.append(Contact(firstName: "Steve", lastName: "Jobs"))
        contacts.append(Contact(firstName: "Steve", lastName: "Woz"))
        contacts.append(Contact(firstName: "Bill", lastName: "Gates"))
        contacts.append(Contact(firstName: "Donald", lastName: "Trump"))
        contacts.append(Contact(firstName: "Darth", lastName: "Vader"))
        contacts.append(Contact(firstName: "Clark", lastName: "Kent"))
        contacts.append(Contact(firstName: "Bruce", lastName: "Wayne"))
        contacts.append(Contact(firstName: "John", lastName: "Doe"))
        contacts.append(Contact(firstName: "Jane", lastName: "Doe"))
        contacts.sort()
    }
}

struct Contacts_Previews: PreviewProvider {
    static var previews: some View {
        Contacts()
    }
}
Sign up to request clarification or add additional context in comments.

8 Comments

Nice Job! Deceptively simple. If you filter your alphabet list to only have the letters in actual use you eliminate any scrolling issues caused by typing on a letter with no entries. This should be the accepted answer.
Really nice job! I wonder if there's a way to get the functionality from Obj-C UITableView, where I don't have to click Button by Button = letter by letter, but I can press once and drag / move over the other letters to let it scroll.
@KarstenS. Check my answer here: stackoverflow.com/a/66068838/8742572
Yes, implemented it that way now after I figured this out. Great job! Thanks a lot.
@directx what is resignKeyboardOnDragGesture()
|
14

Have a look at this tutorial by Federico Zanetello, it's a 100% SwiftUI solution.

Final Result: enter image description here

Full Code (BY: Federico Zanetello):

let database: [String: [String]] = [
  "iPhone": [
    "iPhone", "iPhone 3G", "iPhone 3GS", "iPhone 4", "iPhone 4S", "iPhone 5", "iPhone 5C", "iPhone 5S", "iPhone 6", "iPhone 6 Plus", "iPhone 6S", "iPhone 6S Plus", "iPhone SE", "iPhone 7", "iPhone 7 Plus", "iPhone 8", "iPhone 8 Plus", "iPhone X", "iPhone Xs", "iPhone Xs Max", "iPhone Xʀ", "iPhone 11", "iPhone 11 Pro", "iPhone 11 Pro Max", "iPhone SE 2"
  ],
  "iPad": [
    "iPad", "iPad 2", "iPad 3", "iPad 4", "iPad 5", "iPad 6", "iPad 7", "iPad Air", "iPad Air 2", "iPad Air 3", "iPad Mini", "iPad Mini 2", "iPad Mini 3", "iPad Mini 4", "iPad Mini 5", "iPad Pro 9.7-inch", "iPad Pro 10.5-inch", "iPad Pro 11-inch", "iPad Pro 11-inch 2", "iPad Pro 12.9-inch", "iPad Pro 12.9-inch 2", "iPad Pro 12.9-inch 3", "iPad Pro 12.9-inch 4"
  ],
  "iPod": [
    "iPod Touch", "iPod Touch 2", "iPod Touch 3", "iPod Touch 4", "iPod Touch 5", "iPod Touch 6"
  ],
  "Apple TV": [
    "Apple TV 2", "Apple TV 3", "Apple TV 4", "Apple TV 4K"
  ],
  "Apple Watch": [
    "Apple Watch", "Apple Watch Series 1", "Apple Watch Series 2", "Apple Watch Series 3", "Apple Watch Series 4", "Apple Watch Series 5"
  ],
  "HomePod": [
    "HomePod"
  ]
]

struct HeaderView: View {
  let title: String

  var body: some View {
    Text(title)
      .font(.title)
      .fontWeight(.bold)
      .padding()
      .frame(maxWidth: .infinity, alignment: .leading)
  }
}

struct RowView: View {
  let text: String

  var body: some View {
    Text(text)
      .padding()
      .frame(maxWidth: .infinity, alignment: .leading)
  }
}

struct ContentView: View {
  let devices: [String: [String]] = database

  var body: some View {
    ScrollViewReader { proxy in
      ScrollView {
        LazyVStack {
          devicesList
        }
      }
      .overlay(sectionIndexTitles(proxy: proxy))
    }
    .navigationBarTitle("Apple Devices")
  }

  var devicesList: some View {
    ForEach(devices.sorted(by: { (lhs, rhs) -> Bool in
      lhs.key < rhs.key
    }), id: \.key) { categoryName, devicesArray in
      Section(
        header: HeaderView(title: categoryName)
      ) {
        ForEach(devicesArray, id: \.self) { name in
          RowView(text: name)
        }
      }
    }
  }

  func sectionIndexTitles(proxy: ScrollViewProxy) -> some View {
    SectionIndexTitles(proxy: proxy, titles: devices.keys.sorted())
      .frame(maxWidth: .infinity, alignment: .trailing)
      .padding()
  }
}

struct SectionIndexTitles: View {
  let proxy: ScrollViewProxy
  let titles: [String]
  @GestureState private var dragLocation: CGPoint = .zero

  var body: some View {
    VStack {
      ForEach(titles, id: \.self) { title in
        SectionIndexTitle(image: sfSymbol(for: title))
          .background(dragObserver(title: title))
      }
    }
    .gesture(
      DragGesture(minimumDistance: 0, coordinateSpace: .global)
        .updating($dragLocation) { value, state, _ in
          state = value.location
        }
    )
  }

  func dragObserver(title: String) -> some View {
    GeometryReader { geometry in
      dragObserver(geometry: geometry, title: title)
    }
  }

  func dragObserver(geometry: GeometryProxy, title: String) -> some View {
    if geometry.frame(in: .global).contains(dragLocation) {
      DispatchQueue.main.async {
        proxy.scrollTo(title, anchor: .center)
      }
    }
    return Rectangle().fill(Color.clear)
  }

  func sfSymbol(for deviceCategory: String) -> Image {
    let systemName: String
    switch deviceCategory {
    case "iPhone": systemName = "iphone"
    case "iPad": systemName = "ipad"
    case "iPod": systemName = "ipod"
    case "Apple TV": systemName = "appletv"
    case "Apple Watch": systemName = "applewatch"
    case "HomePod": systemName = "homepod"
    default: systemName = "xmark"
    }
    return Image(systemName: systemName)
  }
}

struct SectionIndexTitle: View {
  let image: Image

  var body: some View {
    RoundedRectangle(cornerRadius: 8, style: .continuous)
      .foregroundColor(Color.gray.opacity(0.1))
      .frame(width: 40, height: 40)
      .overlay(
        image
          .foregroundColor(.blue)
      )
  }
}

1 Comment

the issue with this code is that it will trigger many calls to 'scrollTo' as you drag across the same element. There should ideally be a way to track the last title that triggered a scroll action.
3

I was looking for a solution to the same question , but it currently the only option that we might have right now is using UITableView as View.

import SwiftUI
import UIKit

struct TableView: UIViewRepresentable {

    func makeUIView(context: Context) -> UITableView {
        let tableView =  UITableView(frame: .zero, style: .plain)
        tableView.delegate = context.coordinator
        tableView.dataSource  = context.coordinator
        return tableView
    }

    func updateUIView(_ uiView: UITableView, context: Context) {

    }

    func makeCoordinator() -> Coordinator {
        Coordinator()
    }

    final class Coordinator: NSObject, UITableViewDataSource, UITableViewDelegate {
        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            2
        }

        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            let cellId = "cellIdentifier"
            let cell = tableView.dequeueReusableCell(withIdentifier: cellId) ?? UITableViewCell(style: .default, reuseIdentifier: cellId)

            cell.textLabel?.text = "\(indexPath)"
            return cell
        }

        func sectionIndexTitles(for tableView: UITableView) -> [String]? {
            ["a", "b"]
        }
    }

enter image description here

1 Comment

If you care for architecture whatsoever, SwiftUI framework should not be linked with UIKit (unless obviously there is not a possible implementation).
3

See the solution provided on this page by DirectX and please consider giving it an upvote. It is the correct answer.

I've taken his View and created a ViewModifier you can use with any view that consists of a SwiftUI List with Sections (tableview).

Just be sure to provide a list of header (section) titles that corresponds to the headers in the view you are adding the index to. Click on the letter to scroll to that section of the list. Notice that I only provide the indexes I can actually scroll to when calling the view modifier.

Use like any view modifier:

SimpleDemoView().modifier(VerticalIndex(indexableList: contacts))

Here's the code for the modifier:

struct VerticalIndex: ViewModifier {
    let indexableList: [String]
    func body(content: Content) -> some View {
        var body: some View {
            ScrollViewReader { scrollProxy in
                ZStack {
                    content
                    VStack {
                        ForEach(indexableList, id: \.self) { letter in
                            HStack {
                                Spacer()
                                Button(action: {
                                    withAnimation {
                                        scrollProxy.scrollTo(letter)
                                    }
                                }, label: {
                                    Text(letter)
                                        .font(.system(size: 12))
                                        .padding(.trailing, 7)
                                })
                            }
                        }
                    }
                }
            }
        }
        return body
    }
}

Here's what it looks like using the sample provided by DirectX:

Demo Results

For completeness, here's code to reproduce the display:

struct SimpleDemo_Previews: PreviewProvider {
    
    static var previews: some View {
        
        SimpleDemoView().modifier(VerticalIndex(indexableList: contacts))
    }
}

struct SimpleDemoView: View {
    var body: some View {
        List {
            ForEach(alphabet, id: \.self) { letter in
                Section(header: Text(letter).id(letter)) {
                    ForEach(contacts.filter({ (contact) -> Bool in
                        contact.lastName.prefix(1) == letter
                    })) { contact in
                        HStack {
                            Image(systemName: "person.circle.fill").font(.largeTitle).padding(.trailing, 5)
                            Text(contact.firstName)
                            Text(contact.lastName)
                        }
                    }
                }
            }
        }
        .navigationTitle("Contacts")
        .listStyle(PlainListStyle())

    }
}

Here's the sample data used to provide the demo (modified from DirectX's solution):

let alphabet = ["A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z"] //swiftlint:disable comma

let contacts: [Contact] = {
    var contacts = [Contact]()
        contacts.append(Contact(firstName: "Chris", lastName: "Ryan"))
        contacts.append(Contact(firstName: "Allyson", lastName: "Ryan"))
        contacts.append(Contact(firstName: "Jonathan", lastName: "Ryan"))
        contacts.append(Contact(firstName: "Brendan", lastName: "Ryaan"))
        contacts.append(Contact(firstName: "Jaxon", lastName: "Riner"))
        contacts.append(Contact(firstName: "Leif", lastName: "Adams"))
        contacts.append(Contact(firstName: "Frank", lastName: "Conors"))
        contacts.append(Contact(firstName: "Allyssa", lastName: "Bishop"))
        contacts.append(Contact(firstName: "Justin", lastName: "Bishop"))
        contacts.append(Contact(firstName: "Johnny", lastName: "Appleseed"))
        contacts.append(Contact(firstName: "George", lastName: "Washingotn"))
        contacts.append(Contact(firstName: "Abraham", lastName: "Lincoln"))
        contacts.append(Contact(firstName: "Steve", lastName: "Jobs"))
        contacts.append(Contact(firstName: "Steve", lastName: "Woz"))
        contacts.append(Contact(firstName: "Bill", lastName: "Gates"))
        contacts.append(Contact(firstName: "Donald", lastName: "Trump"))
        contacts.append(Contact(firstName: "Darth", lastName: "Vader"))
        contacts.append(Contact(firstName: "Clark", lastName: "Kent"))
        contacts.append(Contact(firstName: "Bruce", lastName: "Wayne"))
        contacts.append(Contact(firstName: "John", lastName: "Doe"))
        contacts.append(Contact(firstName: "Jane", lastName: "Doe"))
    return contacts.sorted()
}()

let indexes = Array(Set(contacts.compactMap({ String($0.lastName.prefix(1)) }))).sorted()

2 Comments

Tried to use a VStack instead of List but the letters are not visible. List is ugly looking.
Does that also not work with dragging the index? I think a Button only works for tapping.
1

I love this answer: https://stackoverflow.com/a/63996814/1695772, so if you upvote this, give him/her an upvote, too. ;)

import SwiftUI

struct AlphabetSidebarView: View {

  var listView: AnyView
  var lookup: (String) -> (any Hashable)?
  let alphabet: [String] = {
    (65...90).map { String(UnicodeScalar($0)!) }
  }()

  var body: some View {
    ScrollViewReader { scrollProxy in
      ZStack {
        listView
        HStack(alignment: .center) {
          Spacer()
          VStack(alignment: .center) {
            ForEach(alphabet, id: \.self) { letter in
              Button(action: {
                if let found = lookup(letter) {
                  withAnimation {
                    scrollProxy.scrollTo(found, anchor: .top)
                  }
                }
              }, label: {
                Text(letter)
                  .foregroundColor(.label)
                  .minimumScaleFactor(0.5)
                  .font(.subheadline)
                  .padding(.trailing, 4)
              })
            }
          }
        }
      }
    }
  }
}

Use it like this:

AlphabetSidebarView(listView: AnyView(contactsListView)) { letter in
  // contacts: Array, name: String
  contacts.first { $0.name.prefix(1) == letter }
}

5 Comments

Type of expression is ambiguous without more context
But you understand that „contacts“ and „name“ are just an example and you need to use your own array and String property there, right?
Yep. and it gave that error. it's ok. i found a workaround.
Glad to hear that. If the code didn't work for you and you found a workaround consider sharing it here for fellow devs, who encounter the same problem. ;)
I used one of the examples in this post instead. :D Id have preferred to use yours since it's as a struct, more preferable for me.
1

iOS 26+ sectionindexlabel(_:)

https://developer.apple.com/documentation/swiftui/view/sectionindexlabel(_:)

Comments

0

I've made a couple of changes to @Mozahler's and @DirectX's code, refining the result.

  1. I didn't want the main list to include headers with no content, so in the implementation the line under List { becomes:

    ForEach(indexes, id: \.self) { letter in
    

    rather than

    ForEach(alphabet, id: \.self) { letter in
    
  2. Setting a background and uniform width for the index column sets it off from any background and unifies the result:

    Text(letter)
        .frame(width: 16)
        .foregroundColor(Constants.color.textColor)
        .background(Color.secondary.opacity(0.5))
        .font(Constants.font.customFootnoteFont)
        .padding(.trailing, 7)
    

Comments

0

Here is a working example that uses @RyuX51's AlphabetSidebarView:

import SwiftUI

struct ContentView: View {
    let contactsListView = ContactsListView()

    var body: some View {
        VStack {
            AlphabetSidebarView(listView: AnyView(contactsListView)) { letter in
              contacts.first { $0.lastName.prefix(1) == letter }
            }        }
        .padding()
    }
}

struct ContactsListView: View {
    var body: some View {
        List {
            ForEach(contacts, id: \.self) { contact in
                Text("\(contact.firstName) \(contact.lastName)")
            }
        }
    }
}

struct AlphabetSidebarView: View {

  var listView: AnyView
  var lookup: (String) -> (any Hashable)?
  let alphabet: [String] = {
    (65...90).map { String(UnicodeScalar($0)!) }
  }()

  var body: some View {
    ScrollViewReader { scrollProxy in
      ZStack {
        listView
        HStack(alignment: .center) {
          Spacer()
          VStack(alignment: .center) {
            ForEach(alphabet, id: \.self) { letter in
              Button(action: {
                if let found = lookup(letter) {
                  withAnimation {
                    scrollProxy.scrollTo(found, anchor: .top)
                  }
                }
              }, label: {
                Text(letter)
                      .foregroundColor(Color.blue)
                  .font(.body)
                  .padding(.trailing, 4)
              })
            }
          }
        }
      }
    }
  }
}

struct Contact: Identifiable, Comparable, Hashable {
    static func < (lhs: Contact, rhs: Contact) -> Bool {
        return (lhs.lastName, lhs.firstName) < (rhs.lastName, rhs.firstName)
    }
    
    var id = UUID()
    let firstName: String
    let lastName: String
}

let contacts = [
    Contact(firstName: "Leif", lastName: "Adams"),
    Contact(firstName: "Johnny", lastName: "Appleseed"),
    Contact(firstName: "Allyssa", lastName: "Bishop"),
    Contact(firstName: "Justin", lastName: "Bishop"),
    Contact(firstName: "Frank", lastName: "Conors"),
    Contact(firstName: "Jane", lastName: "Doe"),
    Contact(firstName: "John", lastName: "Doe"),
    Contact(firstName: "Bill", lastName: "Gates"),
    Contact(firstName: "Steve", lastName: "Jobs"),
    Contact(firstName: "Clark", lastName: "Kent"),
    Contact(firstName: "Abraham", lastName: "Lincoln"),
    Contact(firstName: "Brendan", lastName: "Ryaan"),
    Contact(firstName: "Chris", lastName: "Ryan"),
    Contact(firstName: "Allyson", lastName: "Ryan"),
    Contact(firstName: "Jonathan", lastName: "Ryan"),
    Contact(firstName: "Jaxon", lastName: "Riner"),
    Contact(firstName: "Donald", lastName: "Trump"),
    Contact(firstName: "Darth", lastName: "Vader"),
    Contact(firstName: "George", lastName: "Washingotn"),
    Contact(firstName: "Bruce", lastName: "Wayne"),
    Contact(firstName: "Steve", lastName: "Woz")
]

1 Comment

This was a nice start. If we're trying to re-create UITableView functionality, the Button approach isn't that good, especially when considering it's hard to exactly touch the button of interest. Buttons generally want to have a height of at least 30pt to be useable. Also, I think a drag gesture is the way forward.
0

I took the answer provided by FarouK / Federico Zanatello and modified it to be a bit more like the classic table view, addressing issues such as duplicate calls to scrollTo (when unnecessary) and animating the table to that section.

https://gist.github.com/horseshoe7/99426e81f64eb3e38513e4d4e5691c69

import SwiftUI

/**
 
 IndexedListView

 Basically a view that aims to restore some of the index bar functionality native to a UITableView.
 
 The starting point for this was this article: https://www.fivestars.blog/articles/section-title-index-swiftui/

 But I found it didn't address the issue of dragging within a section title and that triggering a scrollTo call to the proxy, and didn't handle animation.

 Suggestions for Future Work:
 - Use view modifiers to change its styling. (such as the touchDown color or the overlay builder.)
 - Allow Styling of the IndexBar from the IndexedListView's init method / environment.
 */

struct IndexedListView<T: Identifiable, SectionHeader: View, RowContent: View>: View {
    
    let data: [IndexedGroup<T>]
    let sectionHeaderBuilder: (String) -> SectionHeader
    let rowBuilder: (T) -> RowContent
    
    init(
        data: [IndexedGroup<T>],
        @ViewBuilder sectionHeaderBuilder: @escaping (String) -> SectionHeader,
        @ViewBuilder rowBuilder: @escaping (T) -> RowContent
    ) {
        self.data = data
        self.sectionHeaderBuilder = sectionHeaderBuilder
        self.rowBuilder = rowBuilder
    }
    
    var body: some View {
        ScrollViewReader { scrollProxy in
            ZStack {
                List {
                    ForEach(self.data) { section in
                        Section {
                            ForEach(section.items) { item in
                                self.rowBuilder(item)
                            }
                        } header: {
                            self.sectionHeaderBuilder(section.name)
                                .id(section.id)
                        }
                    }
                }
                
                HStack(alignment: .center) {
                    Spacer()
                    
                    SectionIndexTitles(
                        proxy: scrollProxy,
                        indices: self.data.map(\.id),
                        indexBuilder: {
                            Text($0)
                        },
                        changeOverlay: {
                            Text($0)
                                .font(.largeTitle)
                                .padding(.vertical, 30)
                                .padding(.horizontal, 40)
                                .background(
                                    RoundedRectangle(cornerRadius: 10)
                                        .fill(Color.black.opacity(0.5))
                                )
                                .foregroundColor(.white)
                        }
                    )
                }
            }
        }
    }
}

struct SectionIndexTitles<IndexContent: View, OverlayContent: View>: View {
    
    let proxy: ScrollViewProxy
    let indices: [String]
    let indexBuilder: (String) -> IndexContent
    let changeOverlay: (String) -> OverlayContent
    
    @GestureState private var dragLocation: CGPoint = .zero
    @GestureState private var isTouchDown: Bool = false
    @State private var lastIndexScrolledTo: String? = nil
    @State private var indexItemFrames: [String: CGRect] = [:]
    
    var body: some View {
        HStack {
            
            Spacer()
            
            if let lastIndexScrolledTo, self.isTouchDown {
                self.changeOverlay(lastIndexScrolledTo)
            }
            
            Spacer()
            
            VStack {
                ForEach(indices, id: \.self) { indexId in
                    indexBuilder(indexId)
                        .background(dragObserver(index: indexId))
                }
            }
            .onPreferenceChange(
                SectionIndexFramePreferenceKey.self
            ) { newFrames in
                self.indexItemFrames = newFrames
            }
            .frame(maxHeight: .infinity)
            .frame(width: 20)
            .background(
                isTouchDown ? Color(.blue).opacity(0.4) : Color.clear
            )
            .gesture(
                DragGesture(minimumDistance: 0, coordinateSpace: .global)
                    .updating($dragLocation) { value, state, _ in
                        state = value.location
                        self.updateCurrentlyTouchedIndex()
                    }
                    .updating($isTouchDown) { _, state, _ in
                        state = true
                    }
            )
        }
        .frame(maxHeight: .infinity)
    }
    
    // Function to determine which item frame contains the drag location
    private func updateCurrentlyTouchedIndex() {
        for (index, frame) in indexItemFrames {
            if frame.contains(dragLocation) {
                lastIndexScrolledTo = index
                return
            }
        }
        lastIndexScrolledTo = nil
    }
    
    func dragObserver(index: String) -> some View {
        GeometryReader { geometry in
            dragObserver(geometry: geometry, index: index)
        }
    }
    
    func dragObserver(geometry: GeometryProxy, index: String) -> some View {
        if geometry.frame(in: .global).contains(dragLocation) && lastIndexScrolledTo != index {
            
            DispatchQueue.main.async {
                withAnimation {
                    print("Wants to scroll")
                    proxy.scrollTo(index, anchor: .top)
                }
            }
        }
        return Rectangle()
            .fill(Color.clear)
            .preference(
                key: SectionIndexFramePreferenceKey.self,
                value: [index: geometry.frame(in: .global)]
            )
    }
}

struct SectionIndexFramePreferenceKey: PreferenceKey {
    static var defaultValue: [String: CGRect] = [:]
    
    static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
        value.merge(nextValue()) { current, new in new }
    }
}

// MARK: - Data Types

struct IndexedGroup<ItemType>: Identifiable {
     
     /// Should be one character.
     let id: String
     /// also known as the sortGroup, or name of the group.  Most likely the id
     let name: String
     let items: [ItemType]
     
     init(name: String, items: [ItemType]) {
         guard name.isEmpty == false else {
             fatalError("You should never have an empty title!")
         }
         self.id = String(name.first!)
         self.name = name
         self.items = items
     }
 }

// MARK: - Previews

#Preview {
    IndexedListView(
        data: Dummy.asTableData
    ) { sectionName in
        Text(sectionName)
    } rowBuilder: { dummy in
        Button (
            action: {
                print("tapped: \(dummy.id)")
            },
            label: {
                Text(dummy.id)
            }
        )
        
    }
}

// MARK: - Dummy Data for Preview

private struct Dummy: Identifiable {
    let id: String
    
    static func items(with count: Int) -> [Dummy] {
        var array: [Dummy] = []
        for _ in 0..<count {
            array.append(Dummy(id: String.random(minLength: 5, maxLength: 15, includeNumbers: false)))
        }
        return array
    }
    
    static var asTableData: [IndexedGroup<Dummy>] = {
        let numItemsInSection = 8
        return [
            IndexedGroup(
                name: "A",
                items: Dummy.items(with: numItemsInSection)
            ),
            IndexedGroup(
                name: "C",
                items: Dummy.items(with: numItemsInSection)
            ),
            IndexedGroup(
                name: "F",
                items: Dummy.items(with: numItemsInSection)
            ),
            IndexedGroup(
                name: "G",
                items: Dummy.items(with: numItemsInSection)
            ),
            IndexedGroup(
                name: "H",
                items: Dummy.items(with: numItemsInSection)
            ),
            IndexedGroup(
                name: "X",
                items: Dummy.items(with: numItemsInSection)
            ),
            IndexedGroup(
                name: "Y",
                items: Dummy.items(with: numItemsInSection)
            )
        ]
    }()
}



// MARK: - Helpers

private extension String {
    static func random(length: Int, includeNumbers: Bool = true) -> String {
        let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
        let numbers = "0123456789"
        let values = includeNumbers ? letters + numbers : letters
        return String((0..<length).map { _ in values.randomElement()! })
    }

    static func random(minLength: Int = 1, maxLength: Int = Int.random(in: 1...25), includeNumbers: Bool = true) -> String {
        return random(length: Int.random(in: minLength...maxLength), includeNumbers: includeNumbers)
    }
}

Comments

0

The above solutions work with Button but that does not allow the user to drag the finger on the index.

I am using my own solution which does support dragging.

struct AlphaRegister: View {
    var entries: [String]
    @Binding var selectedEntry: String
    
    // Font lineHeight + padding
    private static let rowHeight = UIFont.systemFont(ofSize: 12, weight: .semibold).lineHeight + 2

    var body: some View {
        VStack(spacing: 0) {
            ForEach(entries, id: \.self) { entry in
                Text(entry)
                    .font(.system(size: 12, weight: .semibold))
                    .foregroundStyle(.blue)
                    .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4))
            }
        }
        .gesture(DragGesture(minimumDistance: 0)
            .onChanged { value in
                print("loc: \(value.location.y)")
                let y = value.location.y
                guard y >= 0 else { return }
                let index = Int(y / AlphaRegister.rowHeight)
                guard index < entries.count else { return }
                selectedEntry = entries[index]
            })
    }
}

Use it in a HStack next to a List :

HStack(spacing: 2) {
    ScrollViewReader { proxy in
        List(selection: $selectedAnimal) {
        ForEach(filteredSections) { sec in
        Section(sec.name) { 
                    ...
        }
        .id(sec.name)
        }
    }
    .listStyle(.plain)
    .onChange(of: selectedGroup, initial: false) { _, newValue in
        proxy.scrollTo(newValue, anchor: .top)
    }
    }
    AlphaRegister(entries: filteredSections.map(\.name), selectedEntry: $selectedGroup)
}

Comments

-2

If you need a class that conforms to UITableViewDataSource, UITableViewDelegate protocols, then:


import SwiftUI

struct SelectRegionView: View {
    var body: some View {
        TableWithIndexView(sectionItems: [["Alex", "Anna"], ["John"]], sectionTitles: ["A", "J"])
    }
}

#if DEBUG
struct SelectRegionView_Previews: PreviewProvider {
    static var previews: some View {
        SelectRegionView()
    }
}
#endif

struct TableWithIndexView<T: CustomStringConvertible>: UIViewRepresentable {
    
    /// the items to show
    public var sectionItems = [[T]]()
    /// the section titles
    public var sectionTitles = [String]()
    
    func makeUIView(context: Context) -> UITableView {
        let tableView =  UITableView(frame: .zero, style: .plain)
        let coordinator = context.coordinator
        coordinator.sectionTitles = sectionTitles
        coordinator.sectionItemCounts = sectionItems.map({$0.count})
        
        // Create cell for given `indexPath`
        coordinator.createCell = { tableView, indexPath -> UITableViewCell in
            let cellId = "cellIdentifier"
            let cell = tableView.dequeueReusableCell(withIdentifier: cellId) ?? UITableViewCell(style: .default, reuseIdentifier: cellId)
            cell.textLabel?.text = "\(sectionItems[indexPath.section][indexPath.row])"
            return cell
        }
        tableView.delegate = coordinator
        tableView.dataSource  = coordinator
        return tableView
    }
    
    func updateUIView(_ uiView: UITableView, context: Context) {
        
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator()
    }
    
    final class Coordinator: NSObject, UITableViewDataSource, UITableViewDelegate {
    
        /// the items to show
        fileprivate var createCell: ((UITableView, IndexPath)->(UITableViewCell))?
        fileprivate var sectionTitles = [String]()
        fileprivate var sectionItemCounts = [Int]()
        
        /// Section titles
        func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
            return sectionTitles[section]
        }
        
        /// Number of sections
        func numberOfSections(in tableView: UITableView) -> Int {
            return sectionTitles.count
        }
        
        /// Number of rows in a section
        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            sectionItemCounts[section]
        }
        
        /// Cell for indexPath
        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            let cellId = "cellIdentifier"
            return createCell?(tableView, indexPath) ?? UITableViewCell(style: .default, reuseIdentifier: cellId)
        }
        
        /// Section index title
        func sectionIndexTitles(for tableView: UITableView) -> [String]? {
            /// Get first letters
            return sectionTitles.map({ String($0.first!).lowercased() })
        }
    }
}

3 Comments

If you care for architecture whatsoever, SwiftUI framework should not be linked with UIKit (unless obviously there is not a possible implementation). PS: SwiftUI removes the necessity of using Coordinators.
@marcelosarquis thanks for your opinion, but no-one cares. I noted in my answer that this is for developers who need UITableViewDataSource, UITableViewDelegate for their specific methods.
As you may, or may not, know, it is my opinion and the theory. But you are right; the majority of devs does not care about good architecture. Nevertheless we should share our code answering questions not only with code that works but also with code that is sustainable, maintainable and good for the long run.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.