[Unfold animation] Start Launcher animation preemptively to synchronize the first frame

Starts unfold animation in Launcher right after receiving
configuration change for the unfolded screen.
This makes sure that before we unblock the screen we
have the first frame of the unfold animation ready
in Launcher (transformations are applied).

Bug: 271099882
Test: atest com.android.systemui.unfold.util.PreemptiveUnfoldTransitionProgressProviderTest
Test: manual testing fold/unfold, checking perfetto traces
Test: test with flag enabled/disabled
Change-Id: Icb8f91f9264248600d4bed14811445f50aac99c7
This commit is contained in:
Nick Chameyev
2023-04-14 17:05:12 +01:00
parent 955555a314
commit 705c665c06
7 changed files with 494 additions and 9 deletions
@@ -43,6 +43,10 @@ public abstract class BaseUnfoldMoveFromCenterAnimator implements TransitionProg
new UnfoldMoveFromCenterRotationListener();
private boolean mAnimationInProgress = false;
// Save the last transition progress so we can re-apply it in case we re-register the view for
// the animation (by calling onPrepareViewsForAnimation)
private Float mLastTransitionProgress = null;
public BaseUnfoldMoveFromCenterAnimator(WindowManager windowManager,
RotationChangeProvider rotationChangeProvider) {
mMoveFromCenterAnimation = new UnfoldMoveFromCenterAnimator(windowManager,
@@ -63,11 +67,13 @@ public abstract class BaseUnfoldMoveFromCenterAnimator implements TransitionProg
@Override
public void onTransitionProgress(float progress) {
mMoveFromCenterAnimation.onTransitionProgress(progress);
mLastTransitionProgress = progress;
}
@CallSuper
@Override
public void onTransitionFinished() {
mLastTransitionProgress = null;
mAnimationInProgress = false;
mRotationChangeProvider.removeCallback(mRotationListener);
mMoveFromCenterAnimation.onTransitionFinished();
@@ -93,8 +99,11 @@ public abstract class BaseUnfoldMoveFromCenterAnimator implements TransitionProg
mOriginalClipToPadding.clear();
}
@CallSuper
protected void onPrepareViewsForAnimation() {
if (mLastTransitionProgress != null) {
mMoveFromCenterAnimation.onTransitionProgress(mLastTransitionProgress);
}
}
protected void registerViewForAnimation(View view) {
@@ -27,10 +27,15 @@ import android.view.WindowManager;
import androidx.core.view.OneShotPreDrawListener;
import com.android.launcher3.DeviceProfile;
import com.android.launcher3.DeviceProfile.OnDeviceProfileChangeListener;
import com.android.launcher3.Hotseat;
import com.android.launcher3.Launcher;
import com.android.launcher3.Workspace;
import com.android.launcher3.config.FeatureFlags;
import com.android.launcher3.util.HorizontalInsettableView;
import com.android.quickstep.SystemUiProxy;
import com.android.quickstep.util.unfold.PreemptiveUnfoldTransitionProgressProvider;
import com.android.systemui.unfold.UnfoldTransitionProgressProvider;
import com.android.systemui.unfold.UnfoldTransitionProgressProvider.TransitionProgressListener;
import com.android.systemui.unfold.updates.RotationChangeProvider;
@@ -40,7 +45,7 @@ import com.android.systemui.unfold.util.ScopedUnfoldTransitionProgressProvider;
/**
* Controls animations that are happening during unfolding foldable devices
*/
public class LauncherUnfoldAnimationController {
public class LauncherUnfoldAnimationController implements OnDeviceProfileChangeListener {
// Percentage of the width of the quick search bar that will be reduced
// from the both sides of the bar when progress is 0
@@ -55,9 +60,11 @@ public class LauncherUnfoldAnimationController {
private final NaturalRotationUnfoldProgressProvider mNaturalOrientationProgressProvider;
private final UnfoldMoveFromCenterHotseatAnimator mUnfoldMoveFromCenterHotseatAnimator;
private final UnfoldMoveFromCenterWorkspaceAnimator mUnfoldMoveFromCenterWorkspaceAnimator;
private PreemptiveUnfoldTransitionProgressProvider mPreemptiveProgressProvider = null;
private Boolean mIsTablet = null;
private static final String TRACE_WAIT_TO_HANDLE_UNFOLD_TRANSITION =
"waitingOneFrameBeforeHandlingUnfoldAnimation";
"LauncherUnfoldAnimationController#waitingForTheNextFrame";
@Nullable
private HorizontalInsettableView mQsbInsettable;
@@ -68,8 +75,19 @@ public class LauncherUnfoldAnimationController {
UnfoldTransitionProgressProvider unfoldTransitionProgressProvider,
RotationChangeProvider rotationChangeProvider) {
mLauncher = launcher;
mProgressProvider = new ScopedUnfoldTransitionProgressProvider(
unfoldTransitionProgressProvider);
if (FeatureFlags.PREEMPTIVE_UNFOLD_ANIMATION_START.get()) {
mPreemptiveProgressProvider = new PreemptiveUnfoldTransitionProgressProvider(
unfoldTransitionProgressProvider, launcher.getMainThreadHandler());
mPreemptiveProgressProvider.init();
mProgressProvider = new ScopedUnfoldTransitionProgressProvider(
mPreemptiveProgressProvider);
} else {
mProgressProvider = new ScopedUnfoldTransitionProgressProvider(
unfoldTransitionProgressProvider);
}
mUnfoldMoveFromCenterHotseatAnimator = new UnfoldMoveFromCenterHotseatAnimator(launcher,
windowManager, rotationChangeProvider);
mUnfoldMoveFromCenterWorkspaceAnimator = new UnfoldMoveFromCenterWorkspaceAnimator(launcher,
@@ -85,6 +103,8 @@ public class LauncherUnfoldAnimationController {
// Animated only in natural orientation
mNaturalOrientationProgressProvider.addCallback(new QsbAnimationListener());
mNaturalOrientationProgressProvider.addCallback(mUnfoldMoveFromCenterHotseatAnimator);
mLauncher.addOnDeviceProfileChangeListener(this);
}
/**
@@ -96,17 +116,21 @@ public class LauncherUnfoldAnimationController {
mQsbInsettable = (HorizontalInsettableView) hotseat.getQsb();
}
handleTransitionOnNextFrame();
mProgressProvider.setReadyToHandleTransition(true);
}
private void handleTransitionOnNextFrame() {
private void preemptivelyStartAnimationOnNextFrame() {
Trace.asyncTraceBegin(Trace.TRACE_TAG_APP,
TRACE_WAIT_TO_HANDLE_UNFOLD_TRANSITION, /* cookie= */ 0);
// Start the animation (and apply the transformations) in pre-draw listener to make sure
// that the views are laid out as some transformations depend on the view sizes and position
OneShotPreDrawListener.add(mLauncher.getWorkspace(),
() -> {
Trace.asyncTraceEnd(Trace.TRACE_TAG_APP,
TRACE_WAIT_TO_HANDLE_UNFOLD_TRANSITION, /* cookie= */ 0);
mProgressProvider.setReadyToHandleTransition(true);
mPreemptiveProgressProvider.preemptivelyStartTransition(
/* initialProgress= */ 0f);
});
}
@@ -124,14 +148,34 @@ public class LauncherUnfoldAnimationController {
public void onDestroy() {
mProgressProvider.destroy();
mNaturalOrientationProgressProvider.destroy();
mLauncher.removeOnDeviceProfileChangeListener(this);
}
/** Called when launcher finished binding its items. */
/**
* Called when launcher has finished binding its items
*/
public void updateRegisteredViewsIfNeeded() {
mUnfoldMoveFromCenterHotseatAnimator.updateRegisteredViewsIfNeeded();
mUnfoldMoveFromCenterWorkspaceAnimator.updateRegisteredViewsIfNeeded();
}
@Override
public void onDeviceProfileChanged(DeviceProfile dp) {
if (!FeatureFlags.PREEMPTIVE_UNFOLD_ANIMATION_START.get()) {
return;
}
if (mIsTablet != null && dp.isTablet != mIsTablet) {
if (dp.isTablet && SystemUiProxy.INSTANCE.get(mLauncher).isActive()) {
// Preemptively start the unfold animation to make sure that we have drawn
// the first frame of the animation before the screen gets unblocked
preemptivelyStartAnimationOnNextFrame();
}
}
mIsTablet = dp.isTablet;
}
private class QsbAnimationListener implements TransitionProgressListener {
@Override
@@ -48,6 +48,8 @@ public class UnfoldMoveFromCenterHotseatAnimator extends BaseUnfoldMoveFromCente
View child = hotseatIcons.getChildAt(i);
registerViewForAnimation(child);
}
super.onPrepareViewsForAnimation();
}
@Override
@@ -58,6 +58,8 @@ public class UnfoldMoveFromCenterWorkspaceAnimator extends BaseUnfoldMoveFromCen
setClipChildren(workspace, false);
setClipToPadding(workspace, true);
super.onPrepareViewsForAnimation();
}
@Override
@@ -0,0 +1,161 @@
/*
* Copyright (C) 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 com.android.quickstep.util.unfold
import android.os.Handler
import android.os.Trace
import android.util.Log
import com.android.systemui.unfold.UnfoldTransitionProgressProvider
import com.android.systemui.unfold.UnfoldTransitionProgressProvider.TransitionProgressListener
/**
* Transition progress provider wrapper that can preemptively start the transition on demand
* without relying on the source provider. When the source provider has started the animation
* it switches to it.
*
* This might be useful when we want to synchronously start the unfold animation and render
* the first frame during turning on the screen. For example, this is used in Launcher where
* we need to render the first frame of the animation immediately after receiving a configuration
* change event so Window Manager will wait for this frame to be rendered before unblocking
* the screen. We can't rely on the original transition progress as it starts the animation
* after the screen fully turned on (and unblocked), at this moment it is already too late to
* start the animation.
*
* Using this provider we could render the first frame preemptively by sending 'transition started'
* and '0' transition progress before the original progress provider sends these events.
*/
class PreemptiveUnfoldTransitionProgressProvider(
private val source: UnfoldTransitionProgressProvider,
private val handler: Handler
) : UnfoldTransitionProgressProvider, TransitionProgressListener {
private val timeoutRunnable = Runnable {
if (isRunning) {
listeners.forEach { it.onTransitionFinished() }
onPreemptiveStartFinished()
Log.wtf(TAG, "Timeout occurred when waiting for the source transition to start")
}
}
private val listeners = arrayListOf<TransitionProgressListener>()
private var isPreemptivelyRunning = false
private var isSourceRunning = false
private val isRunning: Boolean
get() = isPreemptivelyRunning || isSourceRunning
private val sourceListener =
object : TransitionProgressListener {
override fun onTransitionStarted() {
handler.removeCallbacks(timeoutRunnable)
if (!isRunning) {
listeners.forEach { it.onTransitionStarted() }
}
onPreemptiveStartFinished()
isSourceRunning = true
}
override fun onTransitionProgress(progress: Float) {
if (isRunning) {
listeners.forEach { it.onTransitionProgress(progress) }
isSourceRunning = true
}
}
override fun onTransitionFinishing() {
if (isRunning) {
listeners.forEach { it.onTransitionFinishing() }
isSourceRunning = true
}
}
override fun onTransitionFinished() {
if (isRunning) {
listeners.forEach { it.onTransitionFinished() }
}
isSourceRunning = false
onPreemptiveStartFinished()
handler.removeCallbacks(timeoutRunnable)
}
}
fun init() {
source.addCallback(sourceListener)
}
/**
* Starts the animation preemptively.
*
* - If the source provider is already running, this method won't change any behavior
* - If the source provider has not started running yet, it will call onTransitionStarted
* for all listeners and optionally onTransitionProgress(initialProgress) if supplied.
* When the source provider starts the animation it will switch to send progress and finished
* events from it.
* If the source provider won't start the animation within a timeout, the animation will be
* cancelled and onTransitionFinished will be delivered to the current listeners.
*/
@JvmOverloads
fun preemptivelyStartTransition(initialProgress: Float? = null) {
if (!isRunning) {
Trace.beginAsyncSection("$TAG#startedPreemptively", 0)
listeners.forEach { it.onTransitionStarted() }
initialProgress?.let { progress ->
listeners.forEach { it.onTransitionProgress(progress) }
}
handler.removeCallbacks(timeoutRunnable)
handler.postDelayed(timeoutRunnable, PREEMPTIVE_UNFOLD_TIMEOUT_MS)
}
isPreemptivelyRunning = true
}
fun cancelPreemptiveStart() {
handler.removeCallbacks(timeoutRunnable)
if (isRunning) {
listeners.forEach { it.onTransitionFinished() }
}
onPreemptiveStartFinished()
}
private fun onPreemptiveStartFinished() {
if (isPreemptivelyRunning) {
Trace.endAsyncSection("$TAG#startedPreemptively", 0)
isPreemptivelyRunning = false
}
}
override fun destroy() {
handler.removeCallbacks(timeoutRunnable)
source.removeCallback(sourceListener)
source.destroy()
}
override fun addCallback(listener: TransitionProgressListener) {
listeners += listener
}
override fun removeCallback(listener: TransitionProgressListener) {
listeners -= listener
}
}
const val TAG = "PreemptiveUnfoldTransitionProgressProvider"
const val PREEMPTIVE_UNFOLD_TIMEOUT_MS = 1700L
@@ -0,0 +1,261 @@
/*
* Copyright (C) 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 com.android.quickstep.util.unfold
import android.os.Handler
import android.testing.AndroidTestingRunner
import android.testing.TestableLooper
import android.testing.TestableLooper.RunWithLooper
import android.util.Log
import androidx.test.filters.SmallTest
import com.android.launcher3.util.any
import com.android.launcher3.util.mock
import com.android.systemui.unfold.UnfoldTransitionProgressProvider
import com.android.systemui.unfold.UnfoldTransitionProgressProvider.TransitionProgressListener
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.anyBoolean
import org.mockito.Mockito.anyFloat
import org.mockito.Mockito.inOrder
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
@SmallTest
@RunWith(AndroidTestingRunner::class)
@RunWithLooper
class PreemptiveUnfoldTransitionProgressProviderTest {
private lateinit var testableLooper: TestableLooper
private lateinit var source: TransitionProgressListener
private lateinit var handler: Handler
private lateinit var oldWtfHandler: Log.TerribleFailureHandler
private val listener: TransitionProgressListener = mock()
private val testWtfHandler: Log.TerribleFailureHandler = mock()
private lateinit var provider: PreemptiveUnfoldTransitionProgressProvider
@Before
fun before() {
testableLooper = TestableLooper.get(this)
handler = Handler(testableLooper.looper)
val testSource = createSource()
source = testSource as TransitionProgressListener
oldWtfHandler = Log.setWtfHandler(testWtfHandler)
provider = PreemptiveUnfoldTransitionProgressProvider(testSource, handler)
provider.init()
provider.addCallback(listener)
}
@After
fun after() {
Log.setWtfHandler(oldWtfHandler)
}
@Test
fun preemptiveStartInitialProgressNull_transitionStarts() {
provider.preemptivelyStartTransition(initialProgress = null)
verify(listener).onTransitionStarted()
verify(listener, never()).onTransitionProgress(anyFloat())
}
@Test
fun preemptiveStartWithInitialProgress_startsAnimationAndSendsProgress() {
provider.preemptivelyStartTransition(initialProgress = 0.5f)
verify(listener).onTransitionStarted()
verify(listener).onTransitionProgress(0.5f)
}
@Test
fun preemptiveStartAndCancel_finishesAnimation() {
provider.preemptivelyStartTransition()
provider.cancelPreemptiveStart()
with(inOrder(listener)) {
verify(listener).onTransitionStarted()
verify(listener).onTransitionFinished()
}
}
@Test
fun preemptiveStartAndThenSourceStartsTransition_transitionStarts() {
provider.preemptivelyStartTransition()
source.onTransitionStarted()
verify(listener).onTransitionStarted()
}
@Test
fun preemptiveStartAndThenSourceStartsAndFinishesTransition_transitionFinishes() {
provider.preemptivelyStartTransition()
source.onTransitionStarted()
source.onTransitionFinished()
with(inOrder(listener)) {
verify(listener).onTransitionStarted()
verify(listener).onTransitionFinished()
}
}
@Test
fun preemptiveStartAndThenSourceStartsAnimationAndSendsProgress_sendsProgress() {
provider.preemptivelyStartTransition()
source.onTransitionStarted()
source.onTransitionProgress(0.4f)
verify(listener).onTransitionProgress(0.4f)
}
@Test
fun preemptiveStartAndThenSourceSendsProgress_sendsProgress() {
provider.preemptivelyStartTransition()
source.onTransitionProgress(0.4f)
verify(listener).onTransitionProgress(0.4f)
}
@Test
fun preemptiveStartAfterTransitionRunning_transitionStarted() {
source.onTransitionStarted()
provider.preemptivelyStartTransition()
verify(listener).onTransitionStarted()
}
@Test
fun preemptiveStartAfterTransitionRunningAndThenFinished_transitionFinishes() {
source.onTransitionStarted()
provider.preemptivelyStartTransition()
source.onTransitionFinished()
with(inOrder(listener)) {
verify(listener).onTransitionStarted()
verify(listener).onTransitionFinished()
}
}
@Test
fun preemptiveStart_transitionDoesNotFinishAfterTimeout_finishesTransition() {
provider.preemptivelyStartTransition()
testableLooper.moveTimeForward(PREEMPTIVE_UNFOLD_TIMEOUT_MS + 1)
testableLooper.processAllMessages()
with(inOrder(listener)) {
verify(listener).onTransitionStarted()
verify(listener).onTransitionFinished()
}
}
@Test
fun preemptiveStart_transitionFinishAfterTimeout_logsWtf() {
provider.preemptivelyStartTransition()
testableLooper.moveTimeForward(PREEMPTIVE_UNFOLD_TIMEOUT_MS + 1)
testableLooper.processAllMessages()
verify(testWtfHandler).onTerribleFailure(any(), any(), anyBoolean())
}
@Test
fun preemptiveStart_transitionDoesNotFinishBeforeTimeout_doesNotFinishTransition() {
provider.preemptivelyStartTransition()
testableLooper.moveTimeForward(PREEMPTIVE_UNFOLD_TIMEOUT_MS - 1)
testableLooper.processAllMessages()
verify(listener).onTransitionStarted()
}
@Test
fun preemptiveStart_transitionStarted_timeoutHappened_doesNotFinishTransition() {
provider.preemptivelyStartTransition()
source.onTransitionStarted()
testableLooper.moveTimeForward(PREEMPTIVE_UNFOLD_TIMEOUT_MS + 1)
testableLooper.processAllMessages()
verify(listener).onTransitionStarted()
}
@Test
fun noPreemptiveStart_transitionStarted_startsTransition() {
source.onTransitionStarted()
verify(listener).onTransitionStarted()
}
@Test
fun noPreemptiveStart_transitionProgress_sendsProgress() {
source.onTransitionStarted()
source.onTransitionProgress(0.5f)
verify(listener).onTransitionProgress(0.5f)
}
@Test
fun noPreemptiveStart_transitionFinishes_finishesTransition() {
source.onTransitionStarted()
source.onTransitionProgress(0.5f)
source.onTransitionFinished()
with(inOrder(listener)) {
verify(listener).onTransitionStarted()
verify(listener).onTransitionFinished()
}
}
private fun createSource(): UnfoldTransitionProgressProvider =
object : TransitionProgressListener, UnfoldTransitionProgressProvider {
private val listeners = arrayListOf<TransitionProgressListener>()
override fun addCallback(listener: TransitionProgressListener) {
listeners += listener
}
override fun removeCallback(listener: TransitionProgressListener) {
listeners -= listener
}
override fun destroy() {}
override fun onTransitionStarted() =
listeners.forEach(TransitionProgressListener::onTransitionStarted)
override fun onTransitionFinishing() =
listeners.forEach(TransitionProgressListener::onTransitionFinishing)
override fun onTransitionFinished() =
listeners.forEach(TransitionProgressListener::onTransitionFinished)
override fun onTransitionProgress(progress: Float) =
listeners.forEach { it.onTransitionProgress(progress) }
}
}
@@ -307,6 +307,12 @@ public final class FeatureFlags {
"Enables receiving unfold animation events from sysui instead of calculating "
+ "them in launcher process using hinge sensor values.");
public static final BooleanFlag PREEMPTIVE_UNFOLD_ANIMATION_START = getDebugFlag(270397209,
"PREEMPTIVE_UNFOLD_ANIMATION_START", ENABLED,
"Enables starting the unfold animation preemptively when unfolding, without"
+ "waiting for SystemUI and then merging the SystemUI progress whenever we "
+ "start receiving the events");
// TODO(Block 23): Clean up flags
public static final BooleanFlag ENABLE_GRID_ONLY_OVERVIEW = getDebugFlag(270397206,
"ENABLE_GRID_ONLY_OVERVIEW", DISABLED,