SwiftUI Custom ScrollView Indicator Horizontally

Luthfi Abdur Rahim
4 min readNov 14, 2023

--

Recently, I was stuck to achieve the behavior like the view below, I want to make a custom indicator for scroll view in horizontal mode.

Screenshot
custom ScrollView indicator horizontally

Then, I searched for help at ChatGPT and Bard and did some googling. Then I found inspiration from this YouTube video: https://www.youtube.com/watch?v=spAwDQuXeEE&ab_channel=Kavsoft

Even if it’s vertical, I got a little light to achieve my goal.

Then, I got an idea for the code formula from my co-worker to achieve it. Here is the result

Worked as expected

To achieve that behavior in SwiftUI, you need to get these values:

  1. The offset position of the scroll indicator
  2. The scroll view content size (in this case is the width size because of horizontal)
  3. The width of the view

To get those values, you need to make the code below:

extension View {
/// This `offset` is needed to get the CGRect value from the view
/// with this function, we can get the values we needed
@ViewBuilder
func offset(completion: @escaping (CGRect)->()) -> some View {
self
.overlay {
GeometryReader { geo in
let rect = geo.frame(in: .named(Constants.offsetNameSpace))
Color.clear
.preference(key: OffsetKey.self, value: rect)
.onPreferenceChange(OffsetKey.self) { value in
completion(value)
}
}
}
}
}

struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint = .zero

/// with this function, we can get the scroll view indicator position
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) { }
}

struct OffsetKey: PreferenceKey {
static var defaultValue: CGRect = .zero

static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}

struct Constants {
static let offsetNameSpace: String = "offset-namespace"
}

After you write those codes, then we can create the codes for the View using @ViewBuilder from SwiftUI

struct HorizontalScrollViewWithCustomIndicator<Content: View>: View {

private let contentBody: () -> Content

/// This view is using custom scroll indicator with its own formula to get the position of the indicator.
/// You may need to add more param if needed in init
///
/// example:
///
/// HorizontalScrollViewWithCustomIndicator {
/// // put your content view here
/// }
init(@ViewBuilder content: @escaping () -> Content) {
self.contentBody = content
}

@State private var scrollPosition: CGPoint = .zero
@State private var startOffset: CGFloat = 0
@State private var indicatorOffset: CGFloat = 0

private let indicatorBgWidth: CGFloat = 90
private let indicatorFrontWidth: CGFloat = 40
private let indicatorHeight: CGFloat = 6
private let scrollViewContentHeight: CGFloat = 150
private let paddingContentToScrollIndicator: CGFloat = 16

var body: some View {

VStack(spacing: 0) {

GeometryReader { geometryParent in

VStack(spacing: 0) {

ScrollView(.horizontal, showsIndicators: false) {

// MARK: Scroll view content
contentBody()
.frame(height: scrollViewContentHeight)
.offset { rect in

// MARK: FINDING SCROLL INDICATOR OFFSET
let rectWidth: CGFloat = rect.width
let viewWidth: CGFloat = geometryParent.size.width + (startOffset / 2)
let totalScrollRange: CGFloat = rectWidth
let currentScrollOffset: CGFloat = scrollPosition.x * -1
let scrollProgress: CGFloat = (CGFloat(100) * currentScrollOffset) / (totalScrollRange - viewWidth)
let indicatorFrontX: CGFloat = ( scrollProgress * (indicatorBgWidth-indicatorFrontWidth) / 100 )
indicatorOffset = indicatorFrontX

}
.background(
GeometryReader { geometry in
Color.clear
.preference(
key: ScrollOffsetPreferenceKey.self,
value: geometry.frame(in: .named(Constants.offsetNameSpace)).origin
)
}
)
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
self.scrollPosition = value
}

}
.coordinateSpace(name: Constants.offsetNameSpace)


// MARK: Custom scroll view indicator
GeometryReader { geometry in

ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 5)
.fill(.gray.opacity(0.3))
.frame(width: indicatorBgWidth, height: indicatorHeight)
.offset(y: paddingContentToScrollIndicator)
.zIndex(0)

RoundedRectangle(cornerRadius: 5)
.frame(width: indicatorFrontWidth , height: indicatorHeight)
.foregroundColor(.blue)
.offset(y: paddingContentToScrollIndicator)
.zIndex(1)
.offset(x: indicatorOffset)
}
.position(x: geometry.frame(in: .local).midX)

}

}
.padding(0)

}
.offset { rect in
if startOffset != rect.minX {
startOffset = rect.minX
}
}

}
.padding(0)

}
}

After that, you can use it in your ContentView , here is the code example of how to use it:

import SwiftUI

struct ContentView: View {

let items = 1...12

let rows = [
GridItem(.fixed(58), spacing: 20),
GridItem(.fixed(58), spacing: 20)
]

var body: some View {
VStack {

HorizontalScrollViewWithCustomIndicator {

LazyHGrid(rows: rows, alignment: .center) {
ForEach(items, id: \.self) { item in
Image(systemName: "\(item).circle.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 82.5, height: 58)
.font(.largeTitle)
}
}

}

}
.padding()
}
}

Thank you for reading it, I hope this article can be useful for you.

— — — — — — — — — — — — — — — — — — —

If you want to contact me, here is the way:

--

--