I created a custom scrollview indicator that can be dragged and it doesn’t work completely right and it’s not smooth. It has a few problems: Scroll End does not fire when scroll view stops so the indicator always stays visible. Dragging the indicator to move the scroll view partially works (not smooth), doing it too fast makes it stop working sometimes. The indicator doesn’t move that smoothly when the user manually scrolls.
Here’s a quick overview on how it works:
-
When the user manually scrolls, the offset changes and the indicators position is calculated by dividing: offset / scrollview height. (total scroll view height is (scrollViewSize.height – wholeSize.height))
-
The date of each section along with the last element for each section both have overlayed geometry readers, when their position changes I check if their within a certain bound, if they are then the current indicator label is set to it.
-
Each rectangle in the view (represents an image for example) has the id “(item.id)(i)” (i represents the index). When the user drags the indicator a ratio is generated by dividing the indicators offset by height of the view. Then the sum of all rectangles in the view is counted and multiplied by the ratio. This gives us an int index that reprints the nth rectangle in the view to go to.
import SwiftUI
import Combine
struct MemoriesView: View {
@State var isScrolling: Bool = false
@State var barHeight: CGFloat = 0.0
@State var scrollToID: String = ""
@State var currentPresentedID: String = ""
@State var currentPresentedName: String = ""
@State var isDragging: Bool = false
@State var indicatorOffset: CGFloat = 0
@State var lastIndicatorOffset: CGFloat = 0
@State private var offset: Double = 0
@State var scrollViewSize: CGSize = .zero
@State var wholeSize: CGSize = .zero
var body: some View {
ZStack(alignment: .top){
GeometryReader { geo in
ScrollViewReader(content: { proxy in
ChildSizeReader(size: $wholeSize) {
ScrollView {
ZStack {
ScrollViewOffsetReader()
.onScrollingStarted {
isScrolling = true
}
.onScrollingFinished {
isScrolling = false
}
ChildSizeReader(size: $scrollViewSize) {
LazyVStack(spacing: 20){
Color.clear.frame(height: 30)
ForEach(testData) { item in
HStack {
Text(item.date).font(.title3).bold()
.id(item.id)
Spacer()
}
.overlay(GeometryReader { proxy in
Color.clear
.onChange(of: offset, { _, _ in
if !isDragging {
let frame = proxy.frame(in: .global)
let leadingDistance = frame.minY - geo.frame(in: .global).minY
if leadingDistance <= 70 && leadingDistance >= 50 {
currentPresentedID = item.id
currentPresentedName = item.date
}
}
})
})
LazyVGrid(columns: Array(repeating: GridItem(spacing: 3), count: 3), spacing: 3) {
ForEach(0..<item.countData, id: .self) { i in
if i == (item.countData - 1) {
Rectangle()
.id("(item.id)(i)")
.frame(height: 160)
.foregroundStyle(.gray).opacity(0.4)
.overlay(GeometryReader { proxy in
Color.clear
.onChange(of: offset, { _, _ in
if !isDragging {
let frame = proxy.frame(in: .global)
let leadingDistance = frame.minY - geo.frame(in: .global).minY
if leadingDistance <= 250 && leadingDistance >= 200 {
currentPresentedID = item.id
currentPresentedName = item.date
}
}
})
})
} else {
Rectangle()
.id("(item.id)(i)")
.frame(height: 160)
.foregroundStyle(.gray)
.opacity(0.4)
}
}
}
}
Color.clear.frame(height: 30)
}
.padding(.horizontal, 5)
.background(GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: -$0.frame(in: .named("scroll")).origin.y)
})
.onPreferenceChange(ViewOffsetKey.self) { value in
offset = value
}
}
}
}
.scrollIndicators(.hidden)
.coordinateSpace(name: "scroll")
.onChange(of: scrollToID) { _, newValue in
if !newValue.isEmpty {
withAnimation(.easeInOut(duration: 0.1)){
proxy.scrollTo(newValue, anchor: .top)
}
}
}
}
})
}
GeometryReader(content: { geometry in
HStack {
Spacer()
indicator(height: geometry.size.height)
.padding(.trailing, 8)
.onChange(of: offset) { _, new in
if !isDragging {
let fullSize = scrollViewSize.height - wholeSize.height
let ratio = new / fullSize
let maxClampedRatio = max(0.0, ratio)
let minClampedRatio = min(1.0, maxClampedRatio)
let newOffset = minClampedRatio * geometry.size.height
let max = geometry.size.height - 50.0
let clampedOffset = min(newOffset, max)
self.indicatorOffset = clampedOffset
self.lastIndicatorOffset = clampedOffset
}
}
}
}).padding(.top, 40).padding(.bottom, bottom_Inset() + 20)
ZStack {
HStack {
Image(systemName: "chevron.down")
.font(.title3).bold()
Spacer()
}
HStack {
Spacer()
Text("Memories").font(.title).bold()
Spacer()
}
}
.padding(.horizontal).padding(.bottom, 5)
.background(.ultraThickMaterial)
}
.ignoresSafeArea(edges: .bottom)
.onAppear {
if currentPresentedName.isEmpty {
currentPresentedName = testData.first?.date ?? "May 2021"
}
}
}
@ViewBuilder
func indicator(height: CGFloat) -> some View {
ZStack(alignment: .topTrailing){
RoundedRectangle(cornerRadius: 10)
.foregroundStyle(.gray).frame(width: 2)
.opacity(isScrolling ? 1.0 : 0.3)
HStack(spacing: isDragging ? 50 : 5){
Text(currentPresentedName)
.font(.subheadline).bold()
.padding(.horizontal, 8).padding(.vertical, 4)
.foregroundStyle(.white)
.background(Color(red: 5 / 255, green: 176 / 255, blue: 255 / 255))
.clipShape(Capsule())
RoundedRectangle(cornerRadius: 10)
.foregroundStyle(Color(red: 5 / 255, green: 176 / 255, blue: 255 / 255))
.frame(width: 6, height: 50)
}
.background(Color.gray.opacity(0.0001))
.offset(x: 1.5)
.offset(y: indicatorOffset).opacity(isScrolling ? 1.0 : 0.0)
.gesture(
DragGesture()
.onChanged({ value in
if !isDragging {
withAnimation(.easeInOut(duration: 0.2)){
isDragging = true
}
}
if value.location.y >= 25.0 && value.location.y < (height - 25) {
indicatorOffset = value.translation.height + lastIndicatorOffset
let ratio = indicatorOffset / height
let maxClampedRatio = max(0.0, ratio)
let minClampedRatio = min(1.0, maxClampedRatio)
let totalCount = CGFloat(testData.reduce(0) { $0 + $1.countData })
let pickedElement = Int(ratio * totalCount)
var cumulativeCount = 0
var finalID: String?
var finalIdx: Int?
for data in testData {
if (cumulativeCount + data.countData) > pickedElement {
finalID = data.id
finalIdx = pickedElement - cumulativeCount
currentPresentedID = data.id
currentPresentedName = data.date
break
} else {
cumulativeCount += data.countData
}
}
if let f1 = finalID, let f2 = finalIdx {
scrollToID = f1 + "(f2)"
}
}
})
.onEnded({ value in
withAnimation(.easeInOut(duration: 0.2)){
isDragging = false
}
lastIndicatorOffset = indicatorOffset
})
)
}
.frame(height: height)
}
}
let testData = [
testMonthData(date: "May 2020", countData: 2),
testMonthData(date: "June 2021", countData: 2),
testMonthData(date: "July 2022", countData: 20),
testMonthData(date: "Mar 2023", countData: 1),
testMonthData(date: "Apr 2024", countData: 9),
testMonthData(date: "Aug 2025", countData: 2),
testMonthData(date: "Sep 2026", countData: 6),
testMonthData(date: "Nov 2027", countData: 12),
testMonthData(date: "Dec 2028", countData: 8),
testMonthData(date: "Jan 2029", countData: 9),
testMonthData(date: "Jul 2030", countData: 10),
testMonthData(date: "Oct 2031", countData: 13),
testMonthData(date: "Nov 2032", countData: 12),
testMonthData(date: "Mar 2033", countData: 25),
testMonthData(date: "June 2034", countData: 2),
testMonthData(date: "June 2035", countData: 15),
testMonthData(date: "June 2036", countData: 5)
]
struct testMonthData: Identifiable {
var id = UUID().uuidString
var date: String
var countData: Int
}
struct ScrollViewOffsetReader: View {
private let onScrollingStarted: () -> Void
private let onScrollingFinished: () -> Void
private let detector: CurrentValueSubject<CGFloat, Never>
private let publisher: AnyPublisher<CGFloat, Never>
@State private var scrolling: Bool = false
init() {
self.init(onScrollingStarted: {}, onScrollingFinished: {})
}
private init(
onScrollingStarted: @escaping () -> Void,
onScrollingFinished: @escaping () -> Void
) {
self.onScrollingStarted = onScrollingStarted
self.onScrollingFinished = onScrollingFinished
let detector = CurrentValueSubject<CGFloat, Never>(0)
self.publisher = detector
.debounce(for: .seconds(0.2), scheduler: DispatchQueue.main)
.dropFirst()
.eraseToAnyPublisher()
self.detector = detector
}
var body: some View {
GeometryReader { g in
Rectangle()
.frame(width: 0, height: 0)
.onChange(of: g.frame(in: .global).origin.y) { _, offset in
if !scrolling {
scrolling = true
onScrollingStarted()
}
detector.send(offset)
}
.onReceive(publisher) { _ in
scrolling = false
onScrollingFinished()
}
}
}
func onScrollingStarted(_ closure: @escaping () -> Void) -> Self {
.init(
onScrollingStarted: closure,
onScrollingFinished: onScrollingFinished
)
}
func onScrollingFinished(_ closure: @escaping () -> Void) -> Self {
.init(
onScrollingStarted: onScrollingStarted,
onScrollingFinished: closure
)
}
}
#Preview {
MemoriesView()
}
struct ChildSizeReader<Content: View>: View {
@Binding var size: CGSize
let content: () -> Content
var body: some View {
ZStack {
content().background(
GeometryReader { proxy in
Color.clear.preference(
key: SizePreferenceKey.self,
value: proxy.size
)
}
)
}
.onPreferenceChange(SizePreferenceKey.self) { preferences in
self.size = preferences
}
}
}
struct SizePreferenceKey: PreferenceKey {
typealias Value = CGSize
static var defaultValue: Value = .zero
static func reduce(value _: inout Value, nextValue: () -> Value) {
_ = nextValue()
}
}
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}