Custom Scroll View Indicator does not animate correctly

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:

  1. 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))

  2. 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.

  3. 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()
   }
}

Trang chủ Giới thiệu Sinh nhật bé trai Sinh nhật bé gái Tổ chức sự kiện Biểu diễn giải trí Dịch vụ khác Trang trí tiệc cưới Tổ chức khai trương Tư vấn dịch vụ Thư viện ảnh Tin tức - sự kiện Liên hệ Chú hề sinh nhật Trang trí YEAR END PARTY công ty Trang trí tất niên cuối năm Trang trí tất niên xu hướng mới nhất Trang trí sinh nhật bé trai Hải Đăng Trang trí sinh nhật bé Khánh Vân Trang trí sinh nhật Bích Ngân Trang trí sinh nhật bé Thanh Trang Thuê ông già Noel phát quà Biểu diễn xiếc khỉ Xiếc quay đĩa Dịch vụ tổ chức sự kiện 5 sao Thông tin về chúng tôi Dịch vụ sinh nhật bé trai Dịch vụ sinh nhật bé gái Sự kiện trọn gói Các tiết mục giải trí Dịch vụ bổ trợ Tiệc cưới sang trọng Dịch vụ khai trương Tư vấn tổ chức sự kiện Hình ảnh sự kiện Cập nhật tin tức Liên hệ ngay Thuê chú hề chuyên nghiệp Tiệc tất niên cho công ty Trang trí tiệc cuối năm Tiệc tất niên độc đáo Sinh nhật bé Hải Đăng Sinh nhật đáng yêu bé Khánh Vân Sinh nhật sang trọng Bích Ngân Tiệc sinh nhật bé Thanh Trang Dịch vụ ông già Noel Xiếc thú vui nhộn Biểu diễn xiếc quay đĩa Dịch vụ tổ chức tiệc uy tín Khám phá dịch vụ của chúng tôi Tiệc sinh nhật cho bé trai Trang trí tiệc cho bé gái Gói sự kiện chuyên nghiệp Chương trình giải trí hấp dẫn Dịch vụ hỗ trợ sự kiện Trang trí tiệc cưới đẹp Khởi đầu thành công với khai trương Chuyên gia tư vấn sự kiện Xem ảnh các sự kiện đẹp Tin mới về sự kiện Kết nối với đội ngũ chuyên gia Chú hề vui nhộn cho tiệc sinh nhật Ý tưởng tiệc cuối năm Tất niên độc đáo Trang trí tiệc hiện đại Tổ chức sinh nhật cho Hải Đăng Sinh nhật độc quyền Khánh Vân Phong cách tiệc Bích Ngân Trang trí tiệc bé Thanh Trang Thuê dịch vụ ông già Noel chuyên nghiệp Xem xiếc khỉ đặc sắc Xiếc quay đĩa thú vị
Trang chủ Giới thiệu Sinh nhật bé trai Sinh nhật bé gái Tổ chức sự kiện Biểu diễn giải trí Dịch vụ khác Trang trí tiệc cưới Tổ chức khai trương Tư vấn dịch vụ Thư viện ảnh Tin tức - sự kiện Liên hệ Chú hề sinh nhật Trang trí YEAR END PARTY công ty Trang trí tất niên cuối năm Trang trí tất niên xu hướng mới nhất Trang trí sinh nhật bé trai Hải Đăng Trang trí sinh nhật bé Khánh Vân Trang trí sinh nhật Bích Ngân Trang trí sinh nhật bé Thanh Trang Thuê ông già Noel phát quà Biểu diễn xiếc khỉ Xiếc quay đĩa
Thiết kế website Thiết kế website Thiết kế website Cách kháng tài khoản quảng cáo Mua bán Fanpage Facebook Dịch vụ SEO Tổ chức sinh nhật