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)
just.. why?
why with 0 vertical padding and 0 horizontal it has some extra height but with extra horizontal padding height decreases
This is happening because a GeometryReader
has a very small ideal height, which causes scaledToFit
to work in an unexpected way.
GeometryReader
. A GeometryReader
is greedy and tries to use all the space available, horizontally and vertically.ZStack
in the body of the segmentedSlider
is being scaledToFit
. This is bringing the height down to something more normal..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:
GeometryReader
.scaledToFit()
itemBuilder
closure, the foreground color for the item.frame
modifier that sets maxWidth: .infinity
on an itemRoundedRectangle
in the overlayWhen the horizontal padding is at 20, this is what we get:
The size of the selector here is approx. 270x16
With horizontal padding of 0, it's like this:
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:
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
)
)
}
}