From 4ddcf934d261c773a26eaad16d7267695ccba674 Mon Sep 17 00:00:00 2001 From: "dima.avdeev" <99798741+dima-avdeev-jb@users.noreply.github.com> Date: Fri, 26 May 2023 15:28:12 +0300 Subject: [PATCH] Insets on iOS (#577) API is the same as for Android WindowInsets.Companion.concreteInsetsName --- .../foundation/layout/WindowInsets.uikit.kt | 161 ++++++++ .../SystemBarsDefaultInsets.desktop.kt | 9 +- .../SystemBarsDefaultInsets.jsWasm.kt | 25 ++ .../SystemBarsDefaultInsets.macos.kt | 25 ++ .../SystemBarsDefaultInsets.uikit.kt | 25 ++ .../src/iosAppMain/apple/ContentView.swift | 2 +- .../mpp/demo/getViewControllerWithCompose.kt | 4 +- compose/mpp/demo/build.gradle.kts | 1 + .../kotlin/ApplicationLayoutExamples.kt | 391 ++++++++++++++++++ .../androidx/compose/mpp/demo/main.uikit.kt | 19 +- .../androidx/compose/ui/uikit/Insets.uikit.kt | 53 +++ .../ui/uikit/InterfaceOrientation.uikit.kt | 57 +++ .../ui/uikit/KeyboardOverlapHeight.uikit.kt | 27 ++ .../compose/ui/window/ComposeWindow.uikit.kt | 125 +++--- 14 files changed, 858 insertions(+), 66 deletions(-) create mode 100644 compose/foundation/foundation-layout/src/uikitMain/kotlin/androidx/compose/foundation/layout/WindowInsets.uikit.kt rename compose/material3/material3/src/{skikoMain => desktopMain}/kotlin/androidx/compose/material3/SystemBarsDefaultInsets.desktop.kt (80%) create mode 100644 compose/material3/material3/src/jsWasmMain/kotlin/androidx/compose/material3/SystemBarsDefaultInsets.jsWasm.kt create mode 100644 compose/material3/material3/src/macosMain/kotlin/androidx/compose/material3/SystemBarsDefaultInsets.macos.kt create mode 100644 compose/material3/material3/src/uikitMain/kotlin/androidx/compose/material3/SystemBarsDefaultInsets.uikit.kt create mode 100644 compose/mpp/demo/src/uikitMain/kotlin/ApplicationLayoutExamples.kt create mode 100644 compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/Insets.uikit.kt create mode 100644 compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/InterfaceOrientation.uikit.kt create mode 100644 compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/KeyboardOverlapHeight.uikit.kt diff --git a/compose/foundation/foundation-layout/src/uikitMain/kotlin/androidx/compose/foundation/layout/WindowInsets.uikit.kt b/compose/foundation/foundation-layout/src/uikitMain/kotlin/androidx/compose/foundation/layout/WindowInsets.uikit.kt new file mode 100644 index 0000000000000..06053c2fa7148 --- /dev/null +++ b/compose/foundation/foundation-layout/src/uikitMain/kotlin/androidx/compose/foundation/layout/WindowInsets.uikit.kt @@ -0,0 +1,161 @@ +/* + * Copyright 2023 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.foundation.layout + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.InternalComposeApi +import androidx.compose.ui.uikit.* +import androidx.compose.ui.unit.dp + +private val ZeroInsets = WindowInsets(0, 0, 0, 0) + +/** + * This insets represents iOS SafeAreas. + */ +private val WindowInsets.Companion.iosSafeArea: WindowInsets + @Composable + @OptIn(InternalComposeApi::class) + get() = WindowInsets( + top = LocalSafeAreaState.current.value.top, + bottom = LocalSafeAreaState.current.value.bottom, + left = LocalSafeAreaState.current.value.left, + right = LocalSafeAreaState.current.value.right, + ) + +/** + * This insets represents iOS layoutMargins. + */ +private val WindowInsets.Companion.layoutMargins: WindowInsets + @Composable + @OptIn(InternalComposeApi::class) + get() = WindowInsets( + top = LocalLayoutMarginsState.current.value.top, + bottom = LocalLayoutMarginsState.current.value.bottom, + left = LocalLayoutMarginsState.current.value.left, + right = LocalLayoutMarginsState.current.value.right, + ) + +/** + * An insets type representing the window of a caption bar. + * It is useless for iOS. + */ +val WindowInsets.Companion.captionBar get() = ZeroInsets + +/** + * This [WindowInsets] represents the area with the display cutout (e.g. for camera). + */ +val WindowInsets.Companion.displayCutout: WindowInsets + @Composable + @OptIn(InternalComposeApi::class) + get() = when (LocalInterfaceOrientationState.current.value) { + InterfaceOrientation.Portrait -> iosSafeArea.only(WindowInsetsSides.Top) + InterfaceOrientation.PortraitUpsideDown -> iosSafeArea.only(WindowInsetsSides.Bottom) + InterfaceOrientation.LandscapeLeft -> iosSafeArea.only(WindowInsetsSides.Right) + InterfaceOrientation.LandscapeRight -> iosSafeArea.only(WindowInsetsSides.Left) + } + +/** + * An insets type representing the window of an "input method", + * for iOS IME representing the software keyboard. + * + * TODO: Animation doesn't work on iOS yet + */ +val WindowInsets.Companion.ime: WindowInsets + @Composable + @OptIn(InternalComposeApi::class) + get() = WindowInsets(bottom = LocalKeyboardOverlapHeightState.current.value.dp) + +/** + * These insets represents the space where system gestures have priority over application gestures. + */ +val WindowInsets.Companion.mandatorySystemGestures: WindowInsets + @Composable + get() = iosSafeArea.only(WindowInsetsSides.Top + WindowInsetsSides.Bottom) + +/** + * These insets represent where system UI places navigation bars. + * Interactive UI should avoid the navigation bars area. + */ +val WindowInsets.Companion.navigationBars: WindowInsets + @Composable + get() = iosSafeArea.only(WindowInsetsSides.Bottom) + +/** + * These insets represents status bar. + */ +val WindowInsets.Companion.statusBars: WindowInsets + @Composable + @OptIn(InternalComposeApi::class) + get() = when (LocalInterfaceOrientationState.current.value) { + InterfaceOrientation.Portrait -> iosSafeArea.only(WindowInsetsSides.Top) + else -> ZeroInsets + } + +/** + * These insets represents all system bars. + * Includes [statusBars], [captionBar] as well as [navigationBars], but not [ime]. + */ +val WindowInsets.Companion.systemBars: WindowInsets + @Composable + get() = iosSafeArea + +/** + * The systemGestures insets represent the area of a window where system gestures have + * priority and may consume some or all touch input, e.g. due to the system bar + * occupying it, or it being reserved for touch-only gestures. + */ +val WindowInsets.Companion.systemGestures: WindowInsets + @Composable + get() = layoutMargins // the same as iosSafeArea.add(WindowInsets(left = 16.dp, right = 16.dp)) + +/** + * Returns the tappable element insets. + */ +val WindowInsets.Companion.tappableElement: WindowInsets + @Composable + get() = iosSafeArea.only(WindowInsetsSides.Top) + +/** + * The insets for the curved areas in a waterfall display. + * It is useless for iOS. + */ +val WindowInsets.Companion.waterfall: WindowInsets get() = ZeroInsets + +/** + * The insets that include areas where content may be covered by other drawn content. + * This includes all [systemBars], [displayCutout], and [ime]. + */ +val WindowInsets.Companion.safeDrawing + @Composable + get() = systemBars.union(ime).union(displayCutout) + +/** + * The insets that include areas where gestures may be confused with other input, + * including [systemGestures], [mandatorySystemGestures], [waterfall], and [tappableElement]. + */ +val WindowInsets.Companion.safeGestures: WindowInsets + @Composable + get() = tappableElement.union(mandatorySystemGestures).union(systemGestures).union(waterfall) + +/** + * The insets that include all areas that may be drawn over or have gesture confusion, + * including everything in [safeDrawing] and [safeGestures]. + */ +val WindowInsets.Companion.safeContent: WindowInsets + @Composable + get() = safeDrawing.union(safeGestures) + diff --git a/compose/material3/material3/src/skikoMain/kotlin/androidx/compose/material3/SystemBarsDefaultInsets.desktop.kt b/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material3/SystemBarsDefaultInsets.desktop.kt similarity index 80% rename from compose/material3/material3/src/skikoMain/kotlin/androidx/compose/material3/SystemBarsDefaultInsets.desktop.kt rename to compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material3/SystemBarsDefaultInsets.desktop.kt index 22349ac392f16..9a58abc138133 100644 --- a/compose/material3/material3/src/skikoMain/kotlin/androidx/compose/material3/SystemBarsDefaultInsets.desktop.kt +++ b/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material3/SystemBarsDefaultInsets.desktop.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Android Open Source Project + * Copyright 2023 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. @@ -21,8 +21,5 @@ import androidx.compose.ui.unit.dp import androidx.compose.runtime.Composable @Composable -internal actual fun WindowInsets.Companion.systemBarsForVisualComponents(): WindowInsets { - return WindowInsets(0.dp, 0.dp, 0.dp, 0.dp) -} -// @Composable -// get() = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp) +internal actual fun WindowInsets.Companion.systemBarsForVisualComponents(): WindowInsets = + WindowInsets(0.dp, 0.dp, 0.dp, 0.dp) diff --git a/compose/material3/material3/src/jsWasmMain/kotlin/androidx/compose/material3/SystemBarsDefaultInsets.jsWasm.kt b/compose/material3/material3/src/jsWasmMain/kotlin/androidx/compose/material3/SystemBarsDefaultInsets.jsWasm.kt new file mode 100644 index 0000000000000..9a58abc138133 --- /dev/null +++ b/compose/material3/material3/src/jsWasmMain/kotlin/androidx/compose/material3/SystemBarsDefaultInsets.jsWasm.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2023 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.material3 + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.ui.unit.dp +import androidx.compose.runtime.Composable + +@Composable +internal actual fun WindowInsets.Companion.systemBarsForVisualComponents(): WindowInsets = + WindowInsets(0.dp, 0.dp, 0.dp, 0.dp) diff --git a/compose/material3/material3/src/macosMain/kotlin/androidx/compose/material3/SystemBarsDefaultInsets.macos.kt b/compose/material3/material3/src/macosMain/kotlin/androidx/compose/material3/SystemBarsDefaultInsets.macos.kt new file mode 100644 index 0000000000000..9a58abc138133 --- /dev/null +++ b/compose/material3/material3/src/macosMain/kotlin/androidx/compose/material3/SystemBarsDefaultInsets.macos.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2023 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.material3 + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.ui.unit.dp +import androidx.compose.runtime.Composable + +@Composable +internal actual fun WindowInsets.Companion.systemBarsForVisualComponents(): WindowInsets = + WindowInsets(0.dp, 0.dp, 0.dp, 0.dp) diff --git a/compose/material3/material3/src/uikitMain/kotlin/androidx/compose/material3/SystemBarsDefaultInsets.uikit.kt b/compose/material3/material3/src/uikitMain/kotlin/androidx/compose/material3/SystemBarsDefaultInsets.uikit.kt new file mode 100644 index 0000000000000..1bc9371e08c57 --- /dev/null +++ b/compose/material3/material3/src/uikitMain/kotlin/androidx/compose/material3/SystemBarsDefaultInsets.uikit.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2023 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.material3 + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.systemBars +import androidx.compose.runtime.Composable + +@Composable +internal actual fun WindowInsets.Companion.systemBarsForVisualComponents(): WindowInsets = + WindowInsets.systemBars diff --git a/compose/mpp/demo-uikit/src/iosAppMain/apple/ContentView.swift b/compose/mpp/demo-uikit/src/iosAppMain/apple/ContentView.swift index 58f1c9a9d36c7..1302386836e99 100644 --- a/compose/mpp/demo-uikit/src/iosAppMain/apple/ContentView.swift +++ b/compose/mpp/demo-uikit/src/iosAppMain/apple/ContentView.swift @@ -20,7 +20,7 @@ import shared struct ContentView: View { var body: some View { - ComposeView() + ComposeView().ignoresSafeArea(.all) } } diff --git a/compose/mpp/demo-uikit/src/uikitMain/kotlin/androidx/compose/mpp/demo/getViewControllerWithCompose.kt b/compose/mpp/demo-uikit/src/uikitMain/kotlin/androidx/compose/mpp/demo/getViewControllerWithCompose.kt index b8e8dfca1a02e..fd262d8943228 100644 --- a/compose/mpp/demo-uikit/src/uikitMain/kotlin/androidx/compose/mpp/demo/getViewControllerWithCompose.kt +++ b/compose/mpp/demo-uikit/src/uikitMain/kotlin/androidx/compose/mpp/demo/getViewControllerWithCompose.kt @@ -16,12 +16,10 @@ package androidx.compose.mpp.demo -import androidx.compose.runtime.remember import androidx.compose.ui.window.ComposeUIViewController // TODO This module is just a proxy to run the demo from mpp:demo. Figure out how to get rid of it. // If it is removed, there is no available configuration in IDE fun getViewControllerWithCompose() = ComposeUIViewController { - val app = remember() { App() } - app.Content() + IosDemo() } diff --git a/compose/mpp/demo/build.gradle.kts b/compose/mpp/demo/build.gradle.kts index 02362415b4822..778f9989fcd1a 100644 --- a/compose/mpp/demo/build.gradle.kts +++ b/compose/mpp/demo/build.gradle.kts @@ -125,6 +125,7 @@ kotlin { dependencies { implementation(project(":compose:foundation:foundation")) implementation(project(":compose:foundation:foundation-layout")) + implementation(project(":compose:material3:material3")) implementation(project(":compose:material:material")) implementation(project(":compose:mpp")) implementation(project(":compose:runtime:runtime")) diff --git a/compose/mpp/demo/src/uikitMain/kotlin/ApplicationLayoutExamples.kt b/compose/mpp/demo/src/uikitMain/kotlin/ApplicationLayoutExamples.kt new file mode 100644 index 0000000000000..df342a0b95e3e --- /dev/null +++ b/compose/mpp/demo/src/uikitMain/kotlin/ApplicationLayoutExamples.kt @@ -0,0 +1,391 @@ +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Send +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Phone +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.random.Random + +val themeState = mutableStateOf(Theme.SystemTheme) +val topState = mutableStateOf(Top.Empty) +val bottomState = mutableStateOf(Bottom.Empty) + +enum class Theme { SystemTheme, DarkTheme, LightTheme, } +enum class Top { Empty, TopBarBasic, TopBarWithGradient, CollapsingTopBar, } +enum class Bottom { Empty, Tabs, } +enum class Insets { + captionBar, + displayCutout, + ime, + mandatorySystemGestures, + navigationBars, + statusBars, + systemBars, + systemGestures, + tappableElement, + waterfall, + safeDrawing, + safeGestures, + safeContent, +} + +@Composable +fun ApplicationLayoutExamples() { + val isDarkTheme = when (themeState.value) { + Theme.SystemTheme -> isSystemInDarkTheme() + Theme.DarkTheme -> true + Theme.LightTheme -> false + } + Box(Modifier.fillMaxSize()) { + MaterialTheme(colorScheme = if (isDarkTheme) darkColorScheme() else lightColorScheme()) { + WithScaffold() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WithScaffold() { + val isScaffoldPaddingState = rememberSaveable { mutableStateOf(false) } + val isInsetsState = rememberSaveable { mutableStateOf(false) } + val isChatState = rememberSaveable { mutableStateOf(false) } + val isBigTextFieldState = rememberSaveable { mutableStateOf(false) } + + val appBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(appBarState) + Scaffold( + topBar = { + when (topState.value) { + Top.TopBarBasic -> TopBarBasic() + Top.TopBarWithGradient -> TopBarWithGradient() + Top.CollapsingTopBar -> MediumTopAppBar( + title = { Text("Collapsing with Chat content") }, + scrollBehavior = scrollBehavior + ) + + else -> {} + } + }, + bottomBar = { + when (bottomState.value) { + Bottom.Empty -> {} + Bottom.Tabs -> BottomAppBar { + val tabState = rememberSaveable { mutableStateOf(0) } + TabRow(selectedTabIndex = tabState.value) { + listOf( + "Home" to Icons.Default.Home, + "Star" to Icons.Default.Star, + "Settings" to Icons.Default.Settings, + ).forEachIndexed { index, pair -> + Tab( + text = { Text(pair.first) }, + selected = tabState.value == index, + onClick = { tabState.value = index }, + icon = { Icon(pair.second, null) } + ) + } + } + } + } + }, + modifier = Modifier.run { + if (topState.value == Top.CollapsingTopBar) { + nestedScroll(scrollBehavior.nestedScrollConnection) + } else { + this + } + }, + ) { innerPadding -> + if (isScaffoldPaddingState.value) { + ContentScaffoldPadding(innerPadding) + } + + if (isChatState.value) { + ContentChat(innerPadding) + } + + if (isBigTextFieldState.value) { + ContentBigTextField(innerPadding) + } + + Box( + Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.systemBars) + .padding(innerPadding) + ) { + SwitchEnumState( + Top.values(), + topState, + Modifier.align(Alignment.TopEnd) + ) + Column( + Modifier.align(Alignment.CenterEnd).background(Color.Blue.copy(0.3f)).padding(2.dp) + .border(1.dp, Color.Blue).padding(2.dp), + horizontalAlignment = Alignment.End + ) { + @Composable + fun SwitchBooleanState(state: MutableState, text: String) = + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text) + Switch(state.value, { state.value = it }) + } + SwitchBooleanState(isScaffoldPaddingState, "ScaffoldPadding") + SwitchBooleanState(isInsetsState, "Insets") + SwitchBooleanState(isChatState, "Chat") + SwitchBooleanState(isBigTextFieldState, "BigTextField") + } + SwitchEnumState( + Bottom.values(), + bottomState, + Modifier.align(Alignment.BottomEnd) + ) + SwitchEnumState(Theme.values(), themeState, Modifier.align(Alignment.CenterStart)) + } + } + + if (isInsetsState.value) { + ContentInsets() + } + +} + +@Composable +fun ContentScaffoldPadding(innerPadding: PaddingValues) { + Box(Modifier.fillMaxSize()) { + Text( + "Scaffold innerPadding from top", + Modifier.align(Alignment.TopStart) + .padding(innerPadding) + .background(Color.Yellow.copy(0.5f)) + ) + Text( + "Scaffold innerPadding from bottom", + Modifier.align(Alignment.BottomStart) + .padding(innerPadding) + .background(Color.Yellow.copy(0.5f)) + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContentChat(innerPadding: PaddingValues) { + val messagesState = remember { + val messagesArray = buildList { + repeat(20) { + add("Message $it") + } + }.toTypedArray() + mutableStateListOf(*messagesArray) + } + Column(Modifier.fillMaxSize().padding(innerPadding)) { + LazyColumn(Modifier.weight(1f)) { + items(messagesState) { + Row( + Modifier.padding(5.dp).background(Color.Green.copy(0.3f)).padding(5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box(Modifier.size(40.dp).clip(CircleShape).background(Color(Random.nextInt()))) + Text(it, Modifier.padding(4.dp)) + } + } + } + var inputText by remember { mutableStateOf("") } + OutlinedTextField( + modifier = Modifier.fillMaxWidth() + .background(Color.Yellow.copy(0.3f)) + .padding(10.dp), + value = inputText, + placeholder = { + Text("type message here") + }, + onValueChange = { + inputText = it + }, + trailingIcon = { + if (inputText.isNotEmpty()) { + Row( + modifier = Modifier.clickable { + messagesState.add(inputText) + inputText = "" + } + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Send, + contentDescription = "Send", + ) + Text("Send") + } + } + } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContentBigTextField(innerPadding: PaddingValues) { + val textState = remember { + mutableStateOf( + buildString { + appendLine("Begin") + repeat(40) { + appendLine("Some big text $it") + } + appendLine("End") + } + ) + } + TextField( + textState.value, + { textState.value = it }, + Modifier.fillMaxSize().padding(innerPadding) + ) +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun ContentInsets() = Box(Modifier.fillMaxSize()) { + val insetsState = rememberSaveable { mutableStateOf(Insets.ime) } + val current = when (insetsState.value) { + Insets.captionBar -> WindowInsets.captionBar + Insets.displayCutout -> WindowInsets.displayCutout + Insets.ime -> WindowInsets.ime + Insets.mandatorySystemGestures -> WindowInsets.mandatorySystemGestures + Insets.navigationBars -> WindowInsets.navigationBars + Insets.statusBars -> WindowInsets.statusBars + Insets.systemBars -> WindowInsets.systemBars + Insets.systemGestures -> WindowInsets.systemGestures + Insets.tappableElement -> WindowInsets.tappableElement + Insets.waterfall -> WindowInsets.waterfall + Insets.safeDrawing -> WindowInsets.safeDrawing + Insets.safeGestures -> WindowInsets.safeGestures + Insets.safeContent -> WindowInsets.safeContent + } + Box(Modifier.fillMaxSize().background(Color.Red.copy(0.5f))) + Box( + Modifier.fillMaxSize() + .windowInsetsPadding(current) + .border(8.dp, Color.Green) + .background(Color.Green.copy(alpha = 0.5f)) + ) + + Column( + Modifier.align(Alignment.Center) + .windowInsetsPadding(WindowInsets.systemBars).padding(16.dp) + ) { + val keyboardController = LocalSoftwareKeyboardController.current + BasicTextField("Show keyboard", {}, Modifier.background(Color.Green.copy(0.3f))) + BasicText( + "Hide keyboard", + Modifier.clickable { keyboardController?.hide() }.focusable(true) + .background(Color.Gray.copy(0.5f)) + ) + SwitchEnumState( + Insets.values(), + insetsState + ) + } +} + +@Composable +fun SwitchEnumState(values: Array, state: MutableState, modifier: Modifier = Modifier) { + Column(modifier.background(MaterialTheme.colorScheme.surface).width(IntrinsicSize.Min)) { + values.forEach { + Text( + it.toString(), + Modifier.clickable { + state.value = it + } + .fillMaxWidth() + .padding(2.dp).border(1.dp, Color.Blue).padding(2.dp) + .background(if (state.value == it) Color.Gray else Color.Blue.copy(0.3f)) + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TopBarBasic() { + CenterAlignedTopAppBar( + navigationIcon = { + Text("Text", color = Color.Blue) + }, + 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) + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TopBarWithGradient() { + val bgColor = MaterialTheme.colorScheme.background + val green = Color.Green.copy(0.5f).compositeOver(bgColor) + val blue = Color.Blue.copy(0.5f).compositeOver(bgColor) + Box( + Modifier.background(Brush.horizontalGradient(listOf(green, blue))) + ) { + CenterAlignedTopAppBar( + navigationIcon = { + Row { + Icon(Icons.Default.ArrowBack, null, tint = Color.Blue) + Text("Back", color = Color.Blue) + } + }, + title = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Some Name", fontWeight = FontWeight.Bold, fontSize = 16.sp) + Text("last seen 3 hours ago", fontSize = 10.sp) + } + }, + actions = { + val modifier = Modifier.padding(horizontal = 10.dp) + Icon(Icons.Outlined.Phone, null, modifier, Color.Blue) + Icon(Icons.Outlined.Edit, null, modifier, Color.Blue) + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = Color.Transparent + ) + ) + } +} 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 bb42fc4159da5..a8788ca2739d0 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 @@ -1,13 +1,26 @@ // Use `xcodegen` first, then `open ./SkikoSample.xcodeproj` and then Run button in XCode. package androidx.compose.mpp.demo -import androidx.compose.runtime.remember +import ApplicationLayoutExamples +import androidx.compose.runtime.* import androidx.compose.ui.main.defaultUIKitMain import androidx.compose.ui.window.ComposeUIViewController fun main() { defaultUIKitMain("ComposeDemo", ComposeUIViewController { - val app = remember { App() } - app.Content() + IosDemo() }) } + +@Composable +fun IosDemo() { + // You may uncomment different examples: +// MultiplatformDemo() + ApplicationLayoutExamples() +} + +@Composable +fun MultiplatformDemo() { + val app = remember { App() } + app.Content() +} diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/Insets.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/Insets.uikit.kt new file mode 100644 index 0000000000000..339506505c6bd --- /dev/null +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/Insets.uikit.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2023 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.uikit + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.InternalComposeApi +import androidx.compose.runtime.State +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Composition local for SafeArea of ComposeUIViewController + */ +@InternalComposeApi +val LocalSafeAreaState = staticCompositionLocalOf> { + error("CompositionLocal LocalSafeAreaTopState not present") +} + +/** + * Composition local for layoutMargins of ComposeUIViewController + */ +@InternalComposeApi +val LocalLayoutMarginsState = staticCompositionLocalOf> { + error("CompositionLocal LocalLayoutMarginsState not present") +} + +/** + * This class represents iOS Insets. + * It contains equals and hashcode and can be used as Compose State. + */ +@Immutable +@InternalComposeApi +data class IOSInsets( + val top: Dp = 0.dp, + val bottom: Dp = 0.dp, + val left: Dp = 0.dp, + val right: Dp = 0.dp, +) diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/InterfaceOrientation.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/InterfaceOrientation.uikit.kt new file mode 100644 index 0000000000000..6a670b728aa22 --- /dev/null +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/InterfaceOrientation.uikit.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2023 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.uikit + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.InternalComposeApi +import androidx.compose.runtime.State +import androidx.compose.runtime.staticCompositionLocalOf +import platform.UIKit.* + +/** + * Wraps iOS enum statusBarOrientation() + */ +@InternalComposeApi +@Immutable +enum class InterfaceOrientation(private val rawValue: UIInterfaceOrientation) { + Portrait(UIInterfaceOrientationPortrait), + PortraitUpsideDown(UIInterfaceOrientationPortraitUpsideDown), + LandscapeLeft(UIInterfaceOrientationLandscapeLeft), + LandscapeRight(UIInterfaceOrientationLandscapeRight); + + companion object { + fun getByRawValue(orientation: UIInterfaceOrientation): InterfaceOrientation { + return values().firstOrNull { + it.rawValue == orientation + } ?: error("Can't find orientation rawValue $orientation in enum InterfaceOrientation") + } + + /** + * Return iOS statusBarOrientation() wrapped with Kotlin enum [InterfaceOrientation] + */ + fun getStatusBarOrientation(): InterfaceOrientation = + getByRawValue(UIApplication.sharedApplication().statusBarOrientation()) + } +} + +/** + * Composition local for [InterfaceOrientation] of current Application + */ +@InternalComposeApi +val LocalInterfaceOrientationState = staticCompositionLocalOf> { + error("CompositionLocal LocalInterfaceOrientationState not present") +} diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/KeyboardOverlapHeight.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/KeyboardOverlapHeight.uikit.kt new file mode 100644 index 0000000000000..66bfd15806176 --- /dev/null +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/KeyboardOverlapHeight.uikit.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2023 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.uikit + +import androidx.compose.runtime.* + +/** + * Composition local for height that is overlapped with keyboard over Compose view. + */ +@InternalComposeApi +val LocalKeyboardOverlapHeightState = staticCompositionLocalOf> { + error("CompositionLocal LocalKeyboardOverlapHeightState not present") +} diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeWindow.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeWindow.uikit.kt index 570add5e2d329..4ef401c1d127d 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeWindow.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeWindow.uikit.kt @@ -18,6 +18,8 @@ package androidx.compose.ui.window import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.InternalComposeApi +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.createSkiaLayer import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect @@ -27,6 +29,7 @@ import androidx.compose.ui.interop.LocalUIViewController import androidx.compose.ui.native.ComposeLayer import androidx.compose.ui.platform.* import androidx.compose.ui.text.input.PlatformTextInputService +import androidx.compose.ui.uikit.* import androidx.compose.ui.unit.* import kotlin.math.roundToInt import kotlinx.cinterop.CValue @@ -36,7 +39,6 @@ import kotlinx.cinterop.useContents import org.jetbrains.skiko.SkikoUIView import org.jetbrains.skiko.TextActions import platform.CoreGraphics.CGPointMake -import platform.CoreGraphics.CGRectMake import platform.CoreGraphics.CGSize import platform.Foundation.* import platform.UIKit.* @@ -77,10 +79,19 @@ fun ComposeUIViewController(content: @Composable () -> Unit): UIViewController = fun Application( title: String = "JetpackNativeWindow", content: @Composable () -> Unit = { } -):UIViewController = ComposeUIViewController(content) +): UIViewController = ComposeUIViewController(content) +@OptIn(InternalComposeApi::class) @ExportObjCClass internal actual class ComposeWindow : UIViewController { + + private val keyboardOverlapHeightState = mutableStateOf(0f) + private val safeAreaState = mutableStateOf(IOSInsets()) + private val layoutMarginsState = mutableStateOf(IOSInsets()) + private val interfaceOrientationState = mutableStateOf( + InterfaceOrientation.getStatusBarOrientation() + ) + @OverrideInit actual constructor() : super(nibName = null, bundle = null) @@ -104,54 +115,57 @@ internal actual class ComposeWindow : UIViewController { private val keyboardVisibilityListener = object : NSObject() { @Suppress("unused") @ObjCAction - fun keyboardWillShow(arg: NSNotification) { + fun keyboardDidShow(arg: NSNotification) { val keyboardInfo = arg.userInfo!!["UIKeyboardFrameEndUserInfoKey"] as NSValue val keyboardHeight = keyboardInfo.CGRectValue().useContents { size.height } val screenHeight = UIScreen.mainScreen.bounds.useContents { size.height } - val magicMultiplier = density.density - 1 // todo magic number - val viewY = UIScreen.mainScreen.coordinateSpace.convertPoint( - point = CGPointMake(0.0, 0.0), + + val composeViewBottomY = UIScreen.mainScreen.coordinateSpace.convertPoint( + point = CGPointMake(0.0, view.frame.useContents { size.height }), fromCoordinateSpace = view.coordinateSpace - ).useContents { y } * magicMultiplier - val focused = layer.getActiveFocusRect() - if (focused != null) { - val focusedBottom = focused.bottom.value + getTopLeftOffset().y - val hiddenPartOfFocusedElement = - focusedBottom + keyboardHeight - screenHeight - viewY - if (hiddenPartOfFocusedElement > 0) { - // If focused element hidden by keyboard, then change UIView bounds. - // Focused element will be visible - val focusedTop = focused.top.value - val composeOffsetY = if (hiddenPartOfFocusedElement < focusedTop) { - hiddenPartOfFocusedElement - } else { - maxOf(focusedTop, 0f).toDouble() - } - view.setClipsToBounds(true) - val (width, height) = getViewFrameSize() - view.layer.setBounds( - CGRectMake( - x = 0.0, - y = composeOffsetY, - width = width.toDouble(), - height = height.toDouble() - ) - ) - } + ).useContents { y } + val bottomIndent = screenHeight - composeViewBottomY + + if (bottomIndent < keyboardHeight) { + keyboardOverlapHeightState.value = (keyboardHeight - bottomIndent).toFloat() } } @Suppress("unused") @ObjCAction - fun keyboardWillHide(arg: NSNotification) { - val (width, height) = getViewFrameSize() - view.layer.setBounds(CGRectMake(0.0, 0.0, width.toDouble(), height.toDouble())) + fun keyboardDidHide(arg: NSNotification) { + keyboardOverlapHeightState.value = 0f } + } - @Suppress("unused") + private val orientationListener = object : NSObject() { + @Suppress("UNUSED_PARAMETER") @ObjCAction - fun keyboardDidHide(arg: NSNotification) { - view.setClipsToBounds(false) + fun orientationDidChange(arg: NSNotification) { + InterfaceOrientation.getStatusBarOrientation() + interfaceOrientationState.value = InterfaceOrientation.getStatusBarOrientation() + } + } + + @Suppress("unused") + @ObjCAction + fun viewSafeAreaInsetsDidChange() { + // super.viewSafeAreaInsetsDidChange() // TODO: call super after Kotlin 1.8.20 + view.safeAreaInsets.useContents { + safeAreaState.value = IOSInsets( + top = top.dp, + bottom = bottom.dp, + left = left.dp, + right = right.dp, + ) + } + view.directionalLayoutMargins.useContents { + layoutMarginsState.value = IOSInsets( + top = top.dp, + bottom = bottom.dp, + left = leading.dp, + right = trailing.dp, + ) } } @@ -254,6 +268,10 @@ internal actual class ComposeWindow : UIViewController { CompositionLocalProvider( LocalLayerContainer provides rootView, LocalUIViewController provides this, + LocalKeyboardOverlapHeightState provides keyboardOverlapHeightState, + LocalSafeAreaState provides safeAreaState, + LocalLayoutMarginsState provides layoutMarginsState, + LocalInterfaceOrientationState provides interfaceOrientationState, ) { content() } @@ -307,40 +325,46 @@ internal actual class ComposeWindow : UIViewController { super.viewDidAppear(animated) NSNotificationCenter.defaultCenter.addObserver( observer = keyboardVisibilityListener, - selector = NSSelectorFromString("keyboardWillShow:"), - name = platform.UIKit.UIKeyboardWillShowNotification, + selector = NSSelectorFromString(keyboardVisibilityListener::keyboardDidShow.name + ":"), + name = UIKeyboardDidShowNotification, `object` = null ) NSNotificationCenter.defaultCenter.addObserver( observer = keyboardVisibilityListener, - selector = NSSelectorFromString("keyboardWillHide:"), - name = platform.UIKit.UIKeyboardWillHideNotification, + selector = NSSelectorFromString(keyboardVisibilityListener::keyboardDidHide.name + ":"), + name = UIKeyboardDidHideNotification, `object` = null ) NSNotificationCenter.defaultCenter.addObserver( - observer = keyboardVisibilityListener, - selector = NSSelectorFromString("keyboardDidHide:"), - name = platform.UIKit.UIKeyboardDidHideNotification, + observer = orientationListener, + selector = NSSelectorFromString(orientationListener::orientationDidChange.name + ":"), + name = UIDeviceOrientationDidChangeNotification, `object` = null ) } // viewDidUnload() is deprecated and not called. override fun viewDidDisappear(animated: Boolean) { + // TODO call dispose() function, but check how it will works with SwiftUI interop between different screens. super.viewDidDisappear(animated) NSNotificationCenter.defaultCenter.removeObserver( observer = keyboardVisibilityListener, - name = platform.UIKit.UIKeyboardWillShowNotification, + name = UIKeyboardWillShowNotification, `object` = null ) NSNotificationCenter.defaultCenter.removeObserver( observer = keyboardVisibilityListener, - name = platform.UIKit.UIKeyboardWillHideNotification, + name = UIKeyboardWillHideNotification, `object` = null ) NSNotificationCenter.defaultCenter.removeObserver( observer = keyboardVisibilityListener, - name = platform.UIKit.UIKeyboardDidHideNotification, + name = UIKeyboardDidHideNotification, + `object` = null + ) + NSNotificationCenter.defaultCenter.removeObserver( + observer = keyboardVisibilityListener, + name = UIDeviceOrientationDidChangeNotification, `object` = null ) } @@ -357,11 +381,6 @@ internal actual class ComposeWindow : UIViewController { this.content = content } - override fun viewDidUnload() { - super.viewDidUnload() - this.dispose() - } - actual fun dispose() { layer.dispose() }