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

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

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()
}
}
Have a look at this tutorial by Federico Zanetello, it's a 100% SwiftUI solution.
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)
)
}
}
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"]
}
}
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:
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()
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 }
}
iOS 26+ sectionindexlabel(_:)
https://developer.apple.com/documentation/swiftui/view/sectionindexlabel(_:)
I've made a couple of changes to @Mozahler's and @DirectX's code, refining the result.
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
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)
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")
]
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)
}
}
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)
}
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() })
}
}
}
UITableViewDataSource, UITableViewDelegate for their specific methods.