LocaleObserver
Detects locale changes when the user updates device language or region settings.
AppLocale
A lightweight struct that captures the language code and optional region code. You can create one from explicit values or from the current system Locale.
public struct AppLocale: Sendable, Equatable {
public let languageCode: String
public let regionCode: String?
/// Create from explicit values
public init(languageCode: String, regionCode: String? = nil)
/// Create from the current system Locale
public init(from locale: Locale = .current)
} LocaleObserving Protocol
The protocol exposes the current locale and an async stream. Unlike most other observers, localeStream emits the current value immediately on subscription, so you don't need to read current separately.
public protocol LocaleObserving: Sendable {
/// The current locale (synchronous read)
var current: AppLocale { get }
/// Stream of locale changes.
/// Emits the current value immediately on subscription,
/// then emits again whenever the user changes device language or region.
var localeStream: AsyncStream<AppLocale> { get }
} Usage
Create a LocaleObserver and subscribe to localeStream to react when the user changes their device language or region in Settings.
import ForgeObservers
let locale = LocaleObserver()
// Read the current locale
print("Language: \(locale.current.languageCode)")
print("Region: \(locale.current.regionCode ?? "unknown")")
// Subscribe to changes
Task {
for await appLocale in locale.localeStream {
print("Locale changed to \(appLocale.languageCode)")
reloadLocalizedStrings()
}
} ViewModel Example
Bind locale properties to a ViewModel for display in your UI.
@Observable
final class SettingsViewModel {
private let locale: LocaleObserving
var languageCode = ""
var regionCode = ""
init(locale: LocaleObserving) {
self.locale = locale
self.languageCode = locale.current.languageCode
self.regionCode = locale.current.regionCode ?? ""
}
func startObserving() async {
for await appLocale in locale.localeStream {
languageCode = appLocale.languageCode
regionCode = appLocale.regionCode ?? ""
}
}
} Testing
LocaleObserver accepts both an injectable NotificationCenter and a localeProvider closure. The provider lets tests return synthetic locales instead of whatever the simulator's real locale happens to be.
import Foundation
import Synchronization
import ForgeObservers
@Test
func localeObserverReactsToChange() async throws {
let center = NotificationCenter()
let current = Mutex(AppLocale(languageCode: "en", regionCode: "US"))
// Inject both the NotificationCenter and a stub localeProvider
let observer = LocaleObserver(
notificationCenter: center,
localeProvider: { current.withLock { $0 } }
)
// Swap the "system" locale, then post the change notification
current.withLock { $0 = AppLocale(languageCode: "de", regionCode: "DE") }
center.post(name: NSLocale.currentLocaleDidChangeNotification, object: nil)
try await Task.sleep(for: .milliseconds(50))
#expect(observer.current.languageCode == "de")
}