Search code examples
swiftswiftui

changing horizontal padding changes height of the super view SwiftUI


import SwiftUI




public struct segmentedSlider<T, Content> : View where T: Hashable, Content: View {

    public let sources: [T]
    public let selection: T?
    private let itemBuilder: (T) -> Content
    private var customIndicator: AnyView? = nil
    
    public init(
    _ sources: [T],
    selection: T?,
    indicatorBuilder: @escaping () -> some View,
       @ViewBuilder itemBuilder: @escaping (T) -> Content
    ) {
       self.sources = sources
       self.selection = selection
       self.itemBuilder = itemBuilder
       self.customIndicator = AnyView(indicatorBuilder())
    }
    
    public init(
    _ sources: [T],
    selection: T?,
       @ViewBuilder itemBuilder: @escaping (T) -> Content
    ) {
       self.sources = sources
       self.selection = selection
       self.itemBuilder = itemBuilder
    }
    
    @State private var borderColor: Color?
    func borderColor(_ borderColor: Color) -> segmentedSlider {
    var view = self
       view._borderColor = State(initialValue: borderColor)
    return view
    }
    
    @State private var borderWidth: CGFloat?
    func borderWidth(_ borderWidth: CGFloat) -> segmentedSlider {
    var view = self
       view._borderWidth = State(initialValue: borderWidth)
    return view
    }
    
    public var body: some View {
        
        ZStack(alignment: .center) {
            
            if let selection = selection, let selectedIdx = sources.firstIndex(of: selection) {
            if let customIndicator = customIndicator {
                   customIndicator
               } else {
                   GeometryReader { geo in
            RoundedRectangle(cornerRadius: 12.0)
                           .foregroundColor(.accentColor)
                           .padding(EdgeInsets(top: 2, leading: 2, bottom: 2, trailing: 2))
                           .frame(width: geo.size.width / CGFloat(sources.count))
                           .animation(.spring().speed(1.5))
                           .offset(x: geo.size.width / CGFloat(sources.count) * CGFloat(selectedIdx), y: 0)
                   }
                   
               }
                // TODO: Add items
                HStack(spacing: 0) {
                    ForEach(sources, id: \.self) { item in
                        itemBuilder(item)
                    }
                }
            }
            
            
        }
        .scaledToFit()
        .background(
        RoundedRectangle(cornerRadius: 12.0)
               .fill(
                Color.DesignSystem.white,
                strokeBorder: borderColor ?? Color.clear,
                lineWidth: borderWidth ?? .zero
               )
        )
    }
    
   
}

struct PreviewPickerPlus: View {
    @State private var selectedItem : String? = "Modes"
    var body: some View {
        VStack {
            segmentedSlider(
                           ["Modes","Wind Power", "Timer"],
            selection: selectedItem
                       ) { item in
            Text(item.capitalized)
                               .font(Font.footnote.weight(.medium))
                               .foregroundColor(selectedItem == item ? .white : .black)
                               .padding(.horizontal, 20)
                               .padding(.vertical, 0)
                               .frame(maxWidth: .infinity)
                               .multilineTextAlignment(.center)
                               .onTapGesture {
            withAnimation(.easeInOut(duration: 0.150)) {
                                       selectedItem = item
                                   }
                               }
                               .overlay(
                                   RoundedRectangle(cornerRadius: 12)
                                       .stroke( Color.DesignSystem.blue, lineWidth: 1)
                                       .scaleEffect( 1.0)
                                       .padding(.horizontal, 20)
                               )
                       }
                       .borderColor(.blue)
                       .borderWidth(1)
                       .accentColor(.green)
                       .padding(0)
                       .background(.black)
            Spacer()
        }
    }
}

struct PickerPlus_Previews: PreviewProvider {
    
    
    static var previews: some View {
        PreviewPickerPlus()
    }
}

extension Shape {
    public func fill<Fill: ShapeStyle, Stroke: ShapeStyle>(_ fillStyle: Fill, strokeBorder strokeStyle: Stroke, lineWidth: Double = 0) -> some View {
        self
            .stroke(strokeStyle, lineWidth: lineWidth)
            .background(self.fill(fillStyle))
    }
}

extension InsettableShape {
    public func fill<Fill: ShapeStyle, Stroke: ShapeStyle>(_ fillStyle: Fill, strokeBorder strokeStyle: Stroke, lineWidth: Double = 0) -> some View {
        self
            .strokeBorder(strokeStyle, lineWidth: lineWidth)
            .background(self.fill(fillStyle))
    }
}

this is the code, right, when I pass Text view to my segmentedPicker class init ive tryed changing horizontal padding and the bigger padding becomes the smaller slider becomes (uploaded 2 images with 0 horizontal padding and 20)

enter image description hereenter image description here

just.. why?

why with 0 vertical padding and 0 horizontal it has some extra height but with extra horizontal padding height decreases


Solution

  • This is happening because a GeometryReader has a very small ideal height, which causes scaledToFit to work in an unexpected way.

    • The green rounded rectangle contains a GeometryReader. A GeometryReader is greedy and tries to use all the space available, horizontally and vertically.
    • The ZStack in the body of the segmentedSlider is being scaledToFit. This is bringing the height down to something more normal.
    • If you take out the .scaledToFit, the green background fills the full height of the display.

    The modifier .scaledToFit bases the scaling on the ideal sizes of the components. However, even though a GeometryReader will use all the space available, it's ideal size is actually very small. To confirm this, let's try a small test:

    GeometryReader { proxy in
        Color.blue
            .onAppear {
                print("size=\(proxy.size.width)x\(proxy.size.height)")
            }
    }
    .fixedSize()
    

    By applying .fixedSize(), the GeometryReader will only consume its ideal size, instead of being greedy. Here's what we see:

    size=10.0x10.0

    Now lets put the GeometryReader inside a ZStack which also has another view that has a larger size. The modifier .fixedSize() is moved to the ZStack:

    ZStack {
        Color.orange
            .frame(width: 25, height: 25)
    
        GeometryReader { proxy in
            Color.blue
                .onAppear {
                    print("size=\(proxy.size.width)x\(proxy.size.height)")
                }
        }
    }
    .fixedSize()
    

    In this case we see:

    size=25.0x25.0

    The size of the ZStack is being determined by the size of its contents based on their ideal sizes. Once this size has been established and used for the layout, the GeometryReader expands to the full size of the ZStack.

    Applying this to your selector, it will be the size of the HStack that will determine the size of the ZStack before scaling, which in turn will determine the size of the GeometryReader.

    Let's see what space the HStack would need at its ideal size by commenting out the following:

    • the GeometryReader
    • the modifier .scaledToFit()
    • in the itemBuilder closure, the foreground color for the item
    • the .frame modifier that sets maxWidth: .infinity on an item
    • the horizontal padding for the RoundedRectangle in the overlay
    • the black background.

    When the horizontal padding is at 20, this is what we get:

    Screenshot

    The size of the selector here is approx. 270x16

    With horizontal padding of 0, it's like this:

    Screenshot

    The size of the selector here is approx. 150x16. The width has been reduced by 120pt, which is what we would expect, because padding of 2 x 20pt x 3 was taken away.

    These screenshots were taken on an iPhone 15, which has a width of 393pt. When scaledToFit is applied, the constraining factor will be this width. The height of the scaled GeometryReader can therefore be calculated as follows:

    • for horiontal padding of 20, scaled height = (393 / 270) * 16 = approx. 23pt
    • for horizontal padding of 0, scaled height = (393 / 150) * 16 = approx. 42pt

    This explains the different sizes you were seeing. If you measure the actual heights in the two cases, these calculated heights correspond very closely.

    That leaves the question, why don't the text labels get scaled too? This is because, scaledToFit only changes the frame size and this only works on views that are resizable. In particular, it does not make the font size larger. So scaledToFit has no effect on the text labels being used here.

    How to avoid the sizing issue

    I would suggest you move the green rounded rectangle to the background of the HStack and use .matchedGeometryEffect to position it instead. This way, you don't need a GeometryReader, nor do you need .scaledTofit.

    While we're at it, you might like to use a more-conventional capitalized name for the view and add a value parameter to the .animation modifier (the version without value is deprecated).

    Like this:

    public struct SegmentedSlider<T, Content> : View where T: Hashable, Content: View {
    
        @Namespace private var ns
    
        // other properties and functions as before
    
        public var body: some View {
            HStack(spacing: 0) {
                ForEach(sources, id: \.self) { item in
                    itemBuilder(item)
                        .padding(.vertical, 6)
                        .matchedGeometryEffect(id: item, in: ns, isSource: true)
                }
            }
            .background {
                if let selection = selection, let selectedIdx = sources.firstIndex(of: selection) {
                    if let customIndicator = customIndicator {
                        customIndicator
                            .matchedGeometryEffect(id: selection, in: ns, isSource: false)
                    } else {
                        RoundedRectangle(cornerRadius: 12.0)
                            .foregroundColor(.accentColor)
                            .padding(2)
                            .animation(.spring().speed(1.5), value: selection)
                            .matchedGeometryEffect(id: selection, in: ns, isSource: false)
                    }
                }
            }
            .background(
                RoundedRectangle(cornerRadius: 12.0)
                    .fill(
                        Color.DesignSystem.white,
                        strokeBorder: borderColor ?? Color.clear,
                        lineWidth: borderWidth ?? .zero
                    )
            )
        }
    }