diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/CursorAnimationState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/CursorAnimationState.kt index 69faf49d07f08..2f530f3b34379 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/CursorAnimationState.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/CursorAnimationState.kt @@ -20,6 +20,8 @@ import androidx.compose.foundation.AtomicReference import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.InfiniteAnimationPolicy +import kotlin.coroutines.coroutineContext import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.coroutineScope @@ -52,7 +54,7 @@ internal class CursorAnimationState { * Won't return until the animation cancelled via [cancelAndHide] or this coroutine's [Job] is * cancelled. In both cases, the cursor will always end up hidden. */ - suspend fun snapToVisibleAndAnimate() { + suspend fun snapToVisibleAndAnimate() = runCursorAnimation { coroutineScope { // Can't do a single atomic update because we need to get the old value before launching // the new coroutine. So we set to null first, and then launch only if still null (i.e. @@ -94,3 +96,20 @@ internal class CursorAnimationState { job?.cancel() } } + + +/** + * Runs the infinite animation in [block], taking into account the current + * [InfiniteAnimationPolicy]. + * + * This is needed to allow the text field cursor blinking to be cancelled by + * [InfiniteAnimationPolicy] as if it was an animation. Otherwise `waitForIdle` in tests with a + * focused text field will never return. Note that on Android this isn't needed because there + * `waitForIdle` appears to completely ignore delayed tasks (see + * https://issuetracker.google.com/issues/324768454 for details). + */ +private suspend fun runCursorAnimation(block: suspend () -> Unit) = + when (val policy = coroutineContext[InfiniteAnimationPolicy]) { + null -> block() + else -> policy.onInfiniteOperation { block() } + } diff --git a/compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/copyPasteAndroidTests/textfield/TextFieldCursorTest.kt b/compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/copyPasteAndroidTests/textfield/TextFieldCursorTest.kt index 62bea3a2f1fac..9e6064ce274a0 100644 --- a/compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/copyPasteAndroidTests/textfield/TextFieldCursorTest.kt +++ b/compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/copyPasteAndroidTests/textfield/TextFieldCursorTest.kt @@ -26,11 +26,14 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.BasicTextField +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.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Brush @@ -39,9 +42,12 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SkikoComposeUiTest +import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextReplacement import androidx.compose.ui.test.runSkikoComposeUiTest @@ -383,6 +389,37 @@ class TextFieldCursorTest { ) } + @Test + fun cursorBlinkingDoesNotHangTestWithAutoAdvance() = runSkikoComposeUiTest { + mainClock.autoAdvance = true + cursorBlinkingDoesNotHangTest() + } + + @Test + fun cursorBlinkingDoesNotHangTestWithoutAutoAdvance() = runSkikoComposeUiTest { + mainClock.autoAdvance = false + cursorBlinkingDoesNotHangTest() + } + + private fun SkikoComposeUiTest.cursorBlinkingDoesNotHangTest() { + setContent { + val focusRequester = remember { FocusRequester() } + BasicTextField( + value = "", + onValueChange = { }, + modifier = Modifier + .focusRequester(focusRequester) + .testTag("textfield") + ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } + + onNodeWithTag("textfield").assertIsFocused() + } + private fun SkikoComposeUiTest.focusAndWait() { onNode(hasSetTextAction()).performClick() mainClock.advanceTimeUntil { isFocused } diff --git a/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeUiTest.skikoMain.kt b/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeUiTest.skikoMain.kt index bac11e896fb7e..e52ec6b6b398e 100644 --- a/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeUiTest.skikoMain.kt +++ b/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeUiTest.skikoMain.kt @@ -153,9 +153,11 @@ class SkikoComposeUiTest( scene = runOnUiThread(::createUi) return block() } finally { + // Close the scene before calling testScope.runTest so that all the coroutines are + // cancelled when we call it. + runOnUiThread(scene::close) // call runTest instead of deprecated cleanupTestCoroutines() testScope.runTest { } - runOnUiThread(scene::close) uncaughtExceptionHandler.throwUncaught() } }