Why this happens
Picker with .pickerStyle(.navigationLink) behaves like a navigation row, not like a compact control. Given an HStack row, it asks for as much horizontal space as the row can offer, so the selection text sits on the left and the rest of the picker is empty space.
The border in the original layout was applied before the padding and the frame. Modifier order in SwiftUI is outside-in: each modifier wraps the result of the previous one. That means .border was drawn around the already-expanded picker, then padding pushed the whole thing inward, producing a wide grey box with a small label floating inside it.
There is a second quirk: this style always renders the picker label. Passing an empty string, or adding .labelsHidden(), does not remove the gap that the label occupies — the space is still reserved. Any symmetric padding therefore looks off-balance, with a visible gap before the selection text.
How to fix it
1. Shrink the picker with fixedSize, then pad, then draw the outline
Apply .fixedSize() first so the picker collapses to the width of its content. Padding comes next, and the border is drawn with an overlay so it follows the rounded shape instead of a rectangle.
Picker("", selection: $selectedCountry) {
ForEach(Countries.allCases) { country in
Text(country.rawValue)
}
}
.pickerStyle(.navigationLink)
.foregroundStyle(.primary)
.fixedSize()
.padding(.trailing, 8) // leading padding not needed: the empty label already reserves it
.padding(.vertical, 5)
.overlay {
RoundedRectangle(cornerRadius: 5)
.stroke(.gray, lineWidth: 2)
}
.frame(maxWidth: .infinity, alignment: .leading)
.border(Color.gray, width: 2).cornerRadius(5) is the other half of the visual bug — cornerRadius clips the content, and a square border with clipped corners never matches. An overlay RoundedRectangle strokes exactly the shape you want.
The final .frame(maxWidth: .infinity, alignment: .leading) is what makes the layout look intentional: the picker keeps its natural size but the row claims the remaining width, so the selection sits immediately after the label. Put both pickers through the same pipeline and the surplus space is split evenly between them.
2. Keep the labels from wrapping
A long label such as "State/Province" will wrap when space gets tight. Add .fixedSize() to the Text as well, so it stays on one line and the pickers shrink instead:
Text("State/Province")
.fixedSize()
3. Restore a VoiceOver label
Because the picker’s own label is blank, VoiceOver has nothing useful to announce. Set .accessibilityLabel on the picker and hide the decorative Text from the accessibility tree:
Text(label)
.fixedSize()
.accessibilityHidden(true)
Picker("", selection: $selection) { ... }
.accessibilityLabel(label)
4. Wrap the styling once
Since the two pickers share identical styling, a small generic wrapper keeps the call sites clean and guarantees they stay identical.
struct BorderedPicker<T>: View
where T: Hashable, T: Identifiable, T: RawRepresentable, T.RawValue: StringProtocol {
let label: String
@Binding var selection: T
let allCases: [T]
var body: some View {
HStack {
Text(label)
.fixedSize()
.accessibilityHidden(true)
Picker("", selection: $selection) {
ForEach(allCases) { value in
Text(value.rawValue)
}
}
.accessibilityLabel(label)
.pickerStyle(.navigationLink)
.foregroundStyle(.primary)
.fixedSize()
.padding(.trailing, 8)
.padding(.vertical, 5)
.overlay {
RoundedRectangle(cornerRadius: 5)
.stroke(.gray, lineWidth: 2)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
Usage stays readable, and .onChange is only added where you actually handle the change — an empty closure does nothing but add noise:
HStack {
BorderedPicker(
label: "Country",
selection: $selectedCountry,
allCases: Countries.allCases
)
BorderedPicker(
label: "State/Province",
selection: $selectedState,
allCases: States.allCases
)
}
.padding(.horizontal)
If you genuinely want the compact menu look instead of a navigation row, switch to .pickerStyle(.menu) or .pickerStyle(.automatic) and drop the fixedSize/frame dance entirely — those styles size themselves to their content.
How to prevent it
- Decide the layout order first: size the control, then pad it, then decorate it, then frame it. Reading modifiers top to bottom as inside-out prevents most of these surprises.
- Prefer
.overlay { RoundedRectangle(...).stroke(...) }over.border(...) + .cornerRadius(...);cornerRadiusclips content rather than shaping the border. - Never rely on an empty
Pickerlabel for layout — reserve the gap deliberately with trailing padding only. - Extract repeated control styling into a wrapper view so a fix in one place applies to every instance.
- Set
.accessibilityLabelwhenever the visiblePickertitle is empty.
FAQ
Why does .labelsHidden() not remove the gap in a navigationLink picker?
That style renders a full navigation row and always reserves space for the label, so hiding it removes the text but not the resulting inset. Use an empty label plus trailing-only padding to balance the selection against the outline.
How do I make two pickers in one HStack the same width?
Apply .frame(maxWidth: .infinity, alignment: .leading) to each picker after its padding and border. Both then claim an equal share of the surplus width, while .fixedSize() earlier keeps the outline hugging the actual content.
Does this work with a custom enum that isn’t CaseIterable?
The wrapper only needs Hashable, Identifiable and RawRepresentable with a StringProtocol raw value. You pass the array of cases yourself, so any collection works — CaseIterable is just a convenient source for allCases.
Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.
