How to Build a Country Picker in SwiftUI
Use Locale.Region.isoRegions to get every ISO country code, then localise each one with Locale.current.localizedString(forRegionCode:). Present a searchable sheet so users can filter by name and tap to confirm.
struct CountryPickerField: View {
@Binding var selectedCode: String
@State private var showSheet = false
var body: some View {
Button {
showSheet = true
} label: {
HStack {
Text(flag(selectedCode) + " " + name(selectedCode))
Spacer()
Image(systemName: "chevron.up.chevron.down")
.foregroundStyle(.secondary)
}
}
.sheet(isPresented: $showSheet) {
CountryPickerSheet(selectedCode: $selectedCode)
}
}
private func flag(_ code: String) -> String { /* see full impl */ }
private func name(_ code: String) -> String {
Locale.current.localizedString(forRegionCode: code) ?? code
}
}
Full implementation
The approach is a two-part component: a lightweight trigger row (CountryPickerField) that sits inside any Form, and a full-screen sheet (CountryPickerSheet) that owns the search state. Country data is derived entirely from Locale.Region.isoRegions — no hardcoded arrays, no third-party packages. Flag emoji is synthesised from the ISO 3166-1 alpha-2 country code using Unicode regional indicator scalars, so it stays up-to-date automatically with the OS emoji font.
import SwiftUI
// MARK: - Model
struct Country: Identifiable, Hashable {
let code: String // ISO 3166-1 alpha-2, e.g. "US"
let name: String // Localised display name
let flag: String // Flag emoji
var id: String { code }
static let all: [Country] = Locale.Region.isoRegions
.compactMap { region -> Country? in
let code = region.identifier
guard let name = Locale.current.localizedString(forRegionCode: code),
!name.isEmpty else { return nil }
return Country(code: code, name: name, flag: flagEmoji(code))
}
.sorted { $0.name < $1.name }
static func flagEmoji(_ code: String) -> String {
code.uppercased().unicodeScalars.reduce(into: "") {
if let scalar = Unicode.Scalar(127397 + $1.value) {
$0.append(Character(scalar))
}
}
}
}
// MARK: - Sheet
struct CountryPickerSheet: View {
@Binding var selectedCode: String
@Environment(\.dismiss) private var dismiss
@State private var query = ""
private var filtered: [Country] {
query.isEmpty
? Country.all
: Country.all.filter {
$0.name.localizedCaseInsensitiveContains(query) ||
$0.code.localizedCaseInsensitiveContains(query)
}
}
var body: some View {
NavigationStack {
List(filtered) { country in
Button {
selectedCode = country.code
dismiss()
} label: {
HStack(spacing: 12) {
Text(country.flag)
.font(.title2)
Text(country.name)
.foregroundStyle(.primary)
Spacer()
if country.code == selectedCode {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
.fontWeight(.semibold)
}
}
.contentShape(Rectangle())
}
.accessibilityLabel("\(country.name), \(country.code == selectedCode ? "selected" : "")")
}
.searchable(text: $query, prompt: "Search countries")
.navigationTitle("Country")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
}
}
// MARK: - Field (trigger row)
struct CountryPickerField: View {
@Binding var selectedCode: String
@State private var showSheet = false
private var selected: Country? {
Country.all.first { $0.code == selectedCode }
}
var body: some View {
Button {
showSheet = true
} label: {
HStack {
if let c = selected {
Text("\(c.flag) \(c.name)")
.foregroundStyle(.primary)
} else {
Text("Select a country")
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.up.chevron.down")
.foregroundStyle(.secondary)
.imageScale(.small)
}
}
.accessibilityLabel(selected.map { "Country: \($0.name)" } ?? "Select a country")
.accessibilityHint("Opens country picker")
.sheet(isPresented: $showSheet) {
CountryPickerSheet(selectedCode: $selectedCode)
}
}
}
// MARK: - Usage in a Form
struct RegistrationForm: View {
@State private var countryCode = "US"
var body: some View {
Form {
Section("Location") {
CountryPickerField(selectedCode: $countryCode)
}
}
.navigationTitle("Registration")
}
}
// MARK: - Preview
#Preview {
NavigationStack {
RegistrationForm()
}
}
How it works
-
Country.all (static computed once) —
Locale.Region.isoRegionsreturns every ISO 3166-1 region the OS knows about. Each region identifier is a two-letter code like"DE". We pass it throughLocale.current.localizedString(forRegionCode:)to get a user-language display name, then sort alphabetically. Because it's astatic let, the array is built once and shared across all instances. -
Flag emoji synthesis — The Unicode standard encodes national flags as pairs of Regional Indicator Symbol Letters starting at scalar
U+1F1E6(= 127462). Adding 127397 to the ASCII value of each letter in the two-character ISO code (e.g.'U'= 85, 85 + 127397 = 127482 → 🇺) gives the correct flag without any image assets. -
Searchable sheet —
.searchable(text:prompt:)wires the system search bar to the@State var query. Thefilteredcomputed property runs alocalizedCaseInsensitiveContainscheck against both the localised name and the raw ISO code so typing "DE" or "Germany" both surface the right row. -
Checkmark and binding update — Tapping a row sets
selectedCodethrough the@Bindingand immediately callsdismiss(). The checkmark is rendered conditionally (country.code == selectedCode) so the sheet re-opens with the previous selection highlighted. -
VoiceOver — Each list row carries
.accessibilityLabelthat appends "selected" when the country matches, matching the WCAG pattern for single-selection controls. The trigger button announces "Opens country picker" as a hint.
Variants
Inline Picker (compact, no sheet)
If your form is short and the user base is small, an inline Picker with .pickerStyle(.menu) avoids the full-screen sheet at the cost of no search.
struct InlineCountryPicker: View {
@Binding var selectedCode: String
var body: some View {
Picker("Country", selection: $selectedCode) {
ForEach(Country.all) { country in
Text("\(country.flag) \(country.name)")
.tag(country.code)
}
}
.pickerStyle(.menu)
}
}
// Usage inside a Form:
Form {
Section("Location") {
InlineCountryPicker(selectedCode: $countryCode)
}
}
#Preview {
@Previewable @State var code = "GB"
Form { InlineCountryPicker(selectedCode: $code) }
}
Phone dial-code variant
For international phone number inputs, extend the Country model with a dialCode: String property. Source dial codes from a lightweight JSON bundle (no Apple API provides them directly). Expose a separate CountryDialCodePicker that displays flag + dialCode (e.g. 🇺🇸 +1) as the trigger label, keeping the same searchable sheet underneath. Store the dial code and the ISO code separately so validation libraries receive the correct E.164 prefix.
Common pitfalls
-
🚫 iOS 16 and
Locale.Region.isoRegions—Locale.Regionwas introduced in iOS 16, but.isoRegionsis only available from iOS 17. On iOS 16 you'd need to fall back to the deprecatedLocale.isoRegionCodesarray. Since this guide targets iOS 17+, no fallback is required, but set your deployment target explicitly in the project build settings to avoid surprises. -
🚫 Rebuilding Country.all on every render — Making
Country.alla computed instance property instead of astatic letcauses the 250+ country array to be re-sorted on every SwiftUI view update. Keep itstatic letso the sort runs exactly once at app launch. -
🚫 Missing
.contentShape(Rectangle())on the List row button — Without it, only the text and flag are tappable — the whitespace between the label and the checkmark won't respond to taps. Always add.contentShape(Rectangle())to full-rowButtonlabels inside aList.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a country picker in SwiftUI for iOS 17+. Use Picker, Locale.Region.isoRegions, and Locale.current.localizedString(forRegionCode:). Present countries in a searchable sheet with flag emoji and sorted display names. Bind the selected ISO 3166-1 alpha-2 code to a @Binding<String>. Make it accessible (VoiceOver labels for each row and the trigger button). Add a #Preview with realistic sample data inside a NavigationStack Form.
In the Soarias Build phase, drop this prompt into the active screen context so Claude Code wires CountryPickerField directly into your form's @State model — no copy-paste required.
Related
FAQ
Does this work on iOS 16?
Partially. Locale.Region exists on iOS 16, but .isoRegions is iOS 17+. On iOS 16 you would need to replace it with the deprecated Locale.isoRegionCodes: [String] and construct regions manually. The .searchable modifier, NavigationStack, and #Preview macro all require iOS 16+, so the sheet architecture works fine on iOS 16 with that one substitution.
How do I pre-select the user's current country automatically?
Read Locale.current.region?.identifier and use it as the default value for your @State var countryCode. This returns the user's preferred region as set in Settings → General → Language & Region. Always provide a hardcoded fallback (e.g. "US") in case the property is nil on unusual simulator configurations.
What's the UIKit equivalent?
In UIKit you'd use a UIPickerView embedded in a UIViewController with a UISearchController layered on top — significantly more boilerplate. Alternatively, present a UITableViewController with a UISearchBar as the tableHeaderView. SwiftUI's .searchable + List combination replaces all of that in under 30 lines.
Last reviewed: 2026-05-11 by the Soarias team.