diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/MediaEnvironment.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/MediaEnvironment.ios.kt new file mode 100644 index 0000000000000..bb9033b791799 --- /dev/null +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/MediaEnvironment.ios.kt @@ -0,0 +1,285 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalMediaQueryApi::class) + +package androidx.compose.ui.platform + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.ExperimentalMediaQueryApi +import androidx.compose.ui.UiMediaScope +import androidx.compose.ui.uikit.InterfaceOrientation +import androidx.compose.ui.uikit.density +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import org.jetbrains.skiko.SystemTheme +import androidx.compose.ui.uikit.utils.CMPKeyValueObserver +import androidx.compose.ui.uikit.utils.CMPUIWindowSceneUtils +import androidx.compose.ui.window.KeyboardVisibilityListener +import androidx.compose.ui.window.KeyboardVisibilitySubscriber +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.CPointed +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.CValue +import kotlinx.cinterop.ObjCAction +import kotlinx.cinterop.useContents +import platform.AVFoundation.AVCaptureDevice +import platform.AVFoundation.AVCaptureDeviceWasConnectedNotification +import platform.AVFoundation.AVCaptureDeviceWasDisconnectedNotification +import platform.AVFoundation.AVMediaTypeAudio +import platform.AVFoundation.AVMediaTypeVideo +import platform.CoreGraphics.CGRect +import platform.Foundation.NSKeyValueObservingOptionNew +import platform.Foundation.NSNotification +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSSelectorFromString +import platform.Foundation.addObserver +import platform.Foundation.removeObserver +import platform.darwin.NSObject +import platform.UIKit.UIUserInterfaceStyle +import platform.UIKit.UIViewAnimationOptions +import platform.UIKit.UIWindow +import platform.UIKit.UIWindowScene + +internal class MediaEnvironment(val windowInfo: WindowInfo) : UiMediaScope, KeyboardVisibilitySubscriber { + + private var window: UIWindow? = null + /* + * Initial value is arbitrarily chosen to avoid propagating invalid value logic + * It's never the case in the real usage scenario to reflect that in type system + */ + internal val interfaceOrientationState: MutableState = mutableStateOf( + InterfaceOrientation.Portrait + ) + private val systemThemeState: MutableState = mutableStateOf(SystemTheme.UNKNOWN) + + private val systemDensityState: MutableState = mutableStateOf(window?.density ?: Density(1f)) + + private val isImeShowing = mutableStateOf(KeyboardVisibilityListener.keyboardFrame.useContents { size.height > 0 }) + private val pointerPrecisionState: MutableState = mutableStateOf( + UiMediaScope.PointerPrecision.Coarse + ) + + private val hasMicrophoneState: MutableState = mutableStateOf(detectHasMicrophone()) + private val hasCameraState: MutableState = mutableStateOf(detectHasCamera()) + + private fun detectHasMicrophone(): Boolean = + AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeAudio) != null + + private fun detectHasCamera(): Boolean = + AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo) != null + + + private fun updateCaptureDeviceAvailabilityState() { + hasMicrophoneState.value = detectHasMicrophone() + hasCameraState.value = detectHasCamera() + } + + private val captureDeviceAvailabilityObserver = CaptureDeviceAvailabilityObserver(::updateCaptureDeviceAvailabilityState) + + fun updateInterfaceOrientationState() { + currentInterfaceOrientation?.let { + interfaceOrientationState.value = it + } + } + fun updateUserInterfaceStyle(style: UIUserInterfaceStyle) { + systemThemeState.value = style.asComposeSystemTheme() + } + fun updatePointerPrecision(precision: UiMediaScope.PointerPrecision) { + pointerPrecisionState.value = precision + } + + private val currentInterfaceOrientation: InterfaceOrientation? + get() { + return InterfaceOrientation.getByRawValue( + CMPUIWindowSceneUtils.interfaceOrientationForWindowScene(window?.windowScene) + ) + } + + private val interfaceOrientationObserver = SceneGeometryObserver { + updateInterfaceOrientationState() + } + fun onDidMoveToWindow(window: UIWindow?) { + this.window = window + interfaceOrientationObserver.windowScene = window?.windowScene + window ?: return + + systemDensityState.value = window.density + updateInterfaceOrientationState() + + } + + val systemTheme: SystemTheme + get() = systemThemeState.value + val systemDensity: Density + get() = systemDensityState.value + + fun startObserving() { + interfaceOrientationObserver.isObservingEnabled = true + captureDeviceAvailabilityObserver.isObservingEnabled = true + KeyboardVisibilityListener.addSubscriber(this) + } + + fun stopObserving() { + interfaceOrientationObserver.isObservingEnabled = false + captureDeviceAvailabilityObserver.isObservingEnabled = false + KeyboardVisibilityListener.removeSubscriber(this) + } + + override val windowPosture: UiMediaScope.Posture + get() = UiMediaScope.Posture.Flat //iOS doesn't have foldables yet! + override val windowWidth: Dp + get() = windowInfo.containerDpSize.width + override val windowHeight: Dp + get() = windowInfo.containerDpSize.height + override val pointerPrecision: UiMediaScope.PointerPrecision + get() = pointerPrecisionState.value + override val keyboardKind: UiMediaScope.KeyboardKind + get() = when { + isImeShowing.value -> UiMediaScope.KeyboardKind.Virtual + else -> UiMediaScope.KeyboardKind.None + } + override val hasMicrophone: Boolean + get() = hasMicrophoneState.value + override val hasCamera: Boolean + get() = hasCameraState.value + override val viewingDistance: UiMediaScope.ViewingDistance + get() = UiMediaScope.ViewingDistance.Near + + override fun keyboardWillShow( + targetFrame: CValue, + duration: Double, + animationOptions: UIViewAnimationOptions + ) { + isImeShowing.value = targetFrame.useContents { size.height > 0 } + } + + override fun keyboardWillHide( + targetFrame: CValue, + duration: Double, + animationOptions: UIViewAnimationOptions + ) { + isImeShowing.value = false //targetFrame is CGRectZero.readValue() + } + + override fun keyboardWillChangeFrame( + targetFrame: CValue, + duration: Double, + animationOptions: UIViewAnimationOptions + ) { + isImeShowing.value = targetFrame.useContents { size.height > 0 } + } +} + + +private class SceneGeometryObserver( + val onGeometryChanged: () -> Unit +) : CMPKeyValueObserver() { + private val observingKey = "effectiveGeometry" + + var windowScene: UIWindowScene? = null + set(value) { + if (field == value) return + removeObserverIfNeeded() + field = value + addObserverIfNeeded() + } + + var isObservingEnabled = false + set(value) { + if (field == value) return + field = value + if (value) { + addObserverIfNeeded() + } else { + removeObserverIfNeeded() + } + } + + private var isObservingAdded = false + + private fun addObserverIfNeeded() { + if (isObservingEnabled && !isObservingAdded) { + isObservingAdded = true + windowScene?.addObserver(this, observingKey, NSKeyValueObservingOptionNew, null) + } + } + + private fun removeObserverIfNeeded() { + windowScene?.removeObserver(this, observingKey) + isObservingAdded = false + } + + override fun observeValueForKeyPath( + keyPath: String?, + ofObject: Any?, + change: Map?, + context: CPointer? + ) { + onGeometryChanged() + } +} + +private class CaptureDeviceAvailabilityObserver( + val onDeviceAvailabilityChanged: () -> Unit, + private val notificationCenter: NSNotificationCenter = NSNotificationCenter.defaultCenter +) : NSObject() { + + var isObservingEnabled = false + set(value) { + if (field == value) return + field = value + if (value) { + addObservers() + } else { + removeObservers() + } + } + + private fun addObservers() { + notificationCenter.addObserver( + observer = this, + selector = NSSelectorFromString(::deviceAvailabilityDidChange.name + ":"), + name = AVCaptureDeviceWasConnectedNotification, + `object` = null + ) + notificationCenter.addObserver( + observer = this, + selector = NSSelectorFromString(::deviceAvailabilityDidChange.name + ":"), + name = AVCaptureDeviceWasDisconnectedNotification, + `object` = null + ) + } + + private fun removeObservers() { + notificationCenter.removeObserver(this) + } + + @OptIn(BetaInteropApi::class) + @ObjCAction + fun deviceAvailabilityDidChange(arg: NSNotification) { + onDeviceAvailabilityChanged() + } +} + +private fun UIUserInterfaceStyle.asComposeSystemTheme(): SystemTheme { + return when (this) { + UIUserInterfaceStyle.UIUserInterfaceStyleLight -> SystemTheme.LIGHT + UIUserInterfaceStyle.UIUserInterfaceStyleDark -> SystemTheme.DARK + else -> SystemTheme.UNKNOWN + } +} \ No newline at end of file diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt index 6f9b4ae59e68a..b972292af3bc4 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt @@ -19,25 +19,21 @@ package androidx.compose.ui.scene import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionContext import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.LocalSystemTheme import androidx.compose.ui.graphics.asComposeCanvas import androidx.compose.ui.navigationevent.IosBackNavigationEventInput import androidx.compose.ui.platform.DefaultArchitectureComponentsOwner import androidx.compose.ui.platform.FrameChoreographer +import androidx.compose.ui.platform.MediaEnvironment import androidx.compose.ui.platform.MotionDurationScaleImpl import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.WindowContext import androidx.compose.ui.platform.registerSkikoComposeImplementation import androidx.compose.ui.uikit.ComposeContainerConfiguration -import androidx.compose.ui.uikit.InterfaceOrientation import androidx.compose.ui.uikit.LocalUIViewController import androidx.compose.ui.uikit.PlistSanityCheck import androidx.compose.ui.uikit.density import androidx.compose.ui.uikit.embedSubview -import androidx.compose.ui.uikit.utils.CMPKeyValueObserver -import androidx.compose.ui.uikit.utils.CMPUIWindowSceneUtils import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.LayoutDirection @@ -132,9 +128,7 @@ internal class ComposeContainer( get() = mediatorComponentsOwner ?: error("ArchitectureComponentsOwner is not initialized yet.") - private val interfaceOrientationObserver = SceneGeometryObserver { - updateInterfaceOrientationState() - } + private val mediaEnvironment = MediaEnvironment(windowContext.windowInfo) private val navigationEventInput = IosBackNavigationEventInput( density = view.density, initialLayoutDirection = layoutDirection, @@ -148,14 +142,6 @@ internal class ComposeContainer( ) val hasInteropViews: Boolean get() = mediator?.hasInteropViews ?: false - /* - * Initial value is arbitrarily chosen to avoid propagating invalid value logic - * It's never the case in the real usage scenario to reflect that in type system - */ - private val interfaceOrientationState: MutableState = mutableStateOf( - InterfaceOrientation.Portrait - ) - private val systemThemeState: MutableState = mutableStateOf(SystemTheme.UNKNOWN) private val focusedViewsList = FocusedViewsList() @@ -186,13 +172,6 @@ internal class ComposeContainer( return mediator?.hasInvalidations == true || layersHolder?.layersViewController?.hasInvalidations == true } - private val currentInterfaceOrientation: InterfaceOrientation? - get() { - return InterfaceOrientation.getByRawValue( - CMPUIWindowSceneUtils.interfaceOrientationForWindowScene(windowScene) - ) - } - private fun onLayoutSubviews() { windowContext.updateWindowContainerSize() @@ -206,11 +185,10 @@ internal class ComposeContainer( private fun onDidMoveToWindow(window: UIWindow?) { navigationEventInput.onDidMoveToWindow(window, view) - interfaceOrientationObserver.windowScene = window?.windowScene + mediaEnvironment.onDidMoveToWindow(window) window ?: return - updateInterfaceOrientationState() layersHolder?.layersViewController?.containerWindow = view.window windowContext.window = window @@ -218,11 +196,7 @@ internal class ComposeContainer( lifecycleDelegate.windowScene = window.windowScene } - fun updateInterfaceOrientationState() { - currentInterfaceOrientation?.let { - interfaceOrientationState.value = it - } - } + fun updateInterfaceOrientationState() = mediaEnvironment.updateInterfaceOrientationState() fun sceneDidAppear() { mediator?.sceneDidAppear() @@ -241,9 +215,7 @@ internal class ComposeContainer( navigationEventInput.onDidMoveToWindow(null, view) } - fun updateUserInterfaceStyle(style: UIUserInterfaceStyle) { - systemThemeState.value = style.asComposeSystemTheme() - } + fun updateUserInterfaceStyle(style: UIUserInterfaceStyle) = mediaEnvironment.updateUserInterfaceStyle(style) fun initializeComposeScene() { sceneJob = Job() @@ -314,7 +286,7 @@ internal class ComposeContainer( ) }, navigationEventInput = navigationEventInput, - interfaceOrientationState = interfaceOrientationState, + mediaEnvironment = mediaEnvironment, ).also { mediator -> view.embedSubview(mediator.backgroundView) view.updateMetalView( @@ -338,7 +310,7 @@ internal class ComposeContainer( } } - interfaceOrientationObserver.isObservingEnabled = true + mediaEnvironment.startObserving() architectureComponentsOwner.navigationEventDispatcher.addInput(navigationEventInput) lifecycleDelegate.windowScene = windowScene @@ -365,7 +337,7 @@ internal class ComposeContainer( layersHolder = null - interfaceOrientationObserver.isObservingEnabled = false + mediaEnvironment.stopObserving() } private fun createComposeSceneContext( @@ -409,7 +381,7 @@ internal class ComposeContainer( consumePointerInputOutside = consumePointerInputOutside, parentCoroutineContext = containerCoroutineContext, ownerProvider = architectureComponentsOwner, - interfaceOrientationState = interfaceOrientationState, + mediaEnvironment = mediaEnvironment, invalidateLayout = { layersHolder.getLayersViewController().invalidateLayout() }, invalidateDraw = { layersHolder.getLayersViewController().invalidateDraw() }, ) @@ -461,7 +433,7 @@ internal class ComposeContainer( private fun ProvideContainerCompositionLocals(content: @Composable () -> Unit) = CompositionLocalProvider( LocalUIViewController provides containingViewController, - LocalSystemTheme provides systemThemeState.value, + LocalSystemTheme provides mediaEnvironment.systemTheme, content = content ) @@ -479,14 +451,6 @@ internal class ComposeContainer( get() = view.window?.windowScene } -private fun UIUserInterfaceStyle.asComposeSystemTheme(): SystemTheme { - return when (this) { - UIUserInterfaceStyle.UIUserInterfaceStyleLight -> SystemTheme.LIGHT - UIUserInterfaceStyle.UIUserInterfaceStyleDark -> SystemTheme.DARK - else -> SystemTheme.UNKNOWN - } -} - private fun getApplicationLayoutDirection() = when (UIApplication.sharedApplication().userInterfaceLayoutDirection) { UIUserInterfaceLayoutDirectionRightToLeft -> LayoutDirection.Rtl @@ -515,54 +479,6 @@ private class ComposeLayersHolder( } } -private class SceneGeometryObserver( - val onGeometryChanged: () -> Unit -) : CMPKeyValueObserver() { - private val observingKey = "effectiveGeometry" - - var windowScene: UIWindowScene? = null - set(value) { - if (field == value) return - removeObserverIfNeeded() - field = value - addObserverIfNeeded() - } - - var isObservingEnabled = false - set(value) { - if (field == value) return - field = value - if (value) { - addObserverIfNeeded() - } else { - removeObserverIfNeeded() - } - } - - private var isObservingAdded = false - - private fun addObserverIfNeeded() { - if (isObservingEnabled && !isObservingAdded) { - isObservingAdded = true - windowScene?.addObserver(this, observingKey, NSKeyValueObservingOptionNew, null) - } - } - - private fun removeObserverIfNeeded() { - windowScene?.removeObserver(this, observingKey) - isObservingAdded = false - } - - override fun observeValueForKeyPath( - keyPath: String?, - ofObject: Any?, - change: Map?, - context: CPointer? - ) { - onGeometryChanged() - } -} - private fun UIUserInterfaceLayoutDirection.asLayoutDirection(): LayoutDirection = when (this) { UIUserInterfaceLayoutDirectionLeftToRight -> LayoutDirection.Ltr UIUserInterfaceLayoutDirectionRightToLeft -> LayoutDirection.Rtl diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt index 24511ad38fba9..99f40a2022e49 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt @@ -25,7 +25,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.annotation.VisibleForTesting +import androidx.compose.ui.ExperimentalMediaQueryApi import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.UiMediaScope import androidx.compose.ui.draganddrop.IosDragAndDropManager import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect @@ -65,12 +67,12 @@ import androidx.compose.ui.platform.PlatformScreenReader import androidx.compose.ui.platform.PlatformTextInputMethodRequest import androidx.compose.ui.platform.WindowContext import androidx.compose.ui.platform.ApplicationIdleTimer +import androidx.compose.ui.platform.MediaEnvironment import androidx.compose.ui.platform.TextInputService import androidx.compose.ui.platform.WindowInsetsManager import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.WindowInfo import androidx.compose.ui.semantics.SemanticsOwner -import androidx.compose.ui.uikit.InterfaceOrientation import androidx.compose.ui.uikit.LocalNativeTextInputContext import androidx.compose.ui.uikit.LocalUIView import androidx.compose.ui.uikit.OnFocusBehavior @@ -195,7 +197,7 @@ internal class ComposeSceneMediator( private val architectureComponentsOwner: PlatformArchitectureComponentsOwner, val coroutineContext: CoroutineContext, private val navigationEventInput: IosBackNavigationEventInput, - interfaceOrientationState: State, + private val mediaEnvironment: MediaEnvironment, composeSceneFactory: (platformContext: PlatformContext) -> ComposeScene, ) { private var onPreviewKeyEvent: (KeyEvent) -> Boolean = { false } @@ -385,7 +387,7 @@ internal class ComposeSceneMediator( { _overlayView }, { windowContext.window?.rootViewController?.view }, ), - interfaceOrientation = interfaceOrientationState + interfaceOrientation = mediaEnvironment.interfaceOrientationState ) /** @@ -493,12 +495,14 @@ internal class ComposeSceneMediator( } } + @OptIn(ExperimentalMediaQueryApi::class) private fun onScrollEvent( position: DpOffset, delta: DpOffset, event: UIEvent?, eventKind: TouchesEventKind ) { + mediaEnvironment.updatePointerPrecision(UiMediaScope.PointerPrecision.Fine) when (eventKind) { TouchesEventKind.BEGAN -> activitiesHandler.onActivitiesStarted() TouchesEventKind.MOVED -> {} @@ -522,11 +526,13 @@ internal class ComposeSceneMediator( ) } + @OptIn(ExperimentalMediaQueryApi::class) private fun onHoverEvent( position: DpOffset, event: UIEvent?, eventKind: TouchesEventKind ) { + mediaEnvironment.updatePointerPrecision(UiMediaScope.PointerPrecision.Fine) val eventType = when (eventKind) { TouchesEventKind.BEGAN -> PointerEventType.Enter TouchesEventKind.MOVED -> PointerEventType.Move @@ -565,6 +571,7 @@ internal class ComposeSceneMediator( * @param event the [UIEvent] associated with the touches * @param eventKind the [TouchesEventKind] of the touches */ + @OptIn(ExperimentalMediaQueryApi::class) private fun onTouchesEvent( touches: Set<*>, event: UIEvent?, @@ -576,6 +583,7 @@ internal class ComposeSceneMediator( TouchesEventKind.MOVED -> {} } + var anyIsStylus = false val pointers = touches.mapIndexed { index, touch -> touch as UITouch val position = touch.offsetInView(_backgroundView, screenDensity.density) @@ -585,6 +593,9 @@ internal class ComposeSceneMediator( UITouchTypePencil -> PointerType.Stylus else -> PointerType.Touch } + if (pointerType == PointerType.Stylus) { + anyIsStylus = true + } val id = touch.hashCode().toLong().takeIf { pointerType != PointerType.Mouse } ?: index.toLong() @@ -602,6 +613,12 @@ internal class ComposeSceneMediator( ) } + if (anyIsStylus) { + mediaEnvironment.updatePointerPrecision(UiMediaScope.PointerPrecision.Fine) + } else { + mediaEnvironment.updatePointerPrecision(UiMediaScope.PointerPrecision.Coarse) + } + // UIKit sends buttonMask that was before the release action. It should be empty if no // pressed pointers left. val pointerButtonsMask = event.buttonMaskOrZero.takeIf { @@ -885,6 +902,8 @@ internal class ComposeSceneMediator( private inner class IosPlatformContext : PlatformContext { override val windowInfo: WindowInfo get() = windowContext.windowInfo + @OptIn(ExperimentalMediaQueryApi::class) + override val mediaEnvironment: UiMediaScope get() = this@ComposeSceneMediator.mediaEnvironment override val architectureComponentsOwner get() = this@ComposeSceneMediator.architectureComponentsOwner override val screenReader: PlatformScreenReader get() = platformScreenReader diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/IosComposeSceneLayer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/IosComposeSceneLayer.ios.kt index 26bbc7b3a1428..c5ef137794dd4 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/IosComposeSceneLayer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/IosComposeSceneLayer.ios.kt @@ -28,10 +28,10 @@ import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.navigationevent.IosBackNavigationEventInput import androidx.compose.ui.platform.FrameChoreographer +import androidx.compose.ui.platform.MediaEnvironment import androidx.compose.ui.platform.PlatformArchitectureComponentsOwner import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.uikit.ComposeContainerConfiguration -import androidx.compose.ui.uikit.InterfaceOrientation import androidx.compose.ui.uikit.LocalUIViewController import androidx.compose.ui.uikit.density import androidx.compose.ui.uikit.embedSubview @@ -61,7 +61,7 @@ internal class IosComposeSceneLayer( consumePointerInputOutside: Boolean = focusedViewsList != null, parentCoroutineContext: CoroutineContext, private val ownerProvider: PlatformArchitectureComponentsOwner, - private val interfaceOrientationState: State, + private val mediaEnvironment: MediaEnvironment, private var invalidateLayout: () -> Unit, private var invalidateDraw: () -> Unit, ) : ComposeSceneLayer { @@ -115,7 +115,7 @@ internal class IosComposeSceneLayer( coroutineContext = layerCoroutineContext, composeSceneFactory = ::createComposeScene, navigationEventInput = navigationEventInput, - interfaceOrientationState = interfaceOrientationState + mediaEnvironment = mediaEnvironment ).also { interactionView.embedSubview(it.backgroundView) it.isInterceptingOutsideEvents = consumePointerInputOutside diff --git a/compose/ui/ui/src/iosTest/kotlin/androidx/compose/ui/integrations/ComposeSceneMediatorUnitTest.kt b/compose/ui/ui/src/iosTest/kotlin/androidx/compose/ui/integrations/ComposeSceneMediatorUnitTest.kt index f43e54a892bba..6339022cf4092 100644 --- a/compose/ui/ui/src/iosTest/kotlin/androidx/compose/ui/integrations/ComposeSceneMediatorUnitTest.kt +++ b/compose/ui/ui/src/iosTest/kotlin/androidx/compose/ui/integrations/ComposeSceneMediatorUnitTest.kt @@ -16,17 +16,16 @@ package androidx.compose.ui.integrations -import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.navigationevent.IosBackNavigationEventInput import androidx.compose.ui.platform.DefaultArchitectureComponentsOwner import androidx.compose.ui.platform.FrameChoreographer +import androidx.compose.ui.platform.MediaEnvironment import androidx.compose.ui.platform.WindowContext import androidx.compose.ui.platform.registerSkikoComposeImplementation import androidx.compose.ui.scene.ComposeSceneContext import androidx.compose.ui.scene.ComposeSceneMediator import androidx.compose.ui.scene.PlatformLayersComposeScene import androidx.compose.ui.uikit.EndEdgePanGestureBehavior -import androidx.compose.ui.uikit.InterfaceOrientation import androidx.compose.ui.uikit.OnFocusBehavior import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntOffset @@ -93,32 +92,36 @@ class ComposeSceneMediatorUnitTest { private fun makeMediator( coroutineContext: CoroutineContext, frameChoreographer: FrameChoreographer = FrameChoreographer.choreographerForScene(UIWindowScene()), - ): ComposeSceneMediator = ComposeSceneMediator( - frameChoreographer = frameChoreographer, - onFocusBehavior = OnFocusBehavior.DoNothing, - isClearFocusOnMouseDownEnabled = false, - focusedViewsList = null, - windowContext = WindowContext(), - architectureComponentsOwner = DefaultArchitectureComponentsOwner(), - coroutineContext = coroutineContext, - navigationEventInput = IosBackNavigationEventInput( - density = Density(1f), - initialLayoutDirection = LayoutDirection.Ltr, - getTopLeftOffsetInWindow = { IntOffset.Zero }, - endEdgePanGestureBehavior = EndEdgePanGestureBehavior.Disabled, - ), - interfaceOrientationState = mutableStateOf(InterfaceOrientation.Portrait), - composeSceneFactory = { platformContext -> - registerSkikoComposeImplementation() - PlatformLayersComposeScene( - frameRecomposer = frameChoreographer.frameRecomposer, + ): ComposeSceneMediator { + val windowContext = WindowContext() + val mediaEnvironment = MediaEnvironment(windowContext.windowInfo) + return ComposeSceneMediator( + frameChoreographer = frameChoreographer, + onFocusBehavior = OnFocusBehavior.DoNothing, + isClearFocusOnMouseDownEnabled = false, + focusedViewsList = null, + windowContext = windowContext, + architectureComponentsOwner = DefaultArchitectureComponentsOwner(), + coroutineContext = coroutineContext, + navigationEventInput = IosBackNavigationEventInput( density = Density(1f), - composeSceneContext = object : ComposeSceneContext { - override val platformContext = platformContext - }, - invalidateLayout = {}, - invalidateDraw = {}, - ) - }, - ) + initialLayoutDirection = LayoutDirection.Ltr, + getTopLeftOffsetInWindow = { IntOffset.Zero }, + endEdgePanGestureBehavior = EndEdgePanGestureBehavior.Disabled, + ), + mediaEnvironment = mediaEnvironment, + composeSceneFactory = { platformContext -> + registerSkikoComposeImplementation() + PlatformLayersComposeScene( + frameRecomposer = frameChoreographer.frameRecomposer, + density = Density(1f), + composeSceneContext = object : ComposeSceneContext { + override val platformContext = platformContext + }, + invalidateLayout = {}, + invalidateDraw = {}, + ) + }, + ) + } } diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/CompositionLocals.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/CompositionLocals.skiko.kt index 770d56dd94907..c8f948a0e217d 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/CompositionLocals.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/CompositionLocals.skiko.kt @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@file:OptIn(ExperimentalMediaQueryApi::class) package androidx.compose.ui.platform @@ -25,7 +26,9 @@ import androidx.compose.runtime.ProvidedValue import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.LocalSaveableStateRegistry import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.ExperimentalMediaQueryApi import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.LocalUiMediaScope import androidx.lifecycle.LifecycleOwner import androidx.savedstate.compose.LocalSavedStateRegistryOwner @@ -94,6 +97,7 @@ internal fun ProvidePlatformCompositionLocals( LocalPlatformScreenReader provides platformContext.screenReader, LocalPlatformWindowInsets provides platformContext.windowInsets, LocalPlatformPrefetchScheduler provides platformContext.prefetchScheduler, + LocalUiMediaScope provides platformContext.mediaEnvironment, androidx.lifecycle.compose.LocalLifecycleOwner provides platformContext.architectureComponentsOwner.lifecycleOwner, LocalSavedStateRegistryOwner provides platformContext.architectureComponentsOwner.savedStateRegistryOwner, LocalSaveableStateRegistry provides saveableStateRegistry, diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.skiko.kt deleted file mode 100644 index a0c91764e5190..0000000000000 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.skiko.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2020 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.platform - -import androidx.compose.ui.hapticfeedback.HapticFeedback -import androidx.compose.ui.hapticfeedback.HapticFeedbackType - -// TODO(demin): implement HapticFeedback -internal object DefaultHapticFeedback : HapticFeedback { - override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) { - } -} \ No newline at end of file diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformContext.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformContext.skiko.kt index c39614b2b162e..de983f435efe5 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformContext.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformContext.skiko.kt @@ -13,6 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@file:OptIn(ExperimentalMediaQueryApi::class) + package androidx.compose.ui.platform import androidx.compose.runtime.getValue @@ -20,13 +22,16 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.ComposeUiFlags import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ExperimentalMediaQueryApi import androidx.compose.ui.FrameRateCategory import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.UiMediaScope import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.InputMode import androidx.compose.ui.input.InputModeManager import androidx.compose.ui.input.pointer.PointerIcon @@ -45,6 +50,7 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeOptions import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.intl.LocaleList +import androidx.compose.ui.unit.Dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ViewModelStoreOwner @@ -156,7 +162,7 @@ interface PlatformContext { } val textToolbar: TextToolbar get() = EmptyTextToolbar - val hapticFeedback: HapticFeedback get() = DefaultHapticFeedback + val hapticFeedback: HapticFeedback get() = NoOpHapticFeedback fun setPointerIcon(pointerIcon: PointerIcon) = Unit val parentFocusManager: FocusManager get() = EmptyFocusManager @@ -217,6 +223,17 @@ interface PlatformContext { */ val prefetchScheduler: PlatformPrefetchScheduler get() = NoOpPlatformPrefetchScheduler + /** + * Media-related information exposed to the composition. + * + * This provides platform-specific environment details such as window posture, + * pointer precision, keyboard type, and device capabilities. The default + * implementation is a no-op environment that reports neutral values so code + * using media state remains safe on platforms that do not provide a richer + * implementation. + */ + val mediaEnvironment: UiMediaScope get() = NoOpMediaEnvironment + interface RootForTestListener { fun onRootForTestCreated(root: PlatformRootForTest) fun onRootForTestDisposed(root: PlatformRootForTest) @@ -389,3 +406,27 @@ internal class DelegateRootForTestListener : PlatformContext.RootForTestListener } } } + +private object NoOpHapticFeedback : HapticFeedback { + override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) { + } +} + +private object NoOpMediaEnvironment : UiMediaScope { + override val windowPosture: UiMediaScope.Posture + get() = UiMediaScope.Posture.Flat + override val windowWidth: Dp + get() = Dp.Unspecified + override val windowHeight: Dp + get() = Dp.Unspecified + override val pointerPrecision: UiMediaScope.PointerPrecision + get() = UiMediaScope.PointerPrecision.None + override val keyboardKind: UiMediaScope.KeyboardKind + get() = UiMediaScope.KeyboardKind.None + override val hasMicrophone: Boolean + get() = false + override val hasCamera: Boolean + get() = false + override val viewingDistance: UiMediaScope.ViewingDistance + get() = UiMediaScope.ViewingDistance.Near +} \ No newline at end of file diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.web.kt index 5dae6a1178b53..de75a82314b6f 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.web.kt @@ -39,8 +39,6 @@ internal class WebHapticFeedback : HapticFeedback { private val SoftTickVibrationPattern = vibrationPatternOf(6) private val LongPressVibrationPattern = vibrationPatternOf(0, 30) private val VirtualKeyVibrationPattern = vibrationPatternOf(0, 20) - - fun webHapticFeedbackOrDefault(): HapticFeedback = if (isVibrationSupported()) WebHapticFeedback() else DefaultHapticFeedback } override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) { @@ -81,7 +79,7 @@ private fun vibrate(pattern: JsArray) { js("window.navigator.vibrate(pattern)") } -private fun isVibrationSupported(): Boolean = js( +internal fun isVibrationSupported(): Boolean = js( //language=javascript """ typeof window !== 'undefined' && 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 1b62a170f55c5..0c6a39dcb6d2c 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 @@ -79,6 +79,7 @@ import androidx.compose.ui.platform.PlatformOutOfFrameExecutor import androidx.compose.ui.platform.PlatformPrefetchScheduler import androidx.compose.ui.platform.WebPrefetchScheduler import androidx.compose.ui.platform.isIdleCallbackSupported +import androidx.compose.ui.platform.isVibrationSupported import androidx.compose.ui.scene.ComposeSceneDragAndDropNode import androidx.compose.ui.scene.ComposeScenePointer import androidx.compose.ui.scene.PointerEventResult @@ -309,7 +310,7 @@ internal class ComposeWindow( } override val hapticFeedback by lazy(LazyThreadSafetyMode.NONE) { - WebHapticFeedback.webHapticFeedbackOrDefault() + if (isVibrationSupported()) WebHapticFeedback() else super.hapticFeedback } override val prefetchScheduler: PlatformPrefetchScheduler =