FO
ForgeObservers

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.

AppLocale.swift
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.

LocaleObserving.swift
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.

Locale.swift
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.

SettingsViewModel.swift
@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.

Test with injected dependencies
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")
}