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:
@@ -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" />
|
||||
@@ -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" />
|
||||
19
packages/SystemUI/animation/res/values/ids.xml
Normal file
19
packages/SystemUI/animation/res/values/ids.xml
Normal 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>
|
||||
22
packages/SystemUI/animation/res/values/styles.xml
Normal file
22
packages/SystemUI/animation/res/values/styles.xml
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user