Overview
The Location Search feature allows users to find and select any city worldwide to view its air quality data. It includes real-time search with autocomplete, current location detection via GPS, and a curated list of popular cities.
Implementation
SearchView Component
The search interface is built as a modal sheet with a navigation stack:
import SwiftUI
struct SearchView: View {
@ObservedObject var viewModel: DashboardViewModel
@Environment(\.dismiss) private var dismiss
@FocusState private var isSearchFocused: Bool
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Search bar
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
TextField("Search for a city", text: $viewModel.searchQuery)
.focused($isSearchFocused)
.textFieldStyle(.plain)
.autocorrectionDisabled()
.onChange(of: viewModel.searchQuery) { _, newValue in
viewModel.searchCities(newValue)
}
if !viewModel.searchQuery.isEmpty {
Button {
viewModel.searchQuery = ""
viewModel.searchResults = []
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
}
}
.padding(12)
.background(Color.searchBarBackground)
.clipShape(Capsule())
.padding()
// Use current location button
Button {
dismiss()
viewModel.requestLocation()
} label: {
HStack {
Image(systemName: "location.fill")
.foregroundColor(.accentColor)
Text("Use Current Location")
.fontWeight(.medium)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.background(Color.cardBackground)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
.padding(.horizontal)
// Search results or popular cities
if !viewModel.searchResults.isEmpty {
List(viewModel.searchResults) { city in
Button {
viewModel.selectCity(city)
dismiss()
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(city.name)
.font(.headline)
Text(city.displayName)
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
}
}
.listStyle(.plain)
}
}
.navigationTitle("Search")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Cancel") {
dismiss()
}
}
}
}
.onAppear {
isSearchFocused = true
}
}
}
Features
1. Real-Time Search
The search implements debounced autocomplete to avoid excessive API calls:
func searchCities(_ query: String) {
searchTask?.cancel()
guard query.count >= 2 else {
searchResults = []
return
}
searchTask = Task {
do {
try await Task.sleep(nanoseconds: 300_000_000) // 300ms debounce
let results = try await GeocodingService.shared.searchCities(query: query)
if !Task.isCancelled {
self.searchResults = results
}
} catch {
if !Task.isCancelled {
print("Search error: \(error)")
}
}
}
}
Debouncing: The search waits 300ms after the user stops typing before making an API request. This reduces server load and provides a smoother user experience.
2. Current Location
The “Use Current Location” button triggers GPS location detection:
func requestLocation() {
isLoading = true
errorMessage = nil
switch locationManager.authorizationStatus {
case .notDetermined:
locationManager.requestWhenInUseAuthorization()
case .authorizedWhenInUse, .authorizedAlways:
locationManager.requestLocation()
case .denied, .restricted:
errorMessage = "Location access denied. Please enable in Settings."
isLoading = false
@unknown default:
errorMessage = "Unknown location authorization status."
isLoading = false
}
}
Location Manager Delegate
The ViewModel conforms to CLLocationManagerDelegate to handle location updates:
extension DashboardViewModel: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.first else { return }
locationName = "Your Location"
// Reverse geocode to get city name
CLGeocoder().reverseGeocodeLocation(location) { [weak self] placemarks, _ in
if let placemark = placemarks?.first {
var components: [String] = []
if let city = placemark.locality {
components.append(city)
}
if let country = placemark.country {
components.append(country)
}
if !components.isEmpty {
self?.locationName = components.joined(separator: ", ")
}
}
}
Task {
await fetchAllData(
latitude: location.coordinate.latitude,
longitude: location.coordinate.longitude
)
}
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
errorMessage = "Unable to get your location."
isLoading = false
}
}
3. Popular Cities
When no search is active, a list of popular cities is displayed:
VStack(alignment: .leading, spacing: 12) {
Text("Popular Cities")
.font(.headline)
.padding(.horizontal)
.padding(.top, 24)
ScrollView {
LazyVStack(spacing: 0) {
ForEach(TopCity.all.prefix(10), id: \.name) { city in
Button {
viewModel.locationName = "\(city.name), \(city.country)"
dismiss()
Task {
await viewModel.fetchAllData(latitude: city.lat, longitude: city.lon)
}
} label: {
HStack {
VStack(alignment: .leading) {
Text(city.name)
.font(.body)
Text(city.country)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
}
.buttonStyle(.plain)
Divider()
.padding(.leading)
}
}
}
}
City Model
Cities are represented with comprehensive location data:
import Foundation
import CoreLocation
struct City: Identifiable, Codable {
let id: Int
let name: String
let country: String?
let admin1: String? // State/Province
let latitude: Double
let longitude: Double
var displayName: String {
var components = [name]
if let admin1 = admin1, !admin1.isEmpty {
components.append(admin1)
}
if let country = country, !country.isEmpty {
components.append(country)
}
return components.joined(separator: ", ")
}
var coordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
}
}
The displayName property creates a hierarchical location string:
- With admin1: “Los Angeles, California, USA”
- Without admin1: “Paris, France”
Top Cities List
A curated list of major cities worldwide:
struct TopCity {
let name: String
let country: String
let lat: Double
let lon: Double
static let all: [TopCity] = [
TopCity(name: "New York", country: "USA", lat: 40.7128, lon: -74.0060),
TopCity(name: "Los Angeles", country: "USA", lat: 34.0522, lon: -118.2437),
TopCity(name: "Chicago", country: "USA", lat: 41.8781, lon: -87.6298),
TopCity(name: "London", country: "UK", lat: 51.5074, lon: -0.1278),
TopCity(name: "Paris", country: "France", lat: 48.8566, lon: 2.3522),
TopCity(name: "Tokyo", country: "Japan", lat: 35.6762, lon: 139.6503),
TopCity(name: "Berlin", country: "Germany", lat: 52.5200, lon: 13.4050),
TopCity(name: "Toronto", country: "Canada", lat: 43.6532, lon: -79.3832),
TopCity(name: "Sydney", country: "Australia", lat: -33.8688, lon: 151.2093),
TopCity(name: "Dubai", country: "UAE", lat: 25.2048, lon: 55.2708)
]
}
Geocoding Service
City search is powered by the Open-Meteo Geocoding API via GeocodingService.swift:
actor GeocodingService {
static let shared = GeocodingService()
private let baseURL = "https://geocoding-api.open-meteo.com/v1/search"
func searchCities(query: String) async throws -> [City] {
var components = URLComponents(string: baseURL)!
components.queryItems = [
URLQueryItem(name: "name", value: query),
URLQueryItem(name: "count", value: "10"),
URLQueryItem(name: "language", value: "en"),
URLQueryItem(name: "format", value: "json")
]
guard let url = components.url else {
throw URLError(.badURL)
}
let (data, _) = try await URLSession.shared.data(from: url)
let response = try JSONDecoder().decode(GeocodingResponse.self, from: data)
return response.results ?? []
}
}
struct GeocodingResponse: Codable {
let results: [City]?
}
User Experience Features
Auto-Focus
The search field automatically gains focus when the sheet opens:
.onAppear {
isSearchFocused = true
}
The search field includes a clear button that appears when text is entered:
if !viewModel.searchQuery.isEmpty {
Button {
viewModel.searchQuery = ""
viewModel.searchResults = []
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
}
Search Validation
Search only triggers when at least 2 characters are entered:
guard query.count >= 2 else {
searchResults = []
return
}
Display states:
- Empty query: Shows popular cities
- Query < 2 chars: Shows popular cities
- Query >= 2 chars, no results: Shows “No results found”
- Query >= 2 chars, has results: Shows search results list
City Selection Flow
- User taps a city (from search results or popular cities)
viewModel.selectCity(city) is called
- Location name is updated
- Search state is cleared
- Modal is dismissed
- Data is fetched for the new location
func selectCity(_ city: City) {
locationName = city.displayName
searchResults = []
searchQuery = ""
Task {
await fetchAllData(latitude: city.latitude, longitude: city.longitude)
}
}
Location Permissions
The app handles various location permission states:
Not Determined
Request permission from the userlocationManager.requestWhenInUseAuthorization()
Authorized
Request current locationlocationManager.requestLocation()
Denied/Restricted
Show error message directing user to SettingserrorMessage = "Location access denied. Please enable in Settings."
Make sure to add the NSLocationWhenInUseUsageDescription key to your Info.plist with an appropriate description of why your app needs location access.
File Locations
- Component:
BreezeApp/Views/Search/SearchView.swift
- Model:
BreezeApp/Models/City.swift
- Service:
BreezeApp/Services/GeocodingService.swift
- ViewModel:
BreezeApp/ViewModels/DashboardViewModel.swift