Last active
May 27, 2025 08:57
-
-
Save horseshoe7/99426e81f64eb3e38513e4d4e5691c69 to your computer and use it in GitHub Desktop.
IndexedListView
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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