Skip to content

Instantly share code, notes, and snippets.

@horseshoe7
Last active May 27, 2025 08:57
Show Gist options
  • Save horseshoe7/99426e81f64eb3e38513e4d4e5691c69 to your computer and use it in GitHub Desktop.
Save horseshoe7/99426e81f64eb3e38513e4d4e5691c69 to your computer and use it in GitHub Desktop.
IndexedListView
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.
- The IndexBar dragging needs some work. Once touch is down, the x-coordinate should not matter.
NOTE: This can be problematic in the iOS Simulator as it triggers some Apple bug. It runs fine on the device!
*/
public struct IndexedListView<T: Identifiable, SectionHeader: View, RowContent: View>: View {
let data: [IndexedGroup<T>]
let sectionHeaderBuilder: (String) -> SectionHeader
let rowBuilder: (T) -> RowContent
public 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(provideFrame(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 {
// ignoringWidth means you don't need to keep your finger on the index bar once touch is down.
if frame.ignoringWidth().contains(dragLocation) {
DispatchQueue.main.async {
if index != self.lastIndexScrolledTo {
withAnimation(.linear(duration: 0.2)) {
print("dragLocation: \(dragLocation)")
print("Wants to scroll")
proxy.scrollTo(index, anchor: .top)
}
}
self.lastIndexScrolledTo = index
}
return
}
}
DispatchQueue.main.async {
self.lastIndexScrolledTo = nil
}
}
func provideFrame(index: String) -> some View {
GeometryReader { geometry in
provideFrame(geometry: geometry, index: index)
}
}
func provideFrame(geometry: GeometryProxy, index: String) -> some View {
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 }
}
}
extension CGRect {
func ignoringWidth() -> CGRect {
return CGRect(
x: 0,
y: origin.y,
width: UIScreen.main.bounds.width,
height: height
)
}
}
// 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 indexPool = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
let data = indexPool.map { letter in
return IndexedGroup(
name: "\(letter)",
items: Dummy.items(with: 15)
)
}
return data
}()
}
// 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)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment