diff --git a/compose/mpp/demo/src/uikitMain/kotlin/androidx/compose/mpp/demo/IosSpecificFeaturesExample.kt b/compose/mpp/demo/src/uikitMain/kotlin/androidx/compose/mpp/demo/IosSpecificFeaturesExample.kt index a16041a740551..eece3fdc97ec1 100644 --- a/compose/mpp/demo/src/uikitMain/kotlin/androidx/compose/mpp/demo/IosSpecificFeaturesExample.kt +++ b/compose/mpp/demo/src/uikitMain/kotlin/androidx/compose/mpp/demo/IosSpecificFeaturesExample.kt @@ -53,5 +53,6 @@ val IosSpecificFeatures = Screen.Selection( HapticFeedbackExample, LazyColumnWithInteropViewsExample, AccessibilityLiveRegionExample, - InteropViewAndSemanticsConfigMerge + InteropViewAndSemanticsConfigMerge, + StatusBarStateExample ) \ No newline at end of file diff --git a/compose/mpp/demo/src/uikitMain/kotlin/androidx/compose/mpp/demo/StatusBarState.kt b/compose/mpp/demo/src/uikitMain/kotlin/androidx/compose/mpp/demo/StatusBarState.kt new file mode 100644 index 0000000000000..02c33310ade10 --- /dev/null +++ b/compose/mpp/demo/src/uikitMain/kotlin/androidx/compose/mpp/demo/StatusBarState.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2024 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.mpp.demo + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.interop.LocalUIViewController +import androidx.compose.ui.unit.dp +import platform.UIKit.UIStatusBarAnimation +import platform.UIKit.UIStatusBarStyleDarkContent +import platform.UIKit.UIStatusBarStyleDefault +import platform.UIKit.UIStatusBarStyleLightContent +import platform.UIKit.UIView + +private var statusBarStyleIndex by mutableStateOf(0) +val preferredStatusBarStyleValue by derivedStateOf { styleValues[statusBarStyleIndex].second } + +private var statusBarHiddenIndex by mutableStateOf(0) +val prefersStatusBarHiddenValue by derivedStateOf { hiddenValues[statusBarHiddenIndex].second } + +private var statusBarAnimationIndex by mutableStateOf(0) +val preferredStatysBarAnimationValue by derivedStateOf { animationValues[statusBarAnimationIndex].second } + +private val hiddenValues = listOf( + "null" to null, + "True" to true, + "False" to false +) + +private val animationValues = listOf( + "null" to null, + "UIStatusBarAnimationFade" to UIStatusBarAnimation.UIStatusBarAnimationFade, + "UIStatusBarAnimationSlide" to UIStatusBarAnimation.UIStatusBarAnimationSlide +) + +private val styleValues = listOf( + "null" to null, + "UIStatusBarStyleDefault" to UIStatusBarStyleDefault, + "UIStatusBarStyleLightContent" to UIStatusBarStyleLightContent, + "UIStatusBarStyleDarkContent" to UIStatusBarStyleDarkContent +) + +val StatusBarStateExample = Screen.Example("StatusBarState") { + Column(modifier = Modifier.fillMaxSize()) { + Dropdown("preferredStatusBarStyle", styleValues[statusBarStyleIndex].first, styleValues.map { it.first }) { + statusBarStyleIndex = it + } + + Dropdown("prefersStatusBarHidden", hiddenValues[statusBarHiddenIndex].first, hiddenValues.map { it.first }) { + statusBarHiddenIndex = it + } + + Dropdown("preferredStatysBarAnimation", animationValues[statusBarAnimationIndex].first, animationValues.map { it.first }) { + statusBarAnimationIndex = it + } + } + + val viewController = LocalUIViewController.current + LaunchedEffect(statusBarStyleIndex, statusBarHiddenIndex, statusBarAnimationIndex) { + UIView.animateWithDuration(0.3) { + viewController.setNeedsStatusBarAppearanceUpdate() + } + } +} + +@Composable +private fun Dropdown( + name: String, + current: String, + all: List, + onSelected: (Int) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + Box(modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + .clickable { expanded = true }) { + Text("$name: $current") + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }) { + all.forEachIndexed { index, item -> + DropdownMenuItem( + onClick = { + onSelected(index) + expanded = false + }) { + Text(item) + } + } + } + } +} \ No newline at end of file diff --git a/compose/mpp/demo/src/uikitMain/kotlin/androidx/compose/mpp/demo/main.uikit.kt b/compose/mpp/demo/src/uikitMain/kotlin/androidx/compose/mpp/demo/main.uikit.kt index 4b259c0db4144..08d71360ee645 100644 --- a/compose/mpp/demo/src/uikitMain/kotlin/androidx/compose/mpp/demo/main.uikit.kt +++ b/compose/mpp/demo/src/uikitMain/kotlin/androidx/compose/mpp/demo/main.uikit.kt @@ -11,6 +11,12 @@ import androidx.compose.ui.platform.AccessibilitySyncOptions import androidx.compose.ui.window.ComposeUIViewController import androidx.compose.mpp.demo.bugs.IosBugs import androidx.compose.mpp.demo.bugs.StartRecompositionCheck +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.uikit.ComposeUIViewControllerDelegate +import platform.UIKit.UIStatusBarAnimation +import platform.UIKit.UIStatusBarStyle import platform.UIKit.UIViewController @OptIn(ExperimentalComposeApi::class, ExperimentalComposeUiApi::class) @@ -28,6 +34,17 @@ fun main(vararg args: String) { } } }) + + delegate = object : ComposeUIViewControllerDelegate { + override val preferredStatusBarStyle: UIStatusBarStyle? + get() = preferredStatusBarStyleValue + + override val prefersStatusBarHidden: Boolean? + get() = prefersStatusBarHiddenValue + + override val preferredStatysBarAnimation: UIStatusBarAnimation? + get() = preferredStatysBarAnimationValue + } }) { IosDemo(arg) }) diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPViewController.h b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPViewController.h index 5e539f15e6175..1cc630d28331b 100644 --- a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPViewController.h +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPViewController.h @@ -28,6 +28,13 @@ NS_ASSUME_NONNULL_BEGIN /// Overriding classes should call super. - (void)viewControllerDidLeaveWindowHierarchy; + +// MARK: Unexported methods redeclaration block +// Redeclared to make it visible to Kotlin for override purposes, workaround for the following issue: +// https://youtrack.jetbrains.com/issue/KT-56001/Kotlin-Native-import-Objective-C-category-members-as-class-members-if-the-category-is-located-in-the-same-file + +- (void)viewSafeAreaInsetsDidChange; + @end NS_ASSUME_NONNULL_END diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPViewController.m b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPViewController.m index 0d19814ac722a..716a8efc4d2bd 100644 --- a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPViewController.m +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPViewController.m @@ -95,6 +95,10 @@ - (void)viewWillAppear:(BOOL)animated { [self viewControllerDidEnterWindowHierarchy]; } +- (void)viewSafeAreaInsetsDidChange { + [super viewSafeAreaInsetsDidChange]; +} + - (void)transitLifecycleToStarted { switch (_lifecycleState) { case CMPViewControllerLifecycleStateDestroyed: diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/ComposeUIViewControllerConfiguration.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/ComposeUIViewControllerConfiguration.uikit.kt index 742fa65562979..ea61de3bdf414 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/ComposeUIViewControllerConfiguration.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/ComposeUIViewControllerConfiguration.uikit.kt @@ -18,6 +18,8 @@ package androidx.compose.ui.uikit import androidx.compose.runtime.ExperimentalComposeApi import androidx.compose.ui.platform.AccessibilitySyncOptions +import platform.UIKit.UIStatusBarAnimation +import platform.UIKit.UIStatusBarStyle /** * Configuration of ComposeUIViewController behavior. @@ -29,8 +31,8 @@ class ComposeUIViewControllerConfiguration { var onFocusBehavior: OnFocusBehavior = OnFocusBehavior.FocusableAboveKeyboard /** - * Reassign this property with an object implementing [ComposeUIViewControllerDelegate] to receive - * UIViewController lifetime events. + * Reassign this property with an object implementing [ComposeUIViewControllerDelegate] to interact with APIs + * that otherwise would require subclassing internal implementation of [UIViewController], which is impossible. */ var delegate: ComposeUIViewControllerDelegate = object : ComposeUIViewControllerDelegate {} @@ -56,10 +58,33 @@ class ComposeUIViewControllerConfiguration { } /** - * Interface for UIViewController specific lifetime callbacks to allow injecting logic without overriding internal ComposeWindow. - * All of those callbacks are invoked at the very end of overrided function implementation. + * Interface for UIViewController to allow injecting logic which otherwise is impossible due to ComposeUIViewController + * implementation being internal. + * All of those callbacks are invoked at the very end of overriden function and properties implementation. + * Default implementations do nothing and return Unit/null (indicating that UIKit default will be used). */ interface ComposeUIViewControllerDelegate { + /** + * https://developer.apple.com/documentation/uikit/uiviewcontroller/1621416-preferredstatusbarstyle?language=objc + * @return null if UIKit default should be used. + */ + val preferredStatusBarStyle: UIStatusBarStyle? + get() = null + + /** + * https://developer.apple.com/documentation/uikit/uiviewcontroller/1621434-preferredstatusbarupdateanimatio?language=objc + * @return null if UIKit default should be used. + */ + val preferredStatysBarAnimation: UIStatusBarAnimation? + get() = null + + /** + * https://developer.apple.com/documentation/uikit/uiviewcontroller/1621440-prefersstatusbarhidden?language=objc + * @return null if UIKit default should be used. + */ + val prefersStatusBarHidden: Boolean? + get() = null + fun viewDidLoad() = Unit fun viewWillAppear(animated: Boolean) = Unit fun viewDidAppear(animated: Boolean) = Unit diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeContainer.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeContainer.uikit.kt index 7b8d9f5f81b18..768f3c6e704d2 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeContainer.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeContainer.uikit.kt @@ -52,9 +52,11 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.util.fastForEach import kotlin.coroutines.CoroutineContext import kotlin.math.roundToInt +import kotlin.native.runtime.GC +import kotlin.native.runtime.NativeRuntimeApi +import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.CValue import kotlinx.cinterop.ExportObjCClass -import kotlinx.cinterop.ObjCAction import kotlinx.cinterop.readValue import kotlinx.cinterop.useContents import kotlinx.coroutines.Dispatchers @@ -80,6 +82,8 @@ import platform.UIKit.UIContentSizeCategoryExtraSmall import platform.UIKit.UIContentSizeCategoryLarge import platform.UIKit.UIContentSizeCategoryMedium import platform.UIKit.UIContentSizeCategorySmall +import platform.UIKit.UIStatusBarAnimation +import platform.UIKit.UIStatusBarStyle import platform.UIKit.UITraitCollection import platform.UIKit.UIUserInterfaceLayoutDirection import platform.UIKit.UIUserInterfaceStyle @@ -92,6 +96,7 @@ import platform.darwin.dispatch_get_main_queue private val coroutineDispatcher = Dispatchers.Main // TODO: Move to androidx.compose.ui.scene +@OptIn(BetaInteropApi::class) @ExportObjCClass internal class ComposeContainer( private val configuration: ComposeUIViewControllerConfiguration, @@ -145,10 +150,21 @@ internal class ComposeContainer( } } - @Suppress("unused") - @ObjCAction - fun viewSafeAreaInsetsDidChange() { - // super.viewSafeAreaInsetsDidChange() // TODO: call super after Kotlin 1.8.20 + override fun preferredStatusBarStyle(): UIStatusBarStyle = + configuration.delegate.preferredStatusBarStyle + ?: super.preferredStatusBarStyle() + + override fun preferredStatusBarUpdateAnimation(): UIStatusBarAnimation = + configuration.delegate.preferredStatysBarAnimation + ?: super.preferredStatusBarUpdateAnimation() + + override fun prefersStatusBarHidden(): Boolean = + configuration.delegate.prefersStatusBarHidden + ?: super.prefersStatusBarHidden() + + override fun viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + mediator?.viewSafeAreaInsetsDidChange() layers.fastForEach { it.viewSafeAreaInsetsDidChange() @@ -270,11 +286,12 @@ internal class ComposeContainer( configuration.delegate.viewWillDisappear(animated) } + @OptIn(NativeRuntimeApi::class) override fun viewDidDisappear(animated: Boolean) { super.viewDidDisappear(animated) dispatch_async(dispatch_get_main_queue()) { - kotlin.native.internal.GC.collect() + GC.collect() } lifecycleOwner.handleViewDidDisappear() @@ -286,9 +303,10 @@ internal class ComposeContainer( dispose() } + @OptIn(NativeRuntimeApi::class) override fun didReceiveMemoryWarning() { println("didReceiveMemoryWarning") - kotlin.native.internal.GC.collect() + GC.collect() super.didReceiveMemoryWarning() } @@ -296,7 +314,10 @@ internal class ComposeContainer( ComposeSceneContextImpl(platformContext) @OptIn(ExperimentalComposeApi::class) - private fun createSkikoUIView(interopContext: UIKitInteropContext, renderRelegate: SkikoRenderDelegate): RenderingUIView = + private fun createSkikoUIView( + interopContext: UIKitInteropContext, + renderRelegate: SkikoRenderDelegate + ): RenderingUIView = RenderingUIView(interopContext, renderRelegate).apply { opaque = configuration.opaque } @@ -395,6 +416,7 @@ internal class ComposeContainer( } } +@OptIn(BetaInteropApi::class) private fun UIViewController.checkIfInsideSwiftUI(): Boolean { var parent = parentViewController