.environment(\.locale, …) set on an ancestor does not reach the content of a .sheet or .fullScreenCover on Android. The presented content resolves Text catalog keys — and reports @Environment(\.locale) — in the device locale instead.
It is specifically and only \.locale. Every other environment value crosses the presentation boundary correctly.
Reproduction
Device language en-US, locale overridden once at the root:
struct ContentView: View {
var body: some View {
RootView()
.environment(\.locale, Locale(identifier: "fr"))
.environment(\.probeKey, "set-at-root") // custom EnvironmentKey
.environment(\.layoutDirection, .rightToLeft)
.environment(\.timeZone, TimeZone(identifier: "Asia/Tokyo")!)
}
}
struct RootView: View {
@State var isSheetPresented = false
var body: some View {
NavigationStack {
List {
LocaleProbe(context: "inline")
NavigationLink("Push") { LocaleProbe(context: "navigationDestination") }
Button("Present a sheet") { isSheetPresented = true }
}
}
.sheet(isPresented: $isSheetPresented) {
LocaleProbe(context: "sheet")
}
}
}
struct LocaleProbe: View {
let context: String
@Environment(\.locale) var locale
@Environment(\.probeKey) var probeKey
@Environment(\.layoutDirection) var layoutDirection
@Environment(\.timeZone) var timeZone
var body: some View {
VStack(alignment: .leading) {
Text(verbatim: "locale: \(locale.identifier)")
Text("Welcome") // "Bienvenue" in fr, "Welcome" in the en base language
}
.task {
logger.info("context=\(context) locale=\(locale.identifier) customKey=\(probeKey) layoutDirection=\(layoutDirection) timeZone=\(timeZone.identifier)")
}
}
}
Measured on an Android emulator (API 36, device language en-US), Skip Lite:
| context |
\.locale |
custom key |
\.layoutDirection |
\.timeZone |
| inline |
fr |
set-at-root |
rightToLeft |
Asia/Tokyo |
navigationDestination |
fr |
set-at-root |
rightToLeft |
Asia/Tokyo |
.sheet |
en_US |
set-at-root |
rightToLeft |
Asia/Tokyo |
.fullScreenCover |
en_US |
set-at-root |
rightToLeft |
Asia/Tokyo |
.sheet + .environment(\.locale, …) re-applied on its own content |
fr |
set-at-root |
rightToLeft |
Asia/Tokyo |
The Text("Welcome") in the sheet renders "Welcome"; inline it renders "Bienvenue".
Cause
EnvironmentValues.locale is the only environment value that is not stored in one of SkipUI's own composition locals. It reads and writes Compose's LocalConfiguration (Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift:481):
public var locale: Locale {
get { Locale(LocalConfiguration.current.locales[0]) }
set { /* copies LocalConfiguration.current, sets the locale, provides it */ }
}
.sheet and .fullScreenCover render through Material3's ModalBottomSheet, whose content composes in a separate Android window. Compose's ProvideAndroidCompositionLocals re-provides LocalConfiguration for that window from the platform, so the ancestor's overridden Configuration is replaced by the device one. SkipUI's own composition locals are unaffected, because they flow in through the parent composition context — which is why the custom key, layoutDirection, and timeZone all survive while \.locale does not.
Neither Presentation.swift nor PresentationRoot.swift resets the environment; they only add values. The loss happens entirely inside Compose, one layer below.
.alert is not affected — measured: an alert's message resolves against the presenter's locale both before and after the fix. Material3's ModalBottomSheet hosts its content in its own AbstractComposeView, whereas the Dialog an alert uses does not re-provide LocalConfiguration. So this is specific to the sheet/cover path. I did not measure menus or popovers.
.environment(\.locale, …)set on an ancestor does not reach the content of a.sheetor.fullScreenCoveron Android. The presented content resolvesTextcatalog keys — and reports@Environment(\.locale)— in the device locale instead.It is specifically and only
\.locale. Every other environment value crosses the presentation boundary correctly.Reproduction
Device language
en-US, locale overridden once at the root:Measured on an Android emulator (API 36, device language
en-US), Skip Lite:\.locale\.layoutDirection\.timeZonefrset-at-rootrightToLeftAsia/TokyonavigationDestinationfrset-at-rootrightToLeftAsia/Tokyo.sheeten_USset-at-rootrightToLeftAsia/Tokyo.fullScreenCoveren_USset-at-rootrightToLeftAsia/Tokyo.sheet+.environment(\.locale, …)re-applied on its own contentfrset-at-rootrightToLeftAsia/TokyoThe
Text("Welcome")in the sheet renders "Welcome"; inline it renders "Bienvenue".Cause
EnvironmentValues.localeis the only environment value that is not stored in one of SkipUI's own composition locals. It reads and writes Compose'sLocalConfiguration(Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift:481):.sheetand.fullScreenCoverrender through Material3'sModalBottomSheet, whose content composes in a separate Android window. Compose'sProvideAndroidCompositionLocalsre-providesLocalConfigurationfor that window from the platform, so the ancestor's overriddenConfigurationis replaced by the device one. SkipUI's own composition locals are unaffected, because they flow in through the parent composition context — which is why the custom key,layoutDirection, andtimeZoneall survive while\.localedoes not.Neither
Presentation.swiftnorPresentationRoot.swiftresets the environment; they only add values. The loss happens entirely inside Compose, one layer below..alertis not affected — measured: an alert's message resolves against the presenter's locale both before and after the fix. Material3'sModalBottomSheethosts its content in its ownAbstractComposeView, whereas theDialogan alert uses does not re-provideLocalConfiguration. So this is specific to the sheet/cover path. I did not measure menus or popovers.