```html SwiftUI: How to Build a Country Picker (iOS 17+, 2026)

How to Build a Country Picker in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: Picker / Locale Updated: May 11, 2026
TL;DR

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

  1. Country.all (static computed once)Locale.Region.isoRegions returns every ISO 3166-1 region the OS knows about. Each region identifier is a two-letter code like "DE". We pass it through Locale.current.localizedString(forRegionCode:) to get a user-language display name, then sort alphabetically. Because it's a static let, the array is built once and shared across all instances.
  2. 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.
  3. Searchable sheet.searchable(text:prompt:) wires the system search bar to the @State var query. The filtered computed property runs a localizedCaseInsensitiveContains check against both the localised name and the raw ISO code so typing "DE" or "Germany" both surface the right row.
  4. Checkmark and binding update — Tapping a row sets selectedCode through the @Binding and immediately calls dismiss(). The checkmark is rendered conditionally (country.code == selectedCode) so the sheet re-opens with the previous selection highlighted.
  5. VoiceOver — Each list row carries .accessibilityLabel that 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

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.

```