forked from androidx/androidx
-
Notifications
You must be signed in to change notification settings - Fork 81
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Use pure coroutines to animate cursor instead of Compose animation APIs.
Fixes for both BTF1 and BTF2. This change also manually observes window focus to stop the cursor animation when the window loses focus or the screen is turned off. This wasn't needed before because the animation framework automatically paused animations when the window lost focus. Bug: b/249422803 Relnote: "Cursor animation no longer requests frames between on and off states." Test: CursorAnimationStateTest Test: TextFieldCursorTest Change-Id: Ia22537827b1735a0c2f6f5c732bebf79aaa3b773
- Loading branch information
1 parent
6b4ea61
commit b1b9a0e
Showing
4 changed files
with
288 additions
and
52 deletions.
There are no files selected for viewing
166 changes: 166 additions & 0 deletions
166
...tTest/kotlin/androidx/compose/foundation/text2/input/internal/CursorAnimationStateTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,166 @@ | ||
/* | ||
* 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.text.input.internal | ||
|
||
import com.google.common.truth.Truth.assertThat | ||
import kotlin.test.Test | ||
import kotlinx.coroutines.CoroutineStart | ||
import kotlinx.coroutines.ExperimentalCoroutinesApi | ||
import kotlinx.coroutines.launch | ||
import kotlinx.coroutines.test.TestScope | ||
import kotlinx.coroutines.test.advanceUntilIdle | ||
import kotlinx.coroutines.test.runCurrent | ||
import kotlinx.coroutines.test.runTest | ||
import org.junit.runner.RunWith | ||
import org.junit.runners.JUnit4 | ||
|
||
@OptIn(ExperimentalCoroutinesApi::class) | ||
@RunWith(JUnit4::class) | ||
class CursorAnimationStateTest { | ||
|
||
private val animationState = CursorAnimationState() | ||
|
||
@Test | ||
fun alphaNotAnimatingInitially() = runTest { | ||
assertNotAnimating() | ||
} | ||
|
||
@Test | ||
fun snapToVisibleAndAnimate_animatesAlpha() = runTest { | ||
val job = launch { | ||
animationState.snapToVisibleAndAnimate() | ||
} | ||
|
||
// Should start immediately. | ||
assertThat(animationState.cursorAlpha).isEqualTo(0f) | ||
runCurrent() | ||
|
||
// Then let's verify a few blinks… | ||
assertThat(animationState.cursorAlpha).isEqualTo(1f) | ||
testScheduler.advanceTimeBy(500) | ||
assertThat(animationState.cursorAlpha).isEqualTo(1f) | ||
testScheduler.advanceTimeBy(500) | ||
assertThat(animationState.cursorAlpha).isEqualTo(0f) | ||
testScheduler.advanceTimeBy(500) | ||
assertThat(animationState.cursorAlpha).isEqualTo(1f) | ||
|
||
job.cancel() | ||
} | ||
|
||
@Test | ||
fun snapToVisibleAndAnimate_suspendsWhileAnimating() = runTest { | ||
val job = launch(start = CoroutineStart.UNDISPATCHED) { | ||
animationState.snapToVisibleAndAnimate() | ||
} | ||
|
||
// Advance a few blinks. | ||
repeat(10) { | ||
testScheduler.advanceTimeBy(500) | ||
assertThat(job.isActive).isTrue() | ||
} | ||
|
||
job.cancel() | ||
} | ||
|
||
@Test | ||
fun snapToVisibleAndAnimate_stopsAnimating_whenCancelledImmediately() = runTest { | ||
val job = launch(start = CoroutineStart.UNDISPATCHED) { | ||
animationState.snapToVisibleAndAnimate() | ||
} | ||
job.cancel() | ||
|
||
assertNotAnimating() | ||
assertThat(job.isActive).isFalse() | ||
} | ||
|
||
@Test | ||
fun snapToVisibleAndAnimate_stopsAnimating_whenCancelledAsync() = runTest { | ||
val job = launch { | ||
animationState.snapToVisibleAndAnimate() | ||
} | ||
job.cancel() | ||
|
||
assertNotAnimating() | ||
assertThat(job.isActive).isFalse() | ||
} | ||
|
||
@Test | ||
fun snapToVisibleAndAnimate_stopsAnimating_whenCancelledAfterAWhile() = runTest { | ||
val job = launch(start = CoroutineStart.UNDISPATCHED) { | ||
animationState.snapToVisibleAndAnimate() | ||
} | ||
|
||
// Advance a few blinks… | ||
repeat(10) { | ||
testScheduler.advanceTimeBy(500) | ||
} | ||
job.cancel() | ||
|
||
assertNotAnimating() | ||
} | ||
|
||
@Test | ||
fun cancelAndHide_stopsAnimating_immediately() = runTest { | ||
val job = launch(start = CoroutineStart.UNDISPATCHED) { | ||
animationState.snapToVisibleAndAnimate() | ||
} | ||
animationState.cancelAndHide() | ||
|
||
assertNotAnimating() | ||
assertThat(job.isActive).isFalse() | ||
} | ||
|
||
@Test | ||
fun cancelAndHide_beforeStart_doesntBlockAnimation() = runTest { | ||
animationState.cancelAndHide() | ||
val job = launch { | ||
animationState.snapToVisibleAndAnimate() | ||
} | ||
|
||
runCurrent() | ||
assertThat(animationState.cursorAlpha).isEqualTo(1f) | ||
|
||
job.cancel() | ||
} | ||
|
||
@Test | ||
fun cancelAndHide_stopsAnimating_afterAWhile() = runTest { | ||
val job = launch(start = CoroutineStart.UNDISPATCHED) { | ||
animationState.snapToVisibleAndAnimate() | ||
} | ||
|
||
// Advance a few blinks… | ||
repeat(10) { | ||
testScheduler.advanceTimeBy(500) | ||
} | ||
animationState.cancelAndHide() | ||
|
||
assertNotAnimating() | ||
assertThat(job.isActive).isFalse() | ||
} | ||
|
||
private fun TestScope.assertNotAnimating() { | ||
// Allow the cancellation to process. | ||
advanceUntilIdle() | ||
|
||
// Verify a few blinks. | ||
repeat(10) { | ||
assertThat(animationState.cursorAlpha).isEqualTo(0f) | ||
testScheduler.advanceTimeBy(490) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
96 changes: 96 additions & 0 deletions
96
...ommonMain/kotlin/androidx/compose/foundation/text2/input/internal/CursorAnimationState.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
/* | ||
* 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.text2.input.internal | ||
|
||
import androidx.compose.foundation.AtomicReference | ||
import androidx.compose.runtime.getValue | ||
import androidx.compose.runtime.mutableFloatStateOf | ||
import androidx.compose.runtime.setValue | ||
import kotlinx.coroutines.Job | ||
import kotlinx.coroutines.cancelAndJoin | ||
import kotlinx.coroutines.coroutineScope | ||
import kotlinx.coroutines.delay | ||
import kotlinx.coroutines.launch | ||
|
||
/** | ||
* Holds the state of the animation that blinks the cursor. | ||
* | ||
* We can't use the Compose Animation APIs because they busy-loop on delays, and this animation | ||
* spends most of its time delayed so that's a ton of wasted frames. Pure coroutine delays, however, | ||
* will not cause any work to be done until the delay is over. | ||
*/ | ||
internal class CursorAnimationState { | ||
|
||
private var animationJob = AtomicReference<Job?>(null) | ||
|
||
/** | ||
* The alpha value that should be used to draw the cursor. | ||
* Will always be in the range [0, 1]. | ||
*/ | ||
var cursorAlpha by mutableFloatStateOf(0f) | ||
private set | ||
|
||
/** | ||
* Immediately shows the cursor (sets [cursorAlpha] to 1f) and starts blinking it on and off | ||
* every 500ms. If a previous animation was running, it will be cancelled before the new one | ||
* starts. | ||
* | ||
* 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() { | ||
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. | ||
// no other caller beat us to starting a new animation). | ||
val oldJob = animationJob.getAndSet(null) | ||
|
||
// Even though we're launching a new coroutine, because of structured concurrency, the | ||
// restart function won't return until the animation is finished, and cancelling the | ||
// calling coroutine will cancel the animation. | ||
animationJob.compareAndSet(null, launch { | ||
// Join the old job after cancelling to ensure it finishes its finally block before | ||
// we start changing the cursor alpha, so we don't end up interleaving alpha | ||
// updates. | ||
oldJob?.cancelAndJoin() | ||
|
||
// Start the new animation and run until cancelled. | ||
try { | ||
while (true) { | ||
cursorAlpha = 1f | ||
// Ignore MotionDurationScale – the cursor should blink even when animations | ||
// are disabled by the system. | ||
delay(500) | ||
cursorAlpha = 0f | ||
delay(500) | ||
} | ||
} finally { | ||
// Hide cursor when the animation is cancelled. | ||
cursorAlpha = 0f | ||
} | ||
}) | ||
} | ||
} | ||
|
||
/** | ||
* Immediately cancels the cursor animation and hides the cursor (sets [cursorAlpha] to 0f). | ||
*/ | ||
fun cancelAndHide() { | ||
val job = animationJob.getAndSet(null) | ||
job?.cancel() | ||
} | ||
} |
Oops, something went wrong.