Introduce DialogLaunchAnimator (1/2)

This CL introduces the DialogLaunchAnimator that allows to animate the
entry/exit of any Dialog from/to any View.

Note that this CL does not use the animator for any dialog yet, that
will be added in a follow-up CL.

Test: atest DialogLaunchAnimatorTest
Bug: 193634619
Change-Id: I87f71f8f127eb6c8f2ff7dc6a434b207d9ad4d11
This commit is contained in:
Jordan Demeulenaere
2021-08-25 12:52:29 +02:00
parent c0a85b7c3b
commit e5b54057c1
12 changed files with 899 additions and 6 deletions

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2021 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.
-->
<!-- The enter animation of the host dialog is a translation of 0px that lasts 500ms so that the -->
<!-- host dialog is directly visible but the dim background still takes 500ms to fade in. -->
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:fromXDelta="0"
android:toXDelta="0"
android:duration="500" />

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2021 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.
-->
<alpha
xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:interpolator/decelerate_cubic"
android:duration="150"
android:fromAlpha="1.0"
android:toAlpha="0.0" />

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2021 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.
-->
<resources>
<item type="id" name="launch_animation_running"/>
</resources>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2021 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.
-->
<resources>
<style name="Animation.LaunchHostDialog" parent="@android:style/Animation">
<item name="android:windowEnterAnimation">@anim/launch_host_dialog_enter</item>
<item name="android:windowExitAnimation">@anim/launch_host_dialog_exit</item>
</style>
</resources>

View File

@@ -0,0 +1,523 @@
/*
* Copyright (C) 2021 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
import android.app.Dialog
import android.content.Context
import android.graphics.Color
import android.os.Looper
import android.util.Log
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.WindowManager
import android.widget.FrameLayout
private const val TAG = "DialogLaunchAnimator"
/**
* A class that allows dialogs to be started in a seamless way from a view that is transforming
* nicely into the starting dialog.
*
* Important: Don't forget to call [DialogLaunchAnimator.onDozeAmountChanged] when the doze amount
* changes to gracefully handle dialogs fading out when the device is dozing.
*/
class DialogLaunchAnimator(
private val context: Context,
private val launchAnimator: LaunchAnimator,
private val hostDialogProvider: HostDialogProvider
) {
private companion object {
private val TAG_LAUNCH_ANIMATION_RUNNING = R.id.launch_animation_running
}
// TODO(b/201264644): Remove this set.
private val currentAnimations = hashSetOf<DialogLaunchAnimation>()
/**
* Show [dialog] by expanding it from [view].
*
* Caveats: When calling this function, the dialog content view will actually be stolen and
* attached to a different dialog (and thus a different window) which means that the actual
* dialog window will never be drawn. Moreover, unless [dialog] is a [ListenableDialog], you
* must call dismiss(), hide() and show() on the [Dialog] returned by this function to actually
* dismiss, hide or show the dialog.
*/
fun showFromView(dialog: Dialog, view: View): Dialog {
if (Looper.myLooper() != Looper.getMainLooper()) {
throw IllegalStateException(
"showFromView must be called from the main thread and dialog must be created in " +
"the main thread")
}
// Make sure we don't run the launch animation from the same view twice at the same time.
if (view.getTag(TAG_LAUNCH_ANIMATION_RUNNING) != null) {
Log.e(TAG, "Not running dialog launch animation as there is already one running")
dialog.show()
return dialog
}
view.setTag(TAG_LAUNCH_ANIMATION_RUNNING, true)
val launchAnimation = DialogLaunchAnimation(
context, launchAnimator, hostDialogProvider, view,
onDialogDismissed = { currentAnimations.remove(it) }, originalDialog = dialog)
val hostDialog = launchAnimation.hostDialog
currentAnimations.add(launchAnimation)
// If the dialog is dismissed/hidden/shown, then we should actually dismiss/hide/show the
// host dialog.
if (dialog is ListenableDialog) {
dialog.addListener(object : DialogListener {
override fun onDismiss() {
dialog.removeListener(this)
hostDialog.dismiss()
}
override fun onHide() {
if (launchAnimation.ignoreNextCallToHide) {
launchAnimation.ignoreNextCallToHide = false
return
}
hostDialog.hide()
}
override fun onShow() {
hostDialog.show()
// We don't actually want to show the original dialog, so hide it.
launchAnimation.ignoreNextCallToHide = true
dialog.hide()
}
})
}
launchAnimation.start()
return hostDialog
}
/** Notify the current doze amount, to ensure that dialogs fade out when dozing. */
// TODO(b/193634619): Replace this by some mandatory constructor parameter to make sure that we
// don't forget to call this when the doze amount changes.
fun onDozeAmountChanged(amount: Float) {
currentAnimations.forEach { it.onDozeAmountChanged(amount) }
}
}
interface HostDialogProvider {
/**
* Create a host dialog that will be used to host a launch animation. This host dialog must:
* 1. call [onCreateCallback] in its onCreate() method, e.g. right after calling
* super.onCreate().
* 2. call [dismissOverride] instead of doing any dismissing logic. The actual dismissing
* logic should instead be done inside the lambda passed to [dismissOverride], which will
* be called after the exit animation.
*
* See SystemUIHostDialogProvider for an example of implementation.
*/
fun createHostDialog(
context: Context,
onCreateCallback: () -> Unit,
dismissOverride: (() -> Unit) -> Unit
): Dialog
}
/** A dialog to/from which we can add/remove listeners. */
interface ListenableDialog {
/** Add [listener] to the listeners. */
fun addListener(listener: DialogListener)
/** Remove [listener] from the listeners. */
fun removeListener(listener: DialogListener)
}
interface DialogListener {
/** Called when this dialog dismiss() is called. */
fun onDismiss()
/** Called when this dialog hide() is called. */
fun onHide()
/** Called when this dialog show() is called. */
fun onShow()
}
private class DialogLaunchAnimation(
private val context: Context,
private val launchAnimator: LaunchAnimator,
hostDialogProvider: HostDialogProvider,
/** The view that triggered the dialog after being tapped. */
private val touchSurface: View,
/**
* A callback that will be called with this [DialogLaunchAnimation] after the dialog was
* dismissed and the exit animation is done.
*/
private val onDialogDismissed: (DialogLaunchAnimation) -> Unit,
/** The original dialog whose content will be shown and animate in/out in [hostDialog]. */
private val originalDialog: Dialog
) {
/**
* The fullscreen dialog to which we will add the content view [originalDialogView] of
* [originalDialog].
*/
val hostDialog = hostDialogProvider.createHostDialog(
context, this::onHostDialogCreated, this::onHostDialogDismissed)
/** The root content view of [hostDialog]. */
private val hostDialogRoot = FrameLayout(context)
/**
* The content view of [originalDialog], which will be stolen from that dialog and added to
* [hostDialogRoot].
*/
private var originalDialogView: View? = null
/**
* The background color of [originalDialogView], taking into consideration the [originalDialog]
* window background color.
*/
private var originalDialogBackgroundColor = Color.BLACK
/**
* Whether we are currently launching/showing the dialog by animating it from [touchSurface].
*/
private var isLaunching = true
/** Whether we are currently dismissing/hiding the dialog by animating into [touchSurface]. */
private var isDismissing = false
private var dismissRequested = false
private var drawHostDialog = false
var ignoreNextCallToHide = false
fun start() {
// Show the host (fullscreen) dialog, to which we will add the stolen dialog view.
hostDialog.show()
// Steal the dialog view. We do that by showing it but preventing it from drawing, then
// hiding it as soon as its content is available.
stealOriginalDialogContentView(then = this::showDialogFromView)
}
private fun onHostDialogCreated() {
// Make the dialog fullscreen with a transparent background.
hostDialog.setContentView(
hostDialogRoot,
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
)
val window = hostDialog.window
?: throw IllegalStateException("There is no window associated to the host dialog")
window.setBackgroundDrawableResource(android.R.color.transparent)
window.setLayout(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT
)
// The host dialog animation is a translation of 0px so that it is shown directly. The
// translation lasts X ms, so that the scrim fades in during that amount of time.
window.attributes.windowAnimations = R.style.Animation_LaunchHostDialog
// Prevent the host dialog from drawing until the animation starts.
hostDialogRoot.viewTreeObserver.addOnPreDrawListener(
object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
if (drawHostDialog) {
hostDialogRoot.viewTreeObserver.removeOnPreDrawListener(this)
return true
}
return false
}
}
)
}
/** Get the content view of [originalDialog] and pass it to [then]. */
private fun stealOriginalDialogContentView(then: (View) -> Unit) {
// The original dialog content view will be attached to android.R.id.content when the dialog
// is shown, so we show the dialog and add an observer to get the view but also prevents the
// original dialog from being drawn.
val androidContent = originalDialog.findViewById<ViewGroup>(android.R.id.content)
?: throw IllegalStateException("Dialog does not have any android.R.id.content view")
androidContent.viewTreeObserver.addOnPreDrawListener(
object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
if (androidContent.childCount == 1) {
androidContent.viewTreeObserver.removeOnPreDrawListener(this)
// Hide the animated dialog. Because of the dialog listener set up
// earlier, this would also hide the host dialog, but in this case we
// need to keep the host dialog visible.
ignoreNextCallToHide = true
originalDialog.hide()
then(androidContent.getChildAt(0))
return false
}
// Never draw the original dialog content.
return false
}
})
originalDialog.show()
}
private fun showDialogFromView(dialogView: View) {
// Save the dialog view for later as we will need it for the close animation.
this.originalDialogView = dialogView
// Close the dialog when clicking outside of it.
hostDialogRoot.setOnClickListener { hostDialog.dismiss() }
dialogView.isClickable = true
// Set the background of the window dialog to the dialog itself.
// TODO(b/193634619): Support dialog windows without background.
// TODO(b/193634619): Support dialog whose background comes from the content view instead of
// the window.
val typedArray =
originalDialog.context.obtainStyledAttributes(com.android.internal.R.styleable.Window)
val backgroundRes =
typedArray.getResourceId(com.android.internal.R.styleable.Window_windowBackground, 0)
typedArray.recycle()
if (backgroundRes == 0) {
throw IllegalStateException("Dialogs with no backgrounds on window are not supported")
}
dialogView.setBackgroundResource(backgroundRes)
originalDialogBackgroundColor =
GhostedViewLaunchAnimatorController.findGradientDrawable(dialogView.background!!)
?.color
?.defaultColor ?: Color.BLACK
// Add the dialog view to the host (fullscreen) dialog and make it invisible to make sure
// it's not drawn yet.
(dialogView.parent as? ViewGroup)?.removeView(dialogView)
hostDialogRoot.addView(
dialogView,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
Gravity.CENTER
)
)
dialogView.visibility = View.INVISIBLE
// Start the animation when the dialog is laid out in the center of the host dialog.
dialogView.addOnLayoutChangeListener(object : View.OnLayoutChangeListener {
override fun onLayoutChange(
view: View,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int
) {
dialogView.removeOnLayoutChangeListener(this)
startAnimation(
isLaunching = true,
onLaunchAnimationStart = { drawHostDialog = true },
onLaunchAnimationEnd = {
touchSurface.setTag(R.id.launch_animation_running, null)
// We hide the touch surface when the dialog is showing. We will make this
// view visible again when dismissing the dialog.
// TODO(b/193634619): Provide an easy way for views to check if they should
// be hidden because of a dialog launch so that they don't override this
// visibility when updating/refreshing itself.
touchSurface.visibility = View.INVISIBLE
isLaunching = false
// dismiss was called during the animation, dismiss again now to actually
// dismiss.
if (dismissRequested) {
hostDialog.dismiss()
}
}
)
}
})
}
private fun onHostDialogDismissed(actualDismiss: () -> Unit) {
if (Looper.myLooper() != Looper.getMainLooper()) {
context.mainExecutor.execute { onHostDialogDismissed(actualDismiss) }
return
}
// TODO(b/193634619): Support interrupting the launch animation in the middle.
if (isLaunching) {
dismissRequested = true
return
}
if (isDismissing) {
return
}
isDismissing = true
hideDialogIntoView { instantDismiss: Boolean ->
if (instantDismiss) {
originalDialog.hide()
hostDialog.hide()
}
originalDialog.dismiss()
actualDismiss()
}
}
/**
* Hide the dialog into the touch surface and call [dismissDialogs] when the animation is done
* (passing instantDismiss=true) or if it's skipped (passing instantDismiss=false) to actually
* dismiss the dialogs.
*/
private fun hideDialogIntoView(dismissDialogs: (Boolean) -> Unit) {
if (!shouldAnimateDialogIntoView()) {
Log.i(TAG, "Skipping animation of dialog into the touch surface")
// If the view is invisible it's probably because of us, so we make it visible again.
if (touchSurface.visibility == View.INVISIBLE) {
touchSurface.visibility = View.VISIBLE
}
dismissDialogs(false /* instantDismiss */)
onDialogDismissed(this@DialogLaunchAnimation)
return
}
startAnimation(
isLaunching = false,
onLaunchAnimationStart = {
// Remove the dim background as soon as we start the animation.
hostDialog.window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
},
onLaunchAnimationEnd = {
touchSurface.visibility = View.VISIBLE
originalDialogView!!.visibility = View.INVISIBLE
dismissDialogs(true /* instantDismiss */)
onDialogDismissed(this@DialogLaunchAnimation)
}
)
}
private fun startAnimation(
isLaunching: Boolean,
onLaunchAnimationStart: () -> Unit = {},
onLaunchAnimationEnd: () -> Unit = {}
) {
val dialogView = this.originalDialogView!!
// Create 2 ghost controllers to animate both the dialog and the touch surface in the host
// dialog.
val startView = if (isLaunching) touchSurface else dialogView
val endView = if (isLaunching) dialogView else touchSurface
val startViewController = GhostedViewLaunchAnimatorController(startView)
val endViewController = GhostedViewLaunchAnimatorController(endView)
startViewController.launchContainer = hostDialogRoot
endViewController.launchContainer = hostDialogRoot
val endState = endViewController.createAnimatorState()
val controller = object : LaunchAnimator.Controller {
override var launchContainer: ViewGroup
get() = startViewController.launchContainer
set(value) {
startViewController.launchContainer = value
endViewController.launchContainer = value
}
override fun createAnimatorState(): LaunchAnimator.State {
return startViewController.createAnimatorState()
}
override fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) {
startViewController.onLaunchAnimationStart(isExpandingFullyAbove)
endViewController.onLaunchAnimationStart(isExpandingFullyAbove)
onLaunchAnimationStart()
}
override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) {
startViewController.onLaunchAnimationEnd(isExpandingFullyAbove)
endViewController.onLaunchAnimationEnd(isExpandingFullyAbove)
onLaunchAnimationEnd()
}
override fun onLaunchAnimationProgress(
state: LaunchAnimator.State,
progress: Float,
linearProgress: Float
) {
startViewController.onLaunchAnimationProgress(state, progress, linearProgress)
// The end view is visible only iff the starting view is not visible.
state.visible = !state.visible
endViewController.onLaunchAnimationProgress(state, progress, linearProgress)
// If the dialog content is complex, its dimension might change during the launch
// animation. The animation end position might also change during the exit
// animation, for instance when locking the phone when the dialog is open. Therefore
// we update the end state to the new position/size. Usually the dialog dimension or
// position will change in the early frames, so changing the end state shouldn't
// really be noticeable.
endViewController.fillGhostedViewState(endState)
}
}
launchAnimator.startAnimation(controller, endState, originalDialogBackgroundColor)
}
private fun shouldAnimateDialogIntoView(): Boolean {
// The touch surface should be invisible by now, if it's not then something else changed its
// visibility and we probably don't want to run the animation.
if (touchSurface.visibility != View.INVISIBLE) {
return false
}
// If the touch surface is not attached or one of its ancestors is not visible, then we
// don't run the animation either.
if (!touchSurface.isAttachedToWindow) {
return false
}
return (touchSurface.parent as? View)?.isShown ?: true
}
internal fun onDozeAmountChanged(amount: Float) {
val alpha = Interpolators.ALPHA_OUT.getInterpolation(1 - amount)
val decorView = this.hostDialog.window?.decorView ?: return
if (decorView.hasOverlappingRendering() && alpha > 0.0f &&
alpha < 1.0f && decorView.layerType != View.LAYER_TYPE_HARDWARE) {
decorView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
}
decorView.alpha = alpha
}
}

View File

@@ -23,6 +23,7 @@ import android.os.Handler;
import com.android.internal.statusbar.IStatusBarService;
import com.android.systemui.animation.ActivityLaunchAnimator;
import com.android.systemui.animation.DialogLaunchAnimator;
import com.android.systemui.animation.LaunchAnimator;
import com.android.systemui.dagger.SysUISingleton;
import com.android.systemui.dagger.qualifiers.Main;
@@ -64,6 +65,7 @@ import com.android.systemui.statusbar.phone.StatusBar;
import com.android.systemui.statusbar.phone.StatusBarIconController;
import com.android.systemui.statusbar.phone.StatusBarIconControllerImpl;
import com.android.systemui.statusbar.phone.StatusBarRemoteInputCallback;
import com.android.systemui.statusbar.phone.SystemUIHostDialogProvider;
import com.android.systemui.statusbar.phone.ongoingcall.OngoingCallController;
import com.android.systemui.statusbar.phone.ongoingcall.OngoingCallLogger;
import com.android.systemui.statusbar.policy.RemoteInputUriController;
@@ -279,4 +281,13 @@ public interface StatusBarDependenciesModule {
static ActivityLaunchAnimator provideActivityLaunchAnimator(LaunchAnimator launchAnimator) {
return new ActivityLaunchAnimator(launchAnimator);
}
/**
*/
@Provides
@SysUISingleton
static DialogLaunchAnimator provideDialogLaunchAnimator(Context context,
LaunchAnimator launchAnimator) {
return new DialogLaunchAnimator(context, launchAnimator, new SystemUIHostDialogProvider());
}
}

View File

@@ -135,6 +135,7 @@ import com.android.systemui.R;
import com.android.systemui.SystemUI;
import com.android.systemui.animation.ActivityLaunchAnimator;
import com.android.systemui.animation.DelegateLaunchAnimatorController;
import com.android.systemui.animation.DialogLaunchAnimator;
import com.android.systemui.assist.AssistManager;
import com.android.systemui.battery.BatteryMeterViewController;
import com.android.systemui.biometrics.AuthRippleController;
@@ -664,6 +665,7 @@ public class StatusBar extends SystemUI implements
private HeadsUpAppearanceController mHeadsUpAppearanceController;
private final ActivityLaunchAnimator mActivityLaunchAnimator;
private final DialogLaunchAnimator mDialogLaunchAnimator;
private NotificationLaunchAnimatorControllerProvider mNotificationAnimationProvider;
protected StatusBarNotificationPresenter mPresenter;
private NotificationActivityStarter mNotificationActivityStarter;
@@ -783,7 +785,8 @@ public class StatusBar extends SystemUI implements
Optional<StartingSurface> startingSurfaceOptional,
TunerService tunerService,
DumpManager dumpManager,
ActivityLaunchAnimator activityLaunchAnimator) {
ActivityLaunchAnimator activityLaunchAnimator,
DialogLaunchAnimator dialogLaunchAnimator) {
super(context);
mNotificationsController = notificationsController;
mLightBarController = lightBarController;
@@ -888,6 +891,7 @@ public class StatusBar extends SystemUI implements
mActivityIntentHelper = new ActivityIntentHelper(mContext);
mActivityLaunchAnimator = activityLaunchAnimator;
mDialogLaunchAnimator = dialogLaunchAnimator;
// TODO(b/190746471): Find a better home for this.
DateTimeView.setReceiverHandler(timeTickHandler);
@@ -4392,6 +4396,8 @@ public class StatusBar extends SystemUI implements
&& !(mLightRevealScrim.getRevealEffect() instanceof CircleReveal)) {
mLightRevealScrim.setRevealAmount(1f - linear);
}
mDialogLaunchAnimator.onDozeAmountChanged(linear);
}
@Override

View File

@@ -30,19 +30,24 @@ import android.view.WindowManager.LayoutParams;
import com.android.systemui.Dependency;
import com.android.systemui.R;
import com.android.systemui.animation.DialogListener;
import com.android.systemui.animation.ListenableDialog;
import com.android.systemui.broadcast.BroadcastDispatcher;
import com.android.systemui.statusbar.policy.KeyguardStateController;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* Base class for dialogs that should appear over panels and keyguard.
* The SystemUIDialog registers a listener for the screen off / close system dialogs broadcast,
* and dismisses itself when it receives the broadcast.
*/
public class SystemUIDialog extends AlertDialog {
public class SystemUIDialog extends AlertDialog implements ListenableDialog {
private final Context mContext;
private final DismissReceiver mDismissReceiver;
private final Set<DialogListener> mDialogListeners = new LinkedHashSet<>();
public SystemUIDialog(Context context) {
this(context, R.style.Theme_SystemUI_Dialog);
@@ -72,6 +77,43 @@ public class SystemUIDialog extends AlertDialog {
mDismissReceiver.unregister();
}
@Override
public void addListener(DialogListener listener) {
mDialogListeners.add(listener);
}
@Override
public void removeListener(DialogListener listener) {
mDialogListeners.remove(listener);
}
@Override
public void dismiss() {
super.dismiss();
for (DialogListener listener : new LinkedHashSet<>(mDialogListeners)) {
listener.onDismiss();
}
}
@Override
public void hide() {
super.hide();
for (DialogListener listener : new LinkedHashSet<>(mDialogListeners)) {
listener.onHide();
}
}
@Override
public void show() {
super.show();
for (DialogListener listener : new LinkedHashSet<>(mDialogListeners)) {
listener.onShow();
}
}
public void setShowForAllUsers(boolean show) {
setShowForAllUsers(this, show);
}

View File

@@ -0,0 +1,34 @@
package com.android.systemui.statusbar.phone
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import com.android.systemui.animation.HostDialogProvider
/** An implementation of [HostDialogProvider] to be used when animating SysUI dialogs. */
class SystemUIHostDialogProvider : HostDialogProvider {
override fun createHostDialog(
context: Context,
onCreateCallback: () -> Unit,
dismissOverride: (() -> Unit) -> Unit
): Dialog {
return SystemUIHostDialog(context, onCreateCallback, dismissOverride)
}
private class SystemUIHostDialog(
context: Context,
private val onCreateCallback: () -> Unit,
private val dismissOverride: (() -> Unit) -> Unit
) : SystemUIDialog(context) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
onCreateCallback()
}
override fun dismiss() {
dismissOverride {
super.dismiss()
}
}
}
}

View File

@@ -29,6 +29,7 @@ import com.android.keyguard.KeyguardUpdateMonitor;
import com.android.keyguard.ViewMediatorCallback;
import com.android.systemui.InitController;
import com.android.systemui.animation.ActivityLaunchAnimator;
import com.android.systemui.animation.DialogLaunchAnimator;
import com.android.systemui.assist.AssistManager;
import com.android.systemui.broadcast.BroadcastDispatcher;
import com.android.systemui.classifier.FalsingCollector;
@@ -229,7 +230,8 @@ public interface StatusBarPhoneModule {
Optional<StartingSurface> startingSurfaceOptional,
TunerService tunerService,
DumpManager dumpManager,
ActivityLaunchAnimator activityLaunchAnimator) {
ActivityLaunchAnimator activityLaunchAnimator,
DialogLaunchAnimator dialogLaunchAnimator) {
return new StatusBar(
context,
notificationsController,
@@ -324,6 +326,7 @@ public interface StatusBarPhoneModule {
startingSurfaceOptional,
tunerService,
dumpManager,
activityLaunchAnimator);
activityLaunchAnimator,
dialogLaunchAnimator);
}
}

View File

@@ -0,0 +1,185 @@
package com.android.systemui.animation
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.testing.AndroidTestingRunner
import android.testing.TestableLooper
import android.testing.ViewUtils
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.LinearLayout
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import junit.framework.Assert.assertEquals
import junit.framework.Assert.assertFalse
import junit.framework.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@SmallTest
@RunWith(AndroidTestingRunner::class)
@TestableLooper.RunWithLooper
class DialogLaunchAnimatorTest : SysuiTestCase() {
private val launchAnimator = LaunchAnimator(context, isForTesting = true)
private val hostDialogprovider = TestHostDialogProvider()
private val dialogLaunchAnimator =
DialogLaunchAnimator(context, launchAnimator, hostDialogprovider)
@Test
fun testShowDialogFromView() {
// Show the dialog. showFromView() must be called on the main thread with a dialog created
// on the main thread too.
val (dialog, hostDialog) = runOnMainThreadAndWaitForIdleSync {
val touchSurfaceRoot = LinearLayout(context)
val touchSurface = View(context)
touchSurfaceRoot.addView(touchSurface)
// We need to attach the root to the window manager otherwise the exit animation will
// be skipped
ViewUtils.attachView(touchSurfaceRoot)
val dialog = TestDialog(context)
val hostDialog =
dialogLaunchAnimator.showFromView(dialog, touchSurface) as TestHostDialog
dialog to hostDialog
}
// Only the host dialog is actually showing.
assertTrue(hostDialog.isShowing)
assertFalse(dialog.isShowing)
// The dialog onStart() method was called but not onStop().
assertTrue(dialog.onStartCalled)
assertFalse(dialog.onStopCalled)
// The dialog content has been stolen and is shown inside the host dialog.
val hostDialogContent = hostDialog.findViewById<ViewGroup>(android.R.id.content)
assertEquals(0, dialog.findViewById<ViewGroup>(android.R.id.content).childCount)
assertEquals(1, hostDialogContent.childCount)
val hostDialogRoot = hostDialogContent.getChildAt(0) as ViewGroup
assertEquals(1, hostDialogRoot.childCount)
assertEquals(dialog.contentView, hostDialogRoot.getChildAt(0))
// If we are dozing, the host dialog window also fades out.
runOnMainThreadAndWaitForIdleSync { dialogLaunchAnimator.onDozeAmountChanged(0.5f) }
assertTrue(hostDialog.window!!.decorView.alpha < 1f)
// Hiding/showing/dismissing the dialog should hide/show/dismiss the host dialog given that
// it's a ListenableDialog.
runOnMainThreadAndWaitForIdleSync { dialog.hide() }
assertFalse(hostDialog.isShowing)
assertFalse(dialog.isShowing)
runOnMainThreadAndWaitForIdleSync { dialog.show() }
assertTrue(hostDialog.isShowing)
assertFalse(dialog.isShowing)
assertFalse(dialog.onStopCalled)
runOnMainThreadAndWaitForIdleSync { dialog.dismiss() }
assertFalse(hostDialog.isShowing)
assertFalse(dialog.isShowing)
assertTrue(hostDialog.wasDismissed)
assertTrue(dialog.onStopCalled)
}
private fun <T : Any> runOnMainThreadAndWaitForIdleSync(f: () -> T): T {
lateinit var result: T
context.mainExecutor.execute {
result = f()
}
waitForIdleSync()
return result
}
private class TestHostDialogProvider : HostDialogProvider {
override fun createHostDialog(
context: Context,
onCreateCallback: () -> Unit,
dismissOverride: (() -> Unit) -> Unit
): Dialog = TestHostDialog(context, onCreateCallback, dismissOverride)
}
private class TestHostDialog(
context: Context,
private val onCreateCallback: () -> Unit,
private val dismissOverride: (() -> Unit) -> Unit
) : Dialog(context) {
var wasDismissed = false
init {
// We need to set the window type for dialogs shown by SysUI, otherwise WM will throw.
window.setType(WindowManager.LayoutParams.TYPE_STATUS_BAR_SUB_PANEL)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
onCreateCallback()
}
override fun dismiss() {
dismissOverride {
super.dismiss()
wasDismissed = true
}
}
}
private class TestDialog(context: Context) : Dialog(context), ListenableDialog {
private val listeners = hashSetOf<DialogListener>()
val contentView = View(context)
var onStartCalled = false
var onStopCalled = false
init {
// We need to set the window type for dialogs shown by SysUI, otherwise WM will throw.
window.setType(WindowManager.LayoutParams.TYPE_STATUS_BAR_SUB_PANEL)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(contentView)
}
override fun onStart() {
super.onStart()
onStartCalled = true
}
override fun onStop() {
super.onStart()
onStopCalled = true
}
override fun addListener(listener: DialogListener) {
listeners.add(listener)
}
override fun removeListener(listener: DialogListener) {
listeners.remove(listener)
}
override fun dismiss() {
super.dismiss()
notifyListeners { onDismiss() }
}
override fun hide() {
super.hide()
notifyListeners { onHide() }
}
override fun show() {
super.show()
notifyListeners { onShow() }
}
private fun notifyListeners(notify: DialogListener.() -> Unit) {
for (listener in HashSet(listeners)) {
listener.notify()
}
}
}
}

View File

@@ -78,6 +78,7 @@ import com.android.systemui.InitController;
import com.android.systemui.R;
import com.android.systemui.SysuiTestCase;
import com.android.systemui.animation.ActivityLaunchAnimator;
import com.android.systemui.animation.DialogLaunchAnimator;
import com.android.systemui.assist.AssistManager;
import com.android.systemui.broadcast.BroadcastDispatcher;
import com.android.systemui.classifier.FalsingCollectorFake;
@@ -270,6 +271,7 @@ public class StatusBarTest extends SysuiTestCase {
@Mock private OperatorNameViewController mOperatorNameViewController;
@Mock private OperatorNameViewController.Factory mOperatorNameViewControllerFactory;
@Mock private ActivityLaunchAnimator mActivityLaunchAnimator;
@Mock private DialogLaunchAnimator mDialogLaunchAnimator;
private ShadeController mShadeController;
private final FakeSystemClock mFakeSystemClock = new FakeSystemClock();
private FakeExecutor mMainExecutor = new FakeExecutor(mFakeSystemClock);
@@ -445,7 +447,8 @@ public class StatusBarTest extends SysuiTestCase {
Optional.of(mStartingSurface),
mTunerService,
mock(DumpManager.class),
mActivityLaunchAnimator);
mActivityLaunchAnimator,
mDialogLaunchAnimator);
when(mKeyguardViewMediator.registerStatusBar(any(StatusBar.class), any(ViewGroup.class),
any(NotificationPanelViewController.class), any(BiometricUnlockController.class),
any(ViewGroup.class), any(KeyguardBypassController.class)))