Add Predictive Back animation from Dialog to QS tile

Test: atest BackTransformationTest
Test: atest BackAnimationSpecTest
Test: atest OnBackAnimationCallbackExtensionTest
Bug: 238475317
Change-Id: I95bbeb3608906aa8f79177e62d94bafcf99b060c
This commit is contained in:
omarmt
2023-01-18 14:39:11 +00:00
parent 1f889a05b6
commit 6a67abb2f8
11 changed files with 580 additions and 17 deletions

View File

@@ -33,8 +33,13 @@ import android.view.WindowInsets
import android.view.WindowManager
import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
import android.widget.FrameLayout
import android.window.OnBackInvokedDispatcher
import com.android.internal.jank.InteractionJankMonitor
import com.android.internal.jank.InteractionJankMonitor.CujType
import com.android.systemui.animation.back.BackAnimationSpec
import com.android.systemui.animation.back.applyTo
import com.android.systemui.animation.back.floatingSystemSurfacesForSysUi
import com.android.systemui.animation.back.onBackAnimationCallbackFrom
import kotlin.math.roundToInt
private const val TAG = "DialogLaunchAnimator"
@@ -55,8 +60,9 @@ class DialogLaunchAnimator
constructor(
private val callback: Callback,
private val interactionJankMonitor: InteractionJankMonitor,
private val featureFlags: AnimationFeatureFlags,
private val launchAnimator: LaunchAnimator = LaunchAnimator(TIMINGS, INTERPOLATORS),
private val isForTesting: Boolean = false
private val isForTesting: Boolean = false,
) {
private companion object {
private val TIMINGS = ActivityLaunchAnimator.TIMINGS
@@ -273,15 +279,16 @@ constructor(
val animatedDialog =
AnimatedDialog(
launchAnimator,
callback,
interactionJankMonitor,
controller,
launchAnimator = launchAnimator,
callback = callback,
interactionJankMonitor = interactionJankMonitor,
controller = controller,
onDialogDismissed = { openedDialogs.remove(it) },
dialog = dialog,
animateBackgroundBoundsChange,
animatedParent,
isForTesting,
animateBackgroundBoundsChange = animateBackgroundBoundsChange,
parentAnimatedDialog = animatedParent,
forceDisableSynchronization = isForTesting,
featureFlags = featureFlags,
)
openedDialogs.add(animatedDialog)
@@ -517,6 +524,7 @@ private class AnimatedDialog(
* Whether synchronization should be disabled, which can be useful if we are running in a test.
*/
private val forceDisableSynchronization: Boolean,
private val featureFlags: AnimationFeatureFlags,
) {
/**
* The DecorView of this dialog window.
@@ -778,12 +786,45 @@ private class AnimatedDialog(
// the dialog.
dialog.setDismissOverride(this::onDialogDismissed)
if (featureFlags.isPredictiveBackQsDialogAnim) {
// TODO(b/265923095) Improve animations for QS dialogs on configuration change
registerOnBackInvokedCallback(targetView = dialogContentWithBackground)
}
// Show the dialog.
dialog.show()
moveSourceDrawingToDialog()
}
private fun registerOnBackInvokedCallback(targetView: View) {
val metrics = targetView.resources.displayMetrics
val onBackAnimationCallback =
onBackAnimationCallbackFrom(
backAnimationSpec = BackAnimationSpec.floatingSystemSurfacesForSysUi(metrics),
displayMetrics = metrics, // TODO(b/265060720): We could remove this
onBackProgressed = { backTransformation -> backTransformation.applyTo(targetView) },
onBackInvoked = { dialog.dismiss() },
)
val dispatcher = dialog.onBackInvokedDispatcher
targetView.addOnAttachStateChangeListener(
object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
dispatcher.registerOnBackInvokedCallback(
OnBackInvokedDispatcher.PRIORITY_DEFAULT,
onBackAnimationCallback
)
}
override fun onViewDetachedFromWindow(v: View) {
targetView.removeOnAttachStateChangeListener(this)
dispatcher.unregisterOnBackInvokedCallback(onBackAnimationCallback)
}
}
)
}
private fun moveSourceDrawingToDialog() {
if (decorView.viewRootImpl == null) {
// Make sure that we have access to the dialog view root to move the drawing to the

View File

@@ -0,0 +1,73 @@
/*
* 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.systemui.animation.back
import android.util.DisplayMetrics
import android.view.animation.Interpolator
import android.window.BackEvent
import com.android.systemui.animation.Interpolators
import com.android.systemui.util.dpToPx
/** Used to convert [BackEvent] into a [BackTransformation]. */
fun interface BackAnimationSpec {
/** Computes transformation based on a [backEvent] and sets it to [result]. */
fun getBackTransformation(
backEvent: BackEvent,
progressY: Float, // TODO(b/265060720): Remove progressY. Could be retrieved from backEvent
result: BackTransformation,
)
companion object
}
/** Create a [BackAnimationSpec] from [displayMetrics] and design specs. */
fun BackAnimationSpec.Companion.createFloatingSurfaceAnimationSpec(
displayMetrics: DisplayMetrics,
maxMarginXdp: Float,
maxMarginYdp: Float,
minScale: Float,
translateXEasing: Interpolator = Interpolators.STANDARD_DECELERATE,
translateYEasing: Interpolator = Interpolators.LINEAR,
scaleEasing: Interpolator = Interpolators.STANDARD_DECELERATE,
): BackAnimationSpec {
val screenWidthPx = displayMetrics.widthPixels
val screenHeightPx = displayMetrics.heightPixels
val maxMarginXPx = maxMarginXdp.dpToPx(displayMetrics)
val maxMarginYPx = maxMarginYdp.dpToPx(displayMetrics)
val maxTranslationXByScale = (screenWidthPx - screenWidthPx * minScale) / 2
val maxTranslationX = maxTranslationXByScale - maxMarginXPx
val maxTranslationYByScale = (screenHeightPx - screenHeightPx * minScale) / 2
val maxTranslationY = maxTranslationYByScale - maxMarginYPx
val minScaleReversed = 1f - minScale
return BackAnimationSpec { backEvent, progressY, result ->
val direction = if (backEvent.swipeEdge == BackEvent.EDGE_LEFT) 1 else -1
val progressX = backEvent.progress
val ratioTranslateX = translateXEasing.getInterpolation(progressX)
val ratioTranslateY = translateYEasing.getInterpolation(progressY)
val ratioScale = scaleEasing.getInterpolation(progressX)
result.apply {
translateX = ratioTranslateX * direction * maxTranslationX
translateY = ratioTranslateY * maxTranslationY
scale = 1f - (ratioScale * minScaleReversed)
}
}
}

View File

@@ -0,0 +1,75 @@
/*
* 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.systemui.animation.back
import android.util.DisplayMetrics
/**
* SysUI transitions - Dismiss app (ST1) Return to launching surface or place of origin
* https://carbon.googleplex.com/predictive-back-for-apps/pages/st-1-dismiss-app
*/
fun BackAnimationSpec.Companion.dismissAppForSysUi(
displayMetrics: DisplayMetrics,
): BackAnimationSpec =
BackAnimationSpec.createFloatingSurfaceAnimationSpec(
displayMetrics = displayMetrics,
maxMarginXdp = 8f,
maxMarginYdp = 8f,
minScale = 0.8f,
)
/**
* SysUI transitions - Cross task (ST2) Return to previous task/app, keeping the current one open
* https://carbon.googleplex.com/predictive-back-for-apps/pages/st-2-cross-task
*/
fun BackAnimationSpec.Companion.crossTaskForSysUi(
displayMetrics: DisplayMetrics,
): BackAnimationSpec =
BackAnimationSpec.createFloatingSurfaceAnimationSpec(
displayMetrics = displayMetrics,
maxMarginXdp = 8f,
maxMarginYdp = 8f,
minScale = 0.8f,
)
/**
* SysUI transitions - Inner area dismiss (ST3) Dismiss non-detachable surface
* https://carbon.googleplex.com/predictive-back-for-apps/pages/st-3-inner-area-dismiss
*/
fun BackAnimationSpec.Companion.innerAreaDismissForSysUi(
displayMetrics: DisplayMetrics,
): BackAnimationSpec =
BackAnimationSpec.createFloatingSurfaceAnimationSpec(
displayMetrics = displayMetrics,
maxMarginXdp = 0f,
maxMarginYdp = 0f,
minScale = 0.9f,
)
/**
* SysUI transitions - Floating system surfaces (ST4)
* https://carbon.googleplex.com/predictive-back-for-apps/pages/st-4-floating-system-surfaces
*/
fun BackAnimationSpec.Companion.floatingSystemSurfacesForSysUi(
displayMetrics: DisplayMetrics,
): BackAnimationSpec =
BackAnimationSpec.createFloatingSurfaceAnimationSpec(
displayMetrics = displayMetrics,
maxMarginXdp = 8f,
maxMarginYdp = 8f,
minScale = 0.8f,
)

View File

@@ -0,0 +1,39 @@
/*
* 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.systemui.animation.back
import android.view.View
/**
* This object that represents the transformation to apply to the target. The properties of this
* object are mutable for performance reasons (avoid recreating this object)
*/
data class BackTransformation(
var translateX: Float = Float.NaN,
var translateY: Float = Float.NaN,
var scale: Float = Float.NaN,
)
/** Apply the transformation to the [targetView] */
fun BackTransformation.applyTo(targetView: View) {
if (translateX.isFinite()) targetView.translationX = translateX
if (translateY.isFinite()) targetView.translationY = translateY
if (scale.isFinite()) {
targetView.scaleX = scale
targetView.scaleY = scale
}
}

View File

@@ -0,0 +1,66 @@
/*
* 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.systemui.animation.back
import android.util.DisplayMetrics
import android.window.BackEvent
import android.window.OnBackAnimationCallback
/**
* Generates an [OnBackAnimationCallback] given a [backAnimationSpec]. [onBackProgressed] will be
* called on each update passing the current [BackTransformation].
*
* Optionally, you can specify [onBackStarted], [onBackInvoked], and [onBackCancelled] callbacks.
*/
fun onBackAnimationCallbackFrom(
backAnimationSpec: BackAnimationSpec,
displayMetrics: DisplayMetrics, // TODO(b/265060720): We could remove this
onBackProgressed: (BackTransformation) -> Unit,
onBackStarted: (BackEvent) -> Unit = {},
onBackInvoked: () -> Unit = {},
onBackCancelled: () -> Unit = {},
): OnBackAnimationCallback {
return object : OnBackAnimationCallback {
private var initialY = 0f
private val lastTransformation = BackTransformation()
override fun onBackStarted(backEvent: BackEvent) {
initialY = backEvent.touchY
onBackStarted(backEvent)
}
override fun onBackProgressed(backEvent: BackEvent) {
val progressY = (backEvent.touchY - initialY) / displayMetrics.heightPixels
backAnimationSpec.getBackTransformation(
backEvent = backEvent,
progressY = progressY,
result = lastTransformation,
)
onBackProgressed(lastTransformation)
}
override fun onBackInvoked() {
onBackInvoked()
}
override fun onBackCancelled() {
onBackCancelled()
}
}
}

View File

@@ -0,0 +1,32 @@
/*
* 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.systemui.util
import android.content.Context
import android.content.res.Resources
import android.util.DisplayMetrics
import android.util.TypedValue
/** Convert [this] number of dps to device pixels. */
fun Number.dpToPx(context: Context): Float = dpToPx(resources = context.resources)
/** Convert [this] number of dps to device pixels. */
fun Number.dpToPx(resources: Resources): Float = dpToPx(displayMetrics = resources.displayMetrics)
/** Convert [this] number of dps to device pixels. */
fun Number.dpToPx(displayMetrics: DisplayMetrics): Float =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, toFloat(), displayMetrics)

View File

@@ -284,7 +284,8 @@ public interface CentralSurfacesDependenciesModule {
static DialogLaunchAnimator provideDialogLaunchAnimator(IDreamManager dreamManager,
KeyguardStateController keyguardStateController,
Lazy<AlternateBouncerInteractor> alternateBouncerInteractor,
InteractionJankMonitor interactionJankMonitor) {
InteractionJankMonitor interactionJankMonitor,
AnimationFeatureFlags animationFeatureFlags) {
DialogLaunchAnimator.Callback callback = new DialogLaunchAnimator.Callback() {
@Override
public boolean isDreaming() {
@@ -306,7 +307,7 @@ public interface CentralSurfacesDependenciesModule {
return alternateBouncerInteractor.get().canShowAlternateBouncerForFingerprint();
}
};
return new DialogLaunchAnimator(callback, interactionJankMonitor);
return new DialogLaunchAnimator(callback, interactionJankMonitor, animationFeatureFlags);
}
/**

View File

@@ -0,0 +1,87 @@
package com.android.systemui.animation.back
import android.util.DisplayMetrics
import android.window.BackEvent
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
private data class BackInput(val progressX: Float, val progressY: Float, val edge: Int)
@SmallTest
@RunWith(JUnit4::class)
class BackAnimationSpecTest : SysuiTestCase() {
private var displayMetrics =
DisplayMetrics().apply {
widthPixels = 100
heightPixels = 200
density = 3f
}
@Test
fun sysUi_floatingSystemSurfaces_animationValues() {
val maxX = 14.0f
val maxY = 4.0f
val minScale = 0.8f
val backAnimationSpec = BackAnimationSpec.floatingSystemSurfacesForSysUi(displayMetrics)
assertBackTransformation(
backAnimationSpec = backAnimationSpec,
backInput = BackInput(progressX = 0f, progressY = 0f, edge = BackEvent.EDGE_LEFT),
expected = BackTransformation(translateX = 0f, translateY = 0f, scale = 1f),
)
assertBackTransformation(
backAnimationSpec = backAnimationSpec,
backInput = BackInput(progressX = 1f, progressY = 0f, edge = BackEvent.EDGE_LEFT),
expected = BackTransformation(translateX = -maxX, translateY = 0f, scale = minScale),
)
assertBackTransformation(
backAnimationSpec = backAnimationSpec,
backInput = BackInput(progressX = 1f, progressY = 0f, edge = BackEvent.EDGE_RIGHT),
expected = BackTransformation(translateX = maxX, translateY = 0f, scale = minScale),
)
assertBackTransformation(
backAnimationSpec = backAnimationSpec,
backInput = BackInput(progressX = 1f, progressY = 1f, edge = BackEvent.EDGE_LEFT),
expected = BackTransformation(translateX = -maxX, translateY = -maxY, scale = minScale),
)
assertBackTransformation(
backAnimationSpec = backAnimationSpec,
backInput = BackInput(progressX = 0f, progressY = 1f, edge = BackEvent.EDGE_LEFT),
expected = BackTransformation(translateX = 0f, translateY = -maxY, scale = 1f),
)
assertBackTransformation(
backAnimationSpec = backAnimationSpec,
backInput = BackInput(progressX = 0f, progressY = -1f, edge = BackEvent.EDGE_LEFT),
expected = BackTransformation(translateX = 0f, translateY = maxY, scale = 1f),
)
}
}
private fun assertBackTransformation(
backAnimationSpec: BackAnimationSpec,
backInput: BackInput,
expected: BackTransformation,
) {
val actual = BackTransformation()
backAnimationSpec.getBackTransformation(
backEvent =
BackEvent(
/* touchX = */ 0f,
/* touchY = */ 0f,
/* progress = */ backInput.progressX,
/* swipeEdge = */ backInput.edge,
),
progressY = backInput.progressY,
result = actual
)
val tolerance = 0f
assertThat(actual.translateX).isWithin(tolerance).of(expected.translateX)
assertThat(actual.translateY).isWithin(tolerance).of(expected.translateY)
assertThat(actual.scale).isWithin(tolerance).of(expected.scale)
}

View File

@@ -0,0 +1,80 @@
package com.android.systemui.animation.back
import android.view.View
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.util.mockito.mock
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoMoreInteractions
@SmallTest
@RunWith(JUnit4::class)
class BackTransformationTest : SysuiTestCase() {
private val targetView: View = mock()
@Test
fun defaultValue_noTransformation() {
val transformation = BackTransformation()
assertThat(transformation.translateX).isNaN()
assertThat(transformation.translateY).isNaN()
assertThat(transformation.scale).isNaN()
}
@Test
fun applyTo_targetView_translateX_Y_Scale() {
val transformation = BackTransformation(translateX = 0f, translateY = 0f, scale = 1f)
transformation.applyTo(targetView = targetView)
verify(targetView).translationX = 0f
verify(targetView).translationY = 0f
verify(targetView).scaleX = 1f
verify(targetView).scaleY = 1f
verifyNoMoreInteractions(targetView)
}
@Test
fun applyTo_targetView_translateX() {
val transformation = BackTransformation(translateX = 1f)
transformation.applyTo(targetView = targetView)
verify(targetView).translationX = 1f
verifyNoMoreInteractions(targetView)
}
@Test
fun applyTo_targetView_translateY() {
val transformation = BackTransformation(translateY = 2f)
transformation.applyTo(targetView = targetView)
verify(targetView).translationY = 2f
verifyNoMoreInteractions(targetView)
}
@Test
fun applyTo_targetView_scale() {
val transformation = BackTransformation(scale = 3f)
transformation.applyTo(targetView = targetView)
verify(targetView).scaleX = 3f
verify(targetView).scaleY = 3f
verifyNoMoreInteractions(targetView)
}
@Test
fun applyTo_targetView_noTransformation() {
val transformation = BackTransformation()
transformation.applyTo(targetView = targetView)
verifyNoMoreInteractions(targetView)
}
}

View File

@@ -0,0 +1,63 @@
package com.android.systemui.animation.back
import android.util.DisplayMetrics
import android.window.BackEvent
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.util.mockito.mock
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import org.mockito.Mockito.verify
@SmallTest
@RunWith(JUnit4::class)
class OnBackAnimationCallbackExtensionTest : SysuiTestCase() {
private val onBackProgress: (BackTransformation) -> Unit = mock()
private val onBackStart: (BackEvent) -> Unit = mock()
private val onBackInvoke: () -> Unit = mock()
private val onBackCancel: () -> Unit = mock()
private val displayMetrics =
DisplayMetrics().apply {
widthPixels = 100
heightPixels = 100
density = 1f
}
private val onBackAnimationCallback =
onBackAnimationCallbackFrom(
backAnimationSpec = BackAnimationSpec.floatingSystemSurfacesForSysUi(displayMetrics),
displayMetrics = displayMetrics,
onBackProgressed = onBackProgress,
onBackStarted = onBackStart,
onBackInvoked = onBackInvoke,
onBackCancelled = onBackCancel,
)
@Test
fun onBackProgressed_shouldInvoke_onBackProgress() {
val backEvent = BackEvent(0f, 0f, 0f, BackEvent.EDGE_LEFT)
onBackAnimationCallback.onBackStarted(backEvent)
onBackAnimationCallback.onBackProgressed(backEvent)
verify(onBackProgress).invoke(BackTransformation(0f, 0f, 1f))
}
@Test
fun onBackStarted_shouldInvoke_onBackStart() {
val backEvent = BackEvent(0f, 0f, 0f, BackEvent.EDGE_LEFT)
onBackAnimationCallback.onBackStarted(backEvent)
verify(onBackStart).invoke(backEvent)
}
@Test
fun onBackInvoked_shouldInvoke_onBackInvoke() {
onBackAnimationCallback.onBackInvoked()
verify(onBackInvoke).invoke()
}
}

View File

@@ -23,14 +23,20 @@ fun fakeDialogLaunchAnimator(
isUnlocked: Boolean = true,
isShowingAlternateAuthOnUnlock: Boolean = false,
interactionJankMonitor: InteractionJankMonitor = mock(InteractionJankMonitor::class.java),
isPredictiveBackQsDialogAnim: Boolean = false,
): DialogLaunchAnimator {
return DialogLaunchAnimator(
FakeCallback(
isUnlocked = isUnlocked,
isShowingAlternateAuthOnUnlock = isShowingAlternateAuthOnUnlock,
),
interactionJankMonitor,
fakeLaunchAnimator(),
callback =
FakeCallback(
isUnlocked = isUnlocked,
isShowingAlternateAuthOnUnlock = isShowingAlternateAuthOnUnlock,
),
interactionJankMonitor = interactionJankMonitor,
featureFlags =
object : AnimationFeatureFlags {
override val isPredictiveBackQsDialogAnim = isPredictiveBackQsDialogAnim
},
launchAnimator = fakeLaunchAnimator(),
isForTesting = true,
)
}