diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/base/util/PinThisComposable.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/base/util/PinThisComposable.kt new file mode 100644 index 0000000000..b39b3ea06f --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/base/util/PinThisComposable.kt @@ -0,0 +1,22 @@ +package com.bitwarden.ui.platform.base.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.layout.LocalPinnableContainer +import androidx.compose.ui.layout.PinnableContainer + +/** + * Pins the calling composable in its [pinnableContainer] for as long as it stays in the + * composition, so a lazy layout keeps it composed even after it scrolls out of view. + * + * @param pinnableContainer The [PinnableContainer] to pin, typically the current lazy item. + */ +@Composable +fun PinThisComposable( + pinnableContainer: PinnableContainer? = LocalPinnableContainer.current, +) { + DisposableEffect(pinnableContainer) { + val pinnedHandle = pinnableContainer?.pin() + onDispose { pinnedHandle?.release() } + } +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/dropdown/BitwardenDatePickerButton.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/dropdown/BitwardenDatePickerButton.kt index a5eadabe65..fc180182d0 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/dropdown/BitwardenDatePickerButton.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/dropdown/BitwardenDatePickerButton.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import com.bitwarden.core.data.util.toFormattedDateStyle +import com.bitwarden.ui.platform.base.util.PinThisComposable import com.bitwarden.ui.platform.components.animation.AnimateNullableContentVisibility import com.bitwarden.ui.platform.components.button.BitwardenStandardIconButton import com.bitwarden.ui.platform.components.button.BitwardenTextSelectionButton @@ -102,6 +103,10 @@ fun BitwardenDatePickerButton( }, ) if (shouldShowDialog) { + // Keep this button composed while the dialog is showing. In a lazy list, the button can + // scroll out of view when the soft keyboard opens, and disposing it would dismiss the + // dialog and recreate it once the keyboard closes. + PinThisComposable() BitwardenDatePickerDialog( initialDate = currentDate, onDateSelect = { date -> diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/components/dropdown/BitwardenDatePickerButtonTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/components/dropdown/BitwardenDatePickerButtonTest.kt new file mode 100644 index 0000000000..4443524d1e --- /dev/null +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/components/dropdown/BitwardenDatePickerButtonTest.kt @@ -0,0 +1,74 @@ +package com.bitwarden.ui.platform.components.dropdown + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import androidx.test.platform.app.InstrumentationRegistry +import com.bitwarden.ui.platform.base.BaseComposeTest +import com.bitwarden.ui.platform.theme.BitwardenTheme +import kotlinx.coroutines.runBlocking +import org.junit.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset + +class BitwardenDatePickerButtonTest : BaseComposeTest() { + + @Test + fun `dialog should stay open when switching modes after the button scrolls away`() { + // Match a device, where tapping does not move focus to the tapped button. + InstrumentationRegistry.getInstrumentation().setInTouchMode(true) + lateinit var lazyListState: LazyListState + setTestContent { + BitwardenTheme { + lazyListState = rememberLazyListState() + LazyColumn(state = lazyListState) { + item { + BitwardenDatePickerButton( + label = LABEL, + currentDate = null, + onDateSelect = {}, + cardStyle = null, + clock = FIXED_CLOCK, + ) + } + items(count = 50) { + Spacer(modifier = Modifier.height(height = 200.dp)) + } + } + } + } + + composeTestRule.onNodeWithText(text = LABEL).performClick() + composeTestRule + .onNodeWithContentDescription(label = "Switch to text input mode") + .performClick() + composeTestRule.onNodeWithText(text = "Entered date").assertIsDisplayed() + + composeTestRule.runOnUiThread { + runBlocking { lazyListState.scrollToItem(index = 40) } + } + composeTestRule + .onNodeWithContentDescription(label = "Switch to calendar input mode") + .performClick() + + composeTestRule.onNodeWithTag(testTag = "DatePickerDialog").assertIsDisplayed() + composeTestRule.onNodeWithText(text = "Selected date").assertIsDisplayed() + } +} + +private const val LABEL: String = "Date of birth" + +private val FIXED_CLOCK: Clock = Clock.fixed( + Instant.parse("2026-10-26T12:00:00Z"), + ZoneOffset.UTC, +)