I am trying to implement a discrete scrubber with text markings in SwiftUI as follows. My issue is I am unable to determine the height of the HStack
inside the ScrollView
apriori so I tried using onGeometryChange
modifier but it doesn't work (i.e. the overlay text is truncated). One way fix is by using GeometryReader
and assign the height of HStack
based on the geometry proxy, but I want to know if there is another way out without using GeometryReader
.
struct ScrollScrubber: View {
var config:ScrubberConfig
@State var viewSize:CGSize?
var body: some View {
let horizontalPadding = (viewSize?.width ?? 0)/2
ScrollView(.horizontal) {
HStack(spacing:config.spacing) {
let totalSteps = config.steps * config.count
ForEach(0...totalSteps, id: \.self) { index in
let remainder = index % config.steps
Divider()
.background( remainder == 0 ? Color.primary : Color.gray)
.frame(width: 0, height: remainder == 0 ? 20 : 10, alignment: .center)
.frame(maxHeight: 20, alignment: .bottom)
.overlay(alignment: .bottom) {
if remainder == 0 {
Text("\(index / config.steps)")
.font(.caption)
.fontWeight(.semibold)
.textScale(.secondary)
.fixedSize()
.offset(y:20)
}
}
}
}
.frame(height:viewSize?.height)
}
.scrollIndicators(.hidden)
.safeAreaPadding(.horizontal, horizontalPadding)
.onGeometryChange(for: CGSize.self) { proxy in
proxy.size
} action: { newValue in
viewSize = newValue
print("View Size \(newValue)")
}
}
}
struct ScrubberConfig:Equatable {
var count:Int
var steps:Int
var spacing:CGFloat
}
#Preview {
ScrollScrubber(config: .init(count: 100, steps: 5, spacing: 5.0))
.frame(height:60)
}
If you do want the height of the scroll view to take up all the available space, then it is totally appropriate to use GeometryReader
here. There is nothing wrong with that. Otherwise, you'd have to use some other view that fills up the available space (e.g. Color.clear
), and measure the geometry of that view.
Color.clear
.onGeometryChange(for: CGSize.self) { proxy in
proxy.size
} action: { newValue in
viewSize = newValue
}
.overlay {
ScrollView { ... } // the actual scroll view would go on an overlay
.frame(height: viewSize?.height)
}
Clearly, just using a GeometryReader
is more convenient.
In this particular case, if you just want to stop the Text
s from being clipped, you can just disable the clipping by putting scrollClipDisabled()
on the scroll view. There is no need to do anything geometry-related and you can remove the frame
on the scroll view.
Note that the Text
s will still be outside of the bounds of the ScrollView
. If you want them to be inside the bounds, consider using a VStack
to layout the tick marks and text:
ForEach(0...totalSteps, id: \.self) { index in
let remainder = index % config.steps
VStack {
Rectangle() // changed the Divider to a Rectangle, because a Divider is horizontal in a VStack
.fill( remainder == 0 ? Color.primary : Color.gray)
.frame(width: 1, height: remainder == 0 ? 20 : 10, alignment: .center)
.frame(maxHeight: 20, alignment: .bottom)
Text("\(index / config.steps)")
.font(.caption)
.fontWeight(.semibold)
.textScale(.secondary)
.fixedSize()
.opacity(remainder == 0 ? 1 : 0)
}
}
This assumes all the text has the same height. If you cannot assume that, you can find the maximum heights of all the Text
s using a preference key. Then, set the height of the HStack
to be the max height of the texts, plus the max height of the dividers (i.e. 20).
struct MaxHeightPreference: PreferenceKey {
static let defaultValue: CGFloat = 0
// this reduce implementation finds the maximum height of all the sibling views
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
struct MaxHeightPreferenceModifier: ViewModifier {
@State private var height: CGFloat = 0
func body(content: Content) -> some View {
content
.onGeometryChange(for: CGFloat.self, of: \.size.height) { newValue in
height = newValue
}
.preference(key: MaxHeightPreference.self, value: height)
}
}
struct ScrollScrubber: View {
var config:ScrubberConfig
@State var viewWidth: CGFloat = 0
@State var maxTextHeight: CGFloat = 0
var body: some View {
let horizontalPadding = viewWidth / 2
ScrollView(.horizontal) {
HStack(spacing:config.spacing) {
let totalSteps = config.steps * config.count
ForEach(0...totalSteps, id: \.self) { index in
let remainder = index % config.steps
Divider()
.background( remainder == 0 ? Color.primary : Color.gray)
.frame(width: 0, height: remainder == 0 ? 20 : 10)
.frame(maxHeight: 20, alignment: .bottom)
// the text should align to the top of the divider, so that
// .offset(y:20) will put the text directly under the divider
.overlay(alignment: .top) {
if remainder == 0 {
Text("\(index / config.steps)")
.font(.caption)
.fontWeight(.semibold)
.textScale(.secondary)
.fixedSize()
.modifier(MaxHeightPreferenceModifier())
.offset(y:20)
}
}
}
}
.frame(height: maxTextHeight + 20, alignment: .top)
}
.scrollIndicators(.hidden)
.safeAreaPadding(.horizontal, horizontalPadding)
.onGeometryChange(for: CGFloat.self, of: \.size.width) { newValue in
viewWidth = newValue
}
.onPreferenceChange(MaxHeightPreference.self) { newValue in
maxTextHeight = newValue
}
// this border is to show the bounds of the ScrollView.
// you can see that it does not take up all the available height,
// only as much height as needed by the dividers + texts
.border(.red)
}
}