最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

geometryreader - SwiftUI discrete scrubber implementation - Stack Overflow

programmeradmin0浏览0评论

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


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


Share Improve this question asked Mar 28 at 13:44 Deepak SharmaDeepak Sharma 6,63110 gold badges66 silver badges161 bronze badges
Add a comment  | 

1 Answer 1

Reset to default 1

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)
    }
}
发布评论

评论列表(0)

  1. 暂无评论