diff --git a/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/ApplicationLayouts.kt b/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/ApplicationLayouts.kt index 3ebc55fb850f0..8877076d5d8fe 100644 --- a/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/ApplicationLayouts.kt +++ b/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/ApplicationLayouts.kt @@ -367,15 +367,16 @@ fun SwitchEnumState(values: Array, state: MutableState, modifier: Modi @OptIn(ExperimentalMaterial3Api::class) @Composable fun TopBarBasic() { + val accentColor = topBarAccentColor() CenterAlignedTopAppBar( navigationIcon = { - Text("Text", color = Color.Blue) + Text("Text", color = accentColor) }, title = { Text("Chats", fontWeight = FontWeight.Bold) }, actions = { val modifier = Modifier.padding(horizontal = 10.dp) - Icon(Icons.Outlined.Phone, null, modifier, Color.Blue) - Icon(Icons.Outlined.Edit, null, modifier, Color.Blue) + Icon(Icons.Outlined.Phone, null, modifier, accentColor) + Icon(Icons.Outlined.Edit, null, modifier, accentColor) } ) } @@ -384,6 +385,7 @@ fun TopBarBasic() { @Composable fun TopBarWithGradient() { val bgColor = MaterialTheme.colorScheme.background + val accentColor = topBarAccentColor() val green = Color.Green.copy(0.5f).compositeOver(bgColor) val blue = Color.Blue.copy(0.5f).compositeOver(bgColor) Box( @@ -392,8 +394,8 @@ fun TopBarWithGradient() { CenterAlignedTopAppBar( navigationIcon = { Row { - Icon(Icons.Default.ArrowBack, null, tint = Color.Blue) - Text("Back", color = Color.Blue) + Icon(Icons.Default.ArrowBack, null, tint = accentColor) + Text("Back", color = accentColor) } }, title = { @@ -404,8 +406,8 @@ fun TopBarWithGradient() { }, actions = { val modifier = Modifier.padding(horizontal = 10.dp) - Icon(Icons.Outlined.Phone, null, modifier, Color.Blue) - Icon(Icons.Outlined.Edit, null, modifier, Color.Blue) + Icon(Icons.Outlined.Phone, null, modifier, accentColor) + Icon(Icons.Outlined.Edit, null, modifier, accentColor) }, colors = TopAppBarDefaults.centerAlignedTopAppBarColors( containerColor = Color.Transparent @@ -413,3 +415,7 @@ fun TopBarWithGradient() { ) } } + +@Composable +private fun topBarAccentColor(): Color = + if (isSystemInDarkTheme()) Color(0xFF8AB4F8) else Color.Blue diff --git a/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/Screen.kt b/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/Screen.kt index 53d530cd740b2..ea3dbb4aaf295 100644 --- a/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/Screen.kt +++ b/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/Screen.kt @@ -17,6 +17,7 @@ package androidx.compose.mpp.demo import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -154,11 +155,21 @@ private fun ExampleTopBar( back: () -> Unit, ) { TopAppBar( + backgroundColor = topAppBarBackgroundColor(), + contentColor = topAppBarContentColor(), title = { Text(title) }, navigationIcon = { ArrowBackIcon(back) } ) } +@Composable +private fun topAppBarBackgroundColor(): Color = + if (isSystemInDarkTheme()) Color(0xFF3F1D6E) else MaterialTheme.colors.primary + +@Composable +private fun topAppBarContentColor(): Color = + if (isSystemInDarkTheme()) Color.White else MaterialTheme.colors.onPrimary + @Composable private fun SelectionScaffold( title: String, @@ -191,6 +202,8 @@ private fun SelectionTopBar( * out of box in android development or with Material3 Scaffold */ TopAppBar( + backgroundColor = topAppBarBackgroundColor(), + contentColor = topAppBarContentColor(), contentPadding = WindowInsets.systemBars .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) .union(WindowInsets(left = 20.dp)) diff --git a/compose/ui/ui/api/ui.klib.api b/compose/ui/ui/api/ui.klib.api index c31e8751a7d8d..181a27ede18b2 100644 --- a/compose/ui/ui/api/ui.klib.api +++ b/compose/ui/ui/api/ui.klib.api @@ -4942,6 +4942,15 @@ final val androidx.compose.ui.window/androidx_compose_ui_window_KeyboardModeStat // Targets: [js, wasmJs] final val androidx.compose.ui.window/androidx_compose_ui_window_KeyboardModeState_Virtual$stableprop // androidx.compose.ui.window/androidx_compose_ui_window_KeyboardModeState_Virtual$stableprop|#static{}androidx_compose_ui_window_KeyboardModeState_Virtual$stableprop[0] +// Targets: [js, wasmJs] +final val androidx.compose.ui.window/androidx_compose_ui_window_MediaQueryStatus_MATCH$stableprop // androidx.compose.ui.window/androidx_compose_ui_window_MediaQueryStatus_MATCH$stableprop|#static{}androidx_compose_ui_window_MediaQueryStatus_MATCH$stableprop[0] + +// Targets: [js, wasmJs] +final val androidx.compose.ui.window/androidx_compose_ui_window_MediaQueryStatus_NO_MATCH$stableprop // androidx.compose.ui.window/androidx_compose_ui_window_MediaQueryStatus_NO_MATCH$stableprop|#static{}androidx_compose_ui_window_MediaQueryStatus_NO_MATCH$stableprop[0] + +// Targets: [js, wasmJs] +final val androidx.compose.ui.window/androidx_compose_ui_window_MediaQueryStatus_UNSUPPORTED$stableprop // androidx.compose.ui.window/androidx_compose_ui_window_MediaQueryStatus_UNSUPPORTED$stableprop|#static{}androidx_compose_ui_window_MediaQueryStatus_UNSUPPORTED$stableprop[0] + // Targets: [js, wasmJs] final fun androidx.compose.ui.input.pointer/androidx_compose_ui_input_pointer_DummyPointerIcon$stableprop_getter(): kotlin/Int // androidx.compose.ui.input.pointer/androidx_compose_ui_input_pointer_DummyPointerIcon$stableprop_getter|androidx_compose_ui_input_pointer_DummyPointerIcon$stableprop_getter(){}[0] @@ -4963,6 +4972,15 @@ final fun androidx.compose.ui.window/androidx_compose_ui_window_KeyboardModeStat // Targets: [js, wasmJs] final fun androidx.compose.ui.window/androidx_compose_ui_window_KeyboardModeState_Virtual$stableprop_getter(): kotlin/Int // androidx.compose.ui.window/androidx_compose_ui_window_KeyboardModeState_Virtual$stableprop_getter|androidx_compose_ui_window_KeyboardModeState_Virtual$stableprop_getter(){}[0] +// Targets: [js, wasmJs] +final fun androidx.compose.ui.window/androidx_compose_ui_window_MediaQueryStatus_MATCH$stableprop_getter(): kotlin/Int // androidx.compose.ui.window/androidx_compose_ui_window_MediaQueryStatus_MATCH$stableprop_getter|androidx_compose_ui_window_MediaQueryStatus_MATCH$stableprop_getter(){}[0] + +// Targets: [js, wasmJs] +final fun androidx.compose.ui.window/androidx_compose_ui_window_MediaQueryStatus_NO_MATCH$stableprop_getter(): kotlin/Int // androidx.compose.ui.window/androidx_compose_ui_window_MediaQueryStatus_NO_MATCH$stableprop_getter|androidx_compose_ui_window_MediaQueryStatus_NO_MATCH$stableprop_getter(){}[0] + +// Targets: [js, wasmJs] +final fun androidx.compose.ui.window/androidx_compose_ui_window_MediaQueryStatus_UNSUPPORTED$stableprop_getter(): kotlin/Int // androidx.compose.ui.window/androidx_compose_ui_window_MediaQueryStatus_UNSUPPORTED$stableprop_getter|androidx_compose_ui_window_MediaQueryStatus_UNSUPPORTED$stableprop_getter(){}[0] + // Targets: [macosArm64] abstract interface androidx.compose.ui.window/WindowScope { // androidx.compose.ui.window/WindowScope|null[0] abstract val window // androidx.compose.ui.window/WindowScope.window|{}window[0] diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/events/DisposableEventListener.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/events/DisposableEventListener.kt index 5aa90a9b03fe8..d109ed2e79a9f 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/events/DisposableEventListener.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/events/DisposableEventListener.kt @@ -17,8 +17,6 @@ package androidx.compose.ui.events import kotlin.js.js -import kotlinx.browser.document -import kotlinx.browser.window import org.w3c.dom.AddEventListenerOptions import org.w3c.dom.events.Event import org.w3c.dom.events.EventTarget @@ -56,5 +54,4 @@ internal class EventTargetListener(private val eventTarget: EventTarget) { fun dispose() { abortController.abort() } -} - +} \ No newline at end of file diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt index 140d7dbd52076..1ad0f546a81f7 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt @@ -110,7 +110,6 @@ import kotlinx.coroutines.flow.receiveAsFlow import org.jetbrains.skiko.SkiaLayer import org.jetbrains.skiko.SkikoRenderDelegate import org.jetbrains.skiko.hostOs -import org.w3c.dom.AddEventListenerOptions import org.w3c.dom.DocumentReadyState import org.w3c.dom.Element import org.w3c.dom.HTMLCanvasElement @@ -118,7 +117,6 @@ import org.w3c.dom.HTMLDivElement import org.w3c.dom.HTMLElement import org.w3c.dom.HTMLTextAreaElement import org.w3c.dom.LOADING -import org.w3c.dom.MediaQueryListEvent import org.w3c.dom.Node import org.w3c.dom.TouchEvent import org.w3c.dom.events.Event @@ -128,6 +126,7 @@ import org.w3c.dom.events.KeyboardEvent import org.w3c.dom.events.MouseEvent import org.w3c.dom.events.WheelEvent import org.w3c.dom.pointerevents.PointerEvent +import androidx.compose.ui.window.MediaQueryListener private val actualDensity get() = window.devicePixelRatio @@ -153,15 +152,15 @@ internal class DefaultWindowState(private val viewportContainer: Element) : Comp override val globalEvents = EventTargetListener(window) + private var mediaQueryListener: MediaQueryListener? = null + override fun init() { globalEvents.addDisposableEvent("resize") { channel.trySend(getParentContainerBox()) } - initMediaEventListener { - channel.trySend(getParentContainerBox()) - } + recreateMediaQueryListener() channel.trySend(getParentContainerBox()) } @@ -170,16 +169,22 @@ internal class DefaultWindowState(private val viewportContainer: Element) : Comp return IntSize(viewportContainer.clientWidth, viewportContainer.clientHeight) } - private fun initMediaEventListener(handler: (Double) -> Unit) { + private fun recreateMediaQueryListener() { + mediaQueryListener?.dispose() val contentScale = actualDensity - window.matchMedia("(resolution: ${contentScale}dppx)") - .addEventListener("change", { evt -> - evt as MediaQueryListEvent - if (!evt.matches) { - handler(contentScale) + mediaQueryListener = object : MediaQueryListener("(resolution: ${contentScale}dppx)") { + override fun onChange(matches: Boolean) { + if (!matches) { + channel.trySend(getParentContainerBox()) } - initMediaEventListener(handler) - }, AddEventListenerOptions(capture = true, once = true)) + recreateMediaQueryListener() + } + } + } + + override fun dispose() { + mediaQueryListener?.dispose() + super.dispose() } override fun sizeFlow() = channel.receiveAsFlow() diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/MediaQueryListener.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/MediaQueryListener.kt new file mode 100644 index 0000000000000..f305042934e7d --- /dev/null +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/MediaQueryListener.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.window + +import kotlin.js.js +import kotlinx.browser.window +import org.w3c.dom.MediaQueryList +import org.w3c.dom.MediaQueryListEvent +import org.w3c.dom.events.Event + +internal abstract class MediaQueryListener(private val query: String) { + private val media: MediaQueryList by lazy { + window.matchMedia(query) + } + + abstract fun onChange(matches: Boolean) + + private val listener: (Event) -> Unit = { event -> + event as MediaQueryListEvent + onChange(event.matches) + } + + fun dispose() { + if (!isMatchMediaSupported()) return + try { + media.removeEventListener("change", listener) + } catch (t : Throwable) { + media.removeListener(listener) + } + } + + init { + if (isMatchMediaSupported()) { + try { + media.addEventListener("change", listener) + } catch (t: Throwable) { + media.addListener(listener) + } + } + } + + fun matches(): MediaQueryStatus { + return when { + !isMatchMediaSupported() -> MediaQueryStatus.UNSUPPORTED + else -> if (media.matches) MediaQueryStatus.MATCH else MediaQueryStatus.NO_MATCH + } + } +} + +internal sealed interface MediaQueryStatus { + object UNSUPPORTED : MediaQueryStatus + object MATCH : MediaQueryStatus + object NO_MATCH : MediaQueryStatus +} + +// supported by all browsers since 2015 +// https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia +// Changed from `@JsFun` annotation because in 2.2.20 it's marked as not available on LV = 2.0 +// TODO: Cannot add opt-in with LV = 2.0 due to https://youtrack.jetbrains.com/issue/KT-79716 +private fun isMatchMediaSupported(): Boolean = js("window.matchMedia != undefined") \ No newline at end of file diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/SystemThemeObserver.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/SystemThemeObserver.web.kt index b9fa9724f4815..7e6479dbe59b0 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/SystemThemeObserver.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/SystemThemeObserver.web.kt @@ -18,13 +18,7 @@ package androidx.compose.ui.window import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf -import kotlin.js.js -import kotlinx.browser.window import org.jetbrains.skiko.SystemTheme -import org.w3c.dom.MediaQueryList -import org.w3c.dom.MediaQueryListEvent -import org.w3c.dom.Window -import org.w3c.dom.events.Event internal interface SystemThemeObserver { val currentSystemTheme: State @@ -32,54 +26,27 @@ internal interface SystemThemeObserver { fun dispose() } -internal class SystemThemeObserverImpl(window : Window) : SystemThemeObserver { - +private class SystemThemeObserverImpl : SystemThemeObserver { override val currentSystemTheme: State get() = _currentSystemTheme - private val media: MediaQueryList by lazy { - window.matchMedia("(prefers-color-scheme: dark)") + private val mediaQueryListener: MediaQueryListener = object : MediaQueryListener("(prefers-color-scheme: dark)") { + override fun onChange(matches: Boolean) { + _currentSystemTheme.value = if (matches) SystemTheme.DARK else SystemTheme.LIGHT + } } private val _currentSystemTheme = mutableStateOf( - when { - !isMatchMediaSupported() -> SystemTheme.UNKNOWN - media.matches -> SystemTheme.DARK - else -> SystemTheme.LIGHT + when(mediaQueryListener.matches()) { + MediaQueryStatus.MATCH -> SystemTheme.DARK + MediaQueryStatus.NO_MATCH -> SystemTheme.LIGHT + MediaQueryStatus.UNSUPPORTED -> SystemTheme.UNKNOWN } ) - private val listener: (Event) -> Unit = { event -> - _currentSystemTheme.value = if ((event as MediaQueryListEvent).matches) - SystemTheme.DARK else SystemTheme.LIGHT - } - override fun dispose() { - if (isMatchMediaSupported()) { - try { - media.removeEventListener("change", listener) - } catch (t : Throwable) { - media.removeListener(listener) - } - } - } - - init { - if (isMatchMediaSupported()) { - try { - media.addEventListener("change", listener) - } catch (t: Throwable) { - media.addListener(listener) - } - } + mediaQueryListener.dispose() } } -internal fun getSystemThemeObserver(): SystemThemeObserver = - SystemThemeObserverImpl(window) - -// supported by all browsers since 2015 -// https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia -// Changed from `@JsFun` annotation because in 2.2.20 it's marked as not available on LV = 2.0 -// TODO: Cannot add opt-in with LV = 2.0 due to https://youtrack.jetbrains.com/issue/KT-79716 -private fun isMatchMediaSupported(): Boolean = js("window.matchMedia != undefined") \ No newline at end of file +internal fun getSystemThemeObserver(): SystemThemeObserver = SystemThemeObserverImpl()