diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt index 8b2bc4dc90..34fca33e96 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt @@ -9,22 +9,10 @@ package io.element.android.features.securebackup.impl.enter import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.isImeVisible import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.relocation.BringIntoViewRequester -import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -34,13 +22,11 @@ import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyView import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.modifiers.bringIntoViewOnImeVisible import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.ui.strings.CommonStrings -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlin.time.Duration.Companion.milliseconds @Composable fun SecureBackupEnterRecoveryKeyView( @@ -71,29 +57,13 @@ fun SecureBackupEnterRecoveryKeyView( } } -@OptIn(ExperimentalLayoutApi::class) @Composable private fun Content( state: SecureBackupEnterRecoveryKeyState, ) { - val bringIntoViewRequester = remember { BringIntoViewRequester() } - var isFocused by remember { mutableStateOf(false) } - val isImeVisible = WindowInsets.isImeVisible - val coroutineScope = rememberCoroutineScope() - LaunchedEffect(isImeVisible, isFocused) { - // When the keyboard is shown, we want to scroll the text field into view - if (isImeVisible && isFocused) { - coroutineScope.launch { - // Delay to ensure the keyboard is fully shown - delay(100.milliseconds) - bringIntoViewRequester.bringIntoView() - } - } - } RecoveryKeyView( modifier = Modifier - .onFocusChanged { isFocused = it.isFocused } - .bringIntoViewRequester(bringIntoViewRequester) + .bringIntoViewOnImeVisible() .padding(top = 52.dp, bottom = 32.dp), state = state.recoveryKeyViewState, onClick = null, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/BringIntoViewOnImeVisible.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/BringIntoViewOnImeVisible.kt new file mode 100644 index 0000000000..155172c96f --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/BringIntoViewOnImeVisible.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.modifiers + +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.focus.onFocusChanged +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.milliseconds + +/** + * Keeps the focused field visible above the keyboard. Intended for a field inside a scrollable + * container: the field is never clipped by a pinned footer, but the IME is shown only after focus + * arrives, so we re-request bringIntoView once it is visible to scroll the field back into the + * reduced viewport. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun Modifier.bringIntoViewOnImeVisible(): Modifier { + val bringIntoViewRequester = remember { BringIntoViewRequester() } + var isFocused by remember { mutableStateOf(false) } + val isImeVisible = WindowInsets.isImeVisible + LaunchedEffect(isImeVisible, isFocused) { + if (isImeVisible && isFocused) { + // Delay to ensure the keyboard is fully shown before scrolling the field into view. + delay(100.milliseconds) + bringIntoViewRequester.bringIntoView() + } + } + return this + .bringIntoViewRequester(bringIntoViewRequester) + .onFocusChanged { isFocused = it.isFocused } +}