From 348c7bcea8ca5156b96d96d77b25e5a4a049bd5f Mon Sep 17 00:00:00 2001 From: hayyaksi <193020925+hayaksi1@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:35:38 +0300 Subject: [PATCH] Expand the emoji picker to full height when the keyboard is shown (#7382) * Expand the emoji picker to full height when the keyboard is shown The picker content is rendered with fillMaxSize, so the sheet measures itself to the full window height. That makes the partially expanded anchor available, and the modal sheet auto-shows at that anchor, half way down the screen. The sheet reserves the IME inset through its content window insets, but applies that padding at its own bottom edge, which is off-screen while it rests half way down. The band that is both on-screen and above the keyboard is then only about a tenth of the screen, which is why the matching emoji end up behind it. Expand the sheet while the keyboard is visible so its bottom edge returns to the screen edge and the inset it already reserves lands on top of the keyboard. Modifier.imePadding on the sheet content is not an option: the sheet applies the insets with windowInsetsPadding, which consumes them, so a nested imePadding reads zero. * Cover the keyboard driven sheet expansion with a test The IME reaction moves into ExpandSheetWhenImeIsVisible so a Robolectric test can drive it: dispatching window insets to the composition flips WindowInsets.isImeVisible, and the sheet state is observable from the test. Both the shown and the hidden case are asserted, which is the patch coverage this change was missing. * Do not leave a blank strip below the expanded emoji search The 10dp of bottom padding on the search bar separates the collapsed field from the category tabs underneath it. It stayed applied once the search bar expanded to fill the picker, so the picker stopped 10dp short of its own bottom edge. That strip used to sit below the visible part of a half height sheet. Now that the sheet expands against the keyboard it reads as a gap between the results and the keyboard, so only pad while collapsed. --- .../CustomReactionBottomSheet.kt | 17 +++++ .../ExpandSheetWhenImeIsVisibleTest.kt | 62 ++++++++++++++++ libraries/emoji/impl/build.gradle.kts | 2 +- .../emoji/impl/picker/EmojiPickerView.kt | 2 +- .../emoji/impl/picker/EmojiPickerViewTest.kt | 72 +++++++++++++++++++ 5 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/ExpandSheetWhenImeIsVisibleTest.kt create mode 100644 libraries/emoji/impl/src/test/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerViewTest.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt index c90a0e5acc..a14d3b3893 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt @@ -8,11 +8,16 @@ package io.element.android.features.messages.impl.timeline.components.customreaction +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.isImeVisible import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import io.element.android.emojibasebindings.Emoji @@ -54,6 +59,7 @@ fun CustomReactionBottomSheet( modifier = modifier, scrollable = false, ) { + ExpandSheetWhenImeIsVisible(sheetState) emojiPickerRenderer.Render( state = state.target.emojiPickerState, onSelectEmoji = ::onEmojiSelectedDismiss, @@ -67,3 +73,14 @@ fun CustomReactionBottomSheet( } } } + +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) +@Composable +internal fun ExpandSheetWhenImeIsVisible(sheetState: SheetState) { + val isImeVisible = WindowInsets.isImeVisible + LaunchedEffect(isImeVisible) { + if (isImeVisible) { + sheetState.expand() + } + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/ExpandSheetWhenImeIsVisibleTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/ExpandSheetWhenImeIsVisibleTest.kt new file mode 100644 index 0000000000..3f5805a0b4 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/ExpandSheetWhenImeIsVisibleTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalTestApi::class) + +package io.element.android.features.messages.impl.timeline.components.customreaction + +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetState +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import com.google.common.truth.Truth.assertThat +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test + +class ExpandSheetWhenImeIsVisibleTest : RobolectricTest() { + @Test + fun `the sheet is left alone while the keyboard is hidden`() = runAndroidComposeUiTest { + val sheetState = renderWithIme(isImeVisible = false) + assertThat(sheetState.currentValue).isEqualTo(SheetValue.PartiallyExpanded) + } + + @Test + fun `the sheet is expanded when the keyboard is shown`() = runAndroidComposeUiTest { + val sheetState = renderWithIme(isImeVisible = true) + assertThat(sheetState.currentValue).isEqualTo(SheetValue.Expanded) + } +} + +private fun AndroidComposeUiTest.renderWithIme(isImeVisible: Boolean): SheetState { + lateinit var sheetState: SheetState + lateinit var view: View + setContent { + view = LocalView.current + sheetState = rememberBottomSheetState(initialValue = SheetValue.PartiallyExpanded) + ExpandSheetWhenImeIsVisible(sheetState) + } + runOnIdle { + ViewCompat.dispatchApplyWindowInsets( + view, + WindowInsetsCompat.Builder() + .setVisible(WindowInsetsCompat.Type.ime(), isImeVisible) + .setInsets(WindowInsetsCompat.Type.ime(), if (isImeVisible) Insets.of(0, 0, 0, 300) else Insets.NONE) + .build() + ) + } + waitForIdle() + return sheetState +} diff --git a/libraries/emoji/impl/build.gradle.kts b/libraries/emoji/impl/build.gradle.kts index 1cb274e6c0..59a1ad6575 100644 --- a/libraries/emoji/impl/build.gradle.kts +++ b/libraries/emoji/impl/build.gradle.kts @@ -36,5 +36,5 @@ dependencies { implementation(libs.matrix.emojibase.bindings) testImplementation(projects.libraries.matrix.test) - testCommonDependencies(libs) + testCommonDependencies(libs, includeTestComposeView = true) } diff --git a/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerView.kt b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerView.kt index 94eaea1595..88faa3a19a 100644 --- a/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerView.kt +++ b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerView.kt @@ -62,7 +62,7 @@ internal fun EmojiPickerView( Column(modifier) { SearchBar( - modifier = Modifier.padding(bottom = 10.dp), + modifier = Modifier.padding(bottom = if (state.isSearchActive) 0.dp else 10.dp), queryState = state.searchQuery, resultState = state.searchResults, active = state.isSearchActive, diff --git a/libraries/emoji/impl/src/test/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerViewTest.kt b/libraries/emoji/impl/src/test/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerViewTest.kt new file mode 100644 index 0000000000..4c0d7949cb --- /dev/null +++ b/libraries/emoji/impl/src/test/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerViewTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.libraries.emoji.impl.picker + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsEqualTo +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.hasScrollToIndexAction +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import androidx.compose.ui.unit.dp +import io.element.android.emojibasebindings.Emoji +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Test +import org.robolectric.annotation.Config + +private val PICKER_HEIGHT = 600.dp + +class EmojiPickerViewTest : RobolectricTest() { + @Config(qualifiers = "w400dp-h800dp") + @Test + fun `the search results reach the bottom of the picker`() = runAndroidComposeUiTest { + setEmojiPickerView() + onNode(hasScrollToIndexAction()) + .getBoundsInRoot() + .bottom + .assertIsEqualTo(PICKER_HEIGHT, "emoji grid bottom") + } +} + +private fun AndroidComposeUiTest.setEmojiPickerView() { + setContent { + Box(Modifier.size(400.dp, PICKER_HEIGHT)) { + EmojiPickerView( + state = aDefaultEmojiPickerState( + isSearchActive = true, + searchQuery = "smile", + searchResults = SearchBarResultState.Results( + persistentListOf( + Emoji( + hexcode = "0x00", + label = "grinning face", + tags = persistentListOf("grinning"), + shortcodes = persistentListOf("smile"), + unicode = "😀", + skins = null, + ), + ) + ), + ), + onSelectEmoji = {}, + selectedEmojis = persistentSetOf(), + modifier = Modifier.fillMaxSize(), + ) + } + } +}