swiftuigeometryreader

SwiftUI discrete scrubber implementation


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)
}



Solution

  • 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 Texts 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 Texts 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 Texts 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)
        }
    }