Merge changes from topics "dialog-launch", "launch-animator" into sc-v2-dev
* changes: Animate media dialogs Introduce DialogLaunchAnimator (1/2) Split ActivityLaunchAnimator (1/2)
This commit is contained in:
committed by
Android (Google) Code Review
commit
3bf72bd3f9
@@ -165,6 +165,10 @@ public final class MathUtils {
|
||||
return start + (stop - start) * amount;
|
||||
}
|
||||
|
||||
public static float lerp(int start, int stop, float amount) {
|
||||
return lerp((float) start, (float) stop, amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the interpolation scalar (s) that satisfies the equation: {@code value = }{@link
|
||||
* #lerp}{@code (a, b, s)}
|
||||
|
||||
@@ -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>
|
||||
29
packages/SystemUI/animation/res/values/styles.xml
Normal file
29
packages/SystemUI/animation/res/values/styles.xml
Normal file
@@ -0,0 +1,29 @@
|
||||
<?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="HostDialogTheme">
|
||||
<item name="android:windowAnimationStyle">@style/Animation.HostDialog</item>
|
||||
<item name="android:windowIsFloating">false</item>
|
||||
<item name="android:backgroundDimEnabled">true</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
|
||||
<style name="Animation.HostDialog" 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>
|
||||
@@ -1,23 +1,31 @@
|
||||
/*
|
||||
* 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.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.ValueAnimator
|
||||
import android.app.ActivityManager
|
||||
import android.app.ActivityTaskManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.TaskInfo
|
||||
import android.content.Context
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffXfermode
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Looper
|
||||
import android.os.RemoteException
|
||||
import android.util.Log
|
||||
import android.util.MathUtils
|
||||
import android.view.IRemoteAnimationFinishedCallback
|
||||
import android.view.IRemoteAnimationRunner
|
||||
import android.view.RemoteAnimationAdapter
|
||||
@@ -26,7 +34,6 @@ import android.view.SyncRtSurfaceTransactionApplier
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.view.animation.AnimationUtils
|
||||
import android.view.animation.PathInterpolator
|
||||
import com.android.internal.annotations.VisibleForTesting
|
||||
import com.android.internal.policy.ScreenDecorationsUtils
|
||||
@@ -38,52 +45,23 @@ private const val TAG = "ActivityLaunchAnimator"
|
||||
* A class that allows activities to be started in a seamless way from a view that is transforming
|
||||
* nicely into the starting window.
|
||||
*/
|
||||
class ActivityLaunchAnimator(
|
||||
private val callback: Callback,
|
||||
context: Context
|
||||
) {
|
||||
class ActivityLaunchAnimator(private val launchAnimator: LaunchAnimator) {
|
||||
companion object {
|
||||
private const val DEBUG = false
|
||||
const val ANIMATION_DURATION = 500L
|
||||
private const val ANIMATION_DURATION_FADE_OUT_CONTENT = 150L
|
||||
private const val ANIMATION_DURATION_FADE_IN_WINDOW = 183L
|
||||
private const val ANIMATION_DELAY_FADE_IN_WINDOW = ANIMATION_DURATION_FADE_OUT_CONTENT
|
||||
private const val ANIMATION_DURATION_NAV_FADE_IN = 266L
|
||||
private const val ANIMATION_DURATION_NAV_FADE_OUT = 133L
|
||||
private const val ANIMATION_DELAY_NAV_FADE_IN =
|
||||
ANIMATION_DURATION - ANIMATION_DURATION_NAV_FADE_IN
|
||||
LaunchAnimator.ANIMATION_DURATION - ANIMATION_DURATION_NAV_FADE_IN
|
||||
private const val LAUNCH_TIMEOUT = 1000L
|
||||
|
||||
@JvmField val CONTENT_FADE_OUT_INTERPOLATOR = PathInterpolator(0f, 0f, 0.2f, 1f)
|
||||
private val WINDOW_FADE_IN_INTERPOLATOR = PathInterpolator(0f, 0f, 0.6f, 1f)
|
||||
private val NAV_FADE_IN_INTERPOLATOR = PathInterpolator(0f, 0f, 0f, 1f)
|
||||
private val NAV_FADE_OUT_INTERPOLATOR = PathInterpolator(0.2f, 0f, 1f, 1f)
|
||||
|
||||
private val SRC_MODE = PorterDuffXfermode(PorterDuff.Mode.SRC)
|
||||
|
||||
/**
|
||||
* Given the [linearProgress] of a launch animation, return the linear progress of the
|
||||
* sub-animation starting [delay] ms after the launch animation and that lasts [duration].
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getProgress(linearProgress: Float, delay: Long, duration: Long): Float {
|
||||
return MathUtils.constrain(
|
||||
(linearProgress * ANIMATION_DURATION - delay) / duration,
|
||||
0.0f,
|
||||
1.0f
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The interpolator used for the width, height, Y position and corner radius. */
|
||||
private val animationInterpolator = AnimationUtils.loadInterpolator(context,
|
||||
R.interpolator.launch_animation_interpolator_y)
|
||||
|
||||
/** The interpolator used for the X position. */
|
||||
private val animationInterpolatorX = AnimationUtils.loadInterpolator(context,
|
||||
R.interpolator.launch_animation_interpolator_x)
|
||||
|
||||
private val cornerRadii = FloatArray(8)
|
||||
/**
|
||||
* The callback of this animator. This should be set before any call to
|
||||
* [start(Pending)IntentWithAnimation].
|
||||
*/
|
||||
var callback: Callback? = null
|
||||
|
||||
/**
|
||||
* Start an intent and animate the opening window. The intent will be started by running
|
||||
@@ -119,6 +97,8 @@ class ActivityLaunchAnimator(
|
||||
return
|
||||
}
|
||||
|
||||
val callback = this.callback ?: throw IllegalStateException(
|
||||
"ActivityLaunchAnimator.callback must be set before using this animator")
|
||||
val runner = Runner(controller)
|
||||
val hideKeyguardWithAnimation = callback.isOnKeyguard() && !showOverLockscreen
|
||||
|
||||
@@ -126,9 +106,9 @@ class ActivityLaunchAnimator(
|
||||
// keyguard with the animation
|
||||
val animationAdapter = if (!hideKeyguardWithAnimation) {
|
||||
RemoteAnimationAdapter(
|
||||
runner,
|
||||
ANIMATION_DURATION,
|
||||
ANIMATION_DURATION - 150 /* statusBarTransitionDelay */
|
||||
runner,
|
||||
LaunchAnimator.ANIMATION_DURATION,
|
||||
LaunchAnimator.ANIMATION_DURATION - 150 /* statusBarTransitionDelay */
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -150,10 +130,10 @@ class ActivityLaunchAnimator(
|
||||
// Only animate if the app is not already on top and will be opened, unless we are on the
|
||||
// keyguard.
|
||||
val willAnimate =
|
||||
launchResult == ActivityManager.START_TASK_TO_FRONT ||
|
||||
launchResult == ActivityManager.START_SUCCESS ||
|
||||
(launchResult == ActivityManager.START_DELIVERED_TO_TOP &&
|
||||
hideKeyguardWithAnimation)
|
||||
launchResult == ActivityManager.START_TASK_TO_FRONT ||
|
||||
launchResult == ActivityManager.START_SUCCESS ||
|
||||
(launchResult == ActivityManager.START_DELIVERED_TO_TOP &&
|
||||
hideKeyguardWithAnimation)
|
||||
|
||||
Log.i(TAG, "launchResult=$launchResult willAnimate=$willAnimate " +
|
||||
"hideKeyguardWithAnimation=$hideKeyguardWithAnimation")
|
||||
@@ -234,7 +214,7 @@ class ActivityLaunchAnimator(
|
||||
*
|
||||
* Note that all callbacks (onXXX methods) are all called on the main thread.
|
||||
*/
|
||||
interface Controller {
|
||||
interface Controller : LaunchAnimator.Controller {
|
||||
companion object {
|
||||
/**
|
||||
* Return a [Controller] that will animate and expand [view] into the opening window.
|
||||
@@ -258,53 +238,12 @@ class ActivityLaunchAnimator(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The container in which the view that started the intent will be animating together with
|
||||
* the opening window.
|
||||
*
|
||||
* This will be used to:
|
||||
* - Get the associated [Context].
|
||||
* - Compute whether we are expanding fully above the current window.
|
||||
* - Apply surface transactions in sync with RenderThread.
|
||||
*
|
||||
* This container can be changed to force this [Controller] to animate the expanding view
|
||||
* inside a different location, for instance to ensure correct layering during the
|
||||
* animation.
|
||||
*/
|
||||
var launchContainer: ViewGroup
|
||||
|
||||
/**
|
||||
* Return the [State] of the view that will be animated. We will animate from this state to
|
||||
* the final window state.
|
||||
*
|
||||
* Note: This state will be mutated and passed to [onLaunchAnimationProgress] during the
|
||||
* animation.
|
||||
*/
|
||||
fun createAnimatorState(): State
|
||||
|
||||
/**
|
||||
* The intent was started. If [willAnimate] is false, nothing else will happen and the
|
||||
* animation will not be started.
|
||||
*/
|
||||
fun onIntentStarted(willAnimate: Boolean) {}
|
||||
|
||||
/**
|
||||
* The animation started. This is typically used to initialize any additional resource
|
||||
* needed for the animation. [isExpandingFullyAbove] will be true if the window is expanding
|
||||
* fully above the [root view][getRootView].
|
||||
*/
|
||||
fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) {}
|
||||
|
||||
/** The animation made progress and the expandable view [state] should be updated. */
|
||||
fun onLaunchAnimationProgress(state: State, progress: Float, linearProgress: Float) {}
|
||||
|
||||
/**
|
||||
* The animation ended. This will be called *if and only if* [onLaunchAnimationStart] was
|
||||
* called previously. This is typically used to clean up the resources initialized when the
|
||||
* animation was started.
|
||||
*/
|
||||
fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) {}
|
||||
|
||||
/**
|
||||
* The animation was cancelled. Note that [onLaunchAnimationEnd] will still be called after
|
||||
* this if the animation was already started, i.e. if [onLaunchAnimationStart] was called
|
||||
@@ -313,66 +252,11 @@ class ActivityLaunchAnimator(
|
||||
fun onLaunchAnimationCancelled() {}
|
||||
}
|
||||
|
||||
/** The state of an expandable view during an [ActivityLaunchAnimator] animation. */
|
||||
open class State(
|
||||
/** The position of the view in screen space coordinates. */
|
||||
var top: Int,
|
||||
var bottom: Int,
|
||||
var left: Int,
|
||||
var right: Int,
|
||||
|
||||
var topCornerRadius: Float = 0f,
|
||||
var bottomCornerRadius: Float = 0f
|
||||
) {
|
||||
private val startTop = top
|
||||
private val startBottom = bottom
|
||||
private val startLeft = left
|
||||
private val startRight = right
|
||||
private val startWidth = width
|
||||
private val startHeight = height
|
||||
val startCenterX = centerX
|
||||
val startCenterY = centerY
|
||||
|
||||
val width: Int
|
||||
get() = right - left
|
||||
|
||||
val height: Int
|
||||
get() = bottom - top
|
||||
|
||||
open val topChange: Int
|
||||
get() = top - startTop
|
||||
|
||||
open val bottomChange: Int
|
||||
get() = bottom - startBottom
|
||||
|
||||
val leftChange: Int
|
||||
get() = left - startLeft
|
||||
|
||||
val rightChange: Int
|
||||
get() = right - startRight
|
||||
|
||||
val widthRatio: Float
|
||||
get() = width.toFloat() / startWidth
|
||||
|
||||
val heightRatio: Float
|
||||
get() = height.toFloat() / startHeight
|
||||
|
||||
val centerX: Float
|
||||
get() = left + width / 2f
|
||||
|
||||
val centerY: Float
|
||||
get() = top + height / 2f
|
||||
|
||||
/** Whether the expanded view should be visible or hidden. */
|
||||
var visible: Boolean = true
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
inner class Runner(private val controller: Controller) : IRemoteAnimationRunner.Stub() {
|
||||
private val launchContainer = controller.launchContainer
|
||||
private val context = launchContainer.context
|
||||
private val transactionApplier = SyncRtSurfaceTransactionApplier(launchContainer)
|
||||
private var animator: ValueAnimator? = null
|
||||
|
||||
private val matrix = Matrix()
|
||||
private val invertMatrix = Matrix()
|
||||
@@ -380,6 +264,7 @@ class ActivityLaunchAnimator(
|
||||
private var windowCropF = RectF()
|
||||
private var timedOut = false
|
||||
private var cancelled = false
|
||||
private var animation: LaunchAnimator.Animation? = null
|
||||
|
||||
// A timeout to cancel the remote animation if it is not started within X milliseconds after
|
||||
// the intent was started.
|
||||
@@ -429,7 +314,7 @@ class ActivityLaunchAnimator(
|
||||
nonApps: Array<out RemoteAnimationTarget>?,
|
||||
iCallback: IRemoteAnimationFinishedCallback?
|
||||
) {
|
||||
if (DEBUG) {
|
||||
if (LaunchAnimator.DEBUG) {
|
||||
Log.d(TAG, "Remote animation started")
|
||||
}
|
||||
|
||||
@@ -449,36 +334,20 @@ class ActivityLaunchAnimator(
|
||||
it.windowType == WindowManager.LayoutParams.TYPE_NAVIGATION_BAR
|
||||
}
|
||||
|
||||
// Start state.
|
||||
val state = controller.createAnimatorState()
|
||||
|
||||
val startTop = state.top
|
||||
val startBottom = state.bottom
|
||||
val startLeft = state.left
|
||||
val startRight = state.right
|
||||
val startXCenter = (startLeft + startRight) / 2f
|
||||
val startWidth = startRight - startLeft
|
||||
|
||||
val startTopCornerRadius = state.topCornerRadius
|
||||
val startBottomCornerRadius = state.bottomCornerRadius
|
||||
|
||||
// End state.
|
||||
val windowBounds = window.screenSpaceBounds
|
||||
val endTop = windowBounds.top
|
||||
val endBottom = windowBounds.bottom
|
||||
val endLeft = windowBounds.left
|
||||
val endRight = windowBounds.right
|
||||
val endXCenter = (endLeft + endRight) / 2f
|
||||
val endWidth = endRight - endLeft
|
||||
val endState = LaunchAnimator.State(
|
||||
top = windowBounds.top,
|
||||
bottom = windowBounds.bottom,
|
||||
left = windowBounds.left,
|
||||
right = windowBounds.right
|
||||
)
|
||||
val callback = this@ActivityLaunchAnimator.callback!!
|
||||
val windowBackgroundColor = callback.getBackgroundColor(window.taskInfo)
|
||||
|
||||
// TODO(b/184121838): Ensure that we are launching on the same screen.
|
||||
val rootViewLocation = launchContainer.locationOnScreen
|
||||
val isExpandingFullyAbove = endTop <= rootViewLocation[1] &&
|
||||
endBottom >= rootViewLocation[1] + launchContainer.height &&
|
||||
endLeft <= rootViewLocation[0] &&
|
||||
endRight >= rootViewLocation[0] + launchContainer.width
|
||||
|
||||
// TODO(b/184121838): We should somehow get the top and bottom radius of the window.
|
||||
// TODO(b/184121838): We should somehow get the top and bottom radius of the window
|
||||
// instead of recomputing isExpandingFullyAbove here.
|
||||
val isExpandingFullyAbove =
|
||||
launchAnimator.isExpandingFullyAbove(controller.launchContainer, endState)
|
||||
val endRadius = if (isExpandingFullyAbove) {
|
||||
// Most of the time, expanding fully above the root view means expanding in full
|
||||
// screen.
|
||||
@@ -488,97 +357,40 @@ class ActivityLaunchAnimator(
|
||||
// a radius of 0.
|
||||
0f
|
||||
}
|
||||
endState.topCornerRadius = endRadius
|
||||
endState.bottomCornerRadius = endRadius
|
||||
|
||||
// We add an extra layer with the same color as the app splash screen background color,
|
||||
// which is usually the same color of the app background. We first fade in this layer
|
||||
// to hide the expanding view, then we fade it out with SRC mode to draw a hole in the
|
||||
// launch container and reveal the opening window.
|
||||
val windowBackgroundColor = callback.getBackgroundColor(window.taskInfo)
|
||||
val windowBackgroundLayer = GradientDrawable().apply {
|
||||
setColor(windowBackgroundColor)
|
||||
alpha = 0
|
||||
}
|
||||
|
||||
// Update state.
|
||||
val animator = ValueAnimator.ofFloat(0f, 1f)
|
||||
this.animator = animator
|
||||
animator.duration = ANIMATION_DURATION
|
||||
animator.interpolator = Interpolators.LINEAR
|
||||
|
||||
val launchContainerOverlay = launchContainer.overlay
|
||||
animator.addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationStart(animation: Animator?, isReverse: Boolean) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Animation started")
|
||||
}
|
||||
|
||||
// We animate the opening window and delegate the view expansion to [this.controller].
|
||||
val delegate = this.controller
|
||||
val controller = object : LaunchAnimator.Controller by delegate {
|
||||
override fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) {
|
||||
callback.setBlursDisabledForAppLaunch(true)
|
||||
controller.onLaunchAnimationStart(isExpandingFullyAbove)
|
||||
|
||||
// Add the drawable to the launch container overlay. Overlays always draw
|
||||
// drawables after views, so we know that it will be drawn above any view added
|
||||
// by the controller.
|
||||
launchContainerOverlay.add(windowBackgroundLayer)
|
||||
delegate.onLaunchAnimationStart(isExpandingFullyAbove)
|
||||
}
|
||||
|
||||
override fun onAnimationEnd(animation: Animator?) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Animation ended")
|
||||
}
|
||||
|
||||
override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) {
|
||||
callback.setBlursDisabledForAppLaunch(false)
|
||||
iCallback?.invoke()
|
||||
controller.onLaunchAnimationEnd(isExpandingFullyAbove)
|
||||
launchContainerOverlay.remove(windowBackgroundLayer)
|
||||
}
|
||||
})
|
||||
|
||||
animator.addUpdateListener { animation ->
|
||||
if (cancelled) {
|
||||
return@addUpdateListener
|
||||
delegate.onLaunchAnimationEnd(isExpandingFullyAbove)
|
||||
}
|
||||
|
||||
val linearProgress = animation.animatedFraction
|
||||
val progress = animationInterpolator.getInterpolation(linearProgress)
|
||||
val xProgress = animationInterpolatorX.getInterpolation(linearProgress)
|
||||
val xCenter = MathUtils.lerp(startXCenter, endXCenter, xProgress)
|
||||
val halfWidth = lerp(startWidth, endWidth, progress) / 2
|
||||
|
||||
state.top = lerp(startTop, endTop, progress).roundToInt()
|
||||
state.bottom = lerp(startBottom, endBottom, progress).roundToInt()
|
||||
state.left = (xCenter - halfWidth).roundToInt()
|
||||
state.right = (xCenter + halfWidth).roundToInt()
|
||||
|
||||
state.topCornerRadius = MathUtils.lerp(startTopCornerRadius, endRadius, progress)
|
||||
state.bottomCornerRadius =
|
||||
MathUtils.lerp(startBottomCornerRadius, endRadius, progress)
|
||||
|
||||
// The expanding view can/should be hidden once it is completely coverred by the
|
||||
// windowBackgroundLayer.
|
||||
state.visible =
|
||||
getProgress(linearProgress, 0, ANIMATION_DURATION_FADE_OUT_CONTENT) < 1
|
||||
|
||||
applyStateToWindow(window, state)
|
||||
applyStateToWindowBackgroundLayer(windowBackgroundLayer, state, linearProgress)
|
||||
navigationBar?.let { applyStateToNavigationBar(it, state, linearProgress) }
|
||||
|
||||
// If we started expanding the view, we make it 1 pixel smaller on all sides to
|
||||
// avoid artefacts on the corners caused by anti-aliasing of the view background and
|
||||
// the window background layer.
|
||||
if (state.top != startTop && state.left != startLeft &&
|
||||
state.bottom != startBottom && state.right != startRight) {
|
||||
state.top += 1
|
||||
state.left += 1
|
||||
state.right -= 1
|
||||
state.bottom -= 1
|
||||
override fun onLaunchAnimationProgress(
|
||||
state: LaunchAnimator.State,
|
||||
progress: Float,
|
||||
linearProgress: Float
|
||||
) {
|
||||
applyStateToWindow(window, state)
|
||||
navigationBar?.let { applyStateToNavigationBar(it, state, linearProgress) }
|
||||
delegate.onLaunchAnimationProgress(state, progress, linearProgress)
|
||||
}
|
||||
controller.onLaunchAnimationProgress(state, progress, linearProgress)
|
||||
}
|
||||
|
||||
animator.start()
|
||||
// We draw a hole when the additional layer is fading out to reveal the opening window.
|
||||
animation = launchAnimator.startAnimation(
|
||||
controller, endState, windowBackgroundColor, drawHole = true)
|
||||
}
|
||||
|
||||
private fun applyStateToWindow(window: RemoteAnimationTarget, state: State) {
|
||||
private fun applyStateToWindow(window: RemoteAnimationTarget, state: LaunchAnimator.State) {
|
||||
val screenBounds = window.screenSpaceBounds
|
||||
val centerX = (screenBounds.left + screenBounds.right) / 2f
|
||||
val centerY = (screenBounds.top + screenBounds.bottom) / 2f
|
||||
@@ -632,48 +444,13 @@ class ActivityLaunchAnimator(
|
||||
transactionApplier.scheduleApply(params)
|
||||
}
|
||||
|
||||
private fun applyStateToWindowBackgroundLayer(
|
||||
drawable: GradientDrawable,
|
||||
state: State,
|
||||
linearProgress: Float
|
||||
) {
|
||||
// Update position.
|
||||
drawable.setBounds(state.left, state.top, state.right, state.bottom)
|
||||
|
||||
// Update radius.
|
||||
cornerRadii[0] = state.topCornerRadius
|
||||
cornerRadii[1] = state.topCornerRadius
|
||||
cornerRadii[2] = state.topCornerRadius
|
||||
cornerRadii[3] = state.topCornerRadius
|
||||
cornerRadii[4] = state.bottomCornerRadius
|
||||
cornerRadii[5] = state.bottomCornerRadius
|
||||
cornerRadii[6] = state.bottomCornerRadius
|
||||
cornerRadii[7] = state.bottomCornerRadius
|
||||
drawable.cornerRadii = cornerRadii
|
||||
|
||||
// We first fade in the background layer to hide the expanding view, then fade it out
|
||||
// with SRC mode to draw a hole punch in the status bar and reveal the opening window.
|
||||
val fadeInProgress = getProgress(linearProgress, 0, ANIMATION_DURATION_FADE_OUT_CONTENT)
|
||||
if (fadeInProgress < 1) {
|
||||
val alpha = CONTENT_FADE_OUT_INTERPOLATOR.getInterpolation(fadeInProgress)
|
||||
drawable.alpha = (alpha * 0xFF).roundToInt()
|
||||
drawable.setXfermode(null)
|
||||
} else {
|
||||
val fadeOutProgress = getProgress(linearProgress,
|
||||
ANIMATION_DELAY_FADE_IN_WINDOW, ANIMATION_DURATION_FADE_IN_WINDOW)
|
||||
val alpha = 1 - WINDOW_FADE_IN_INTERPOLATOR.getInterpolation(fadeOutProgress)
|
||||
drawable.alpha = (alpha * 0xFF).roundToInt()
|
||||
drawable.setXfermode(SRC_MODE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyStateToNavigationBar(
|
||||
navigationBar: RemoteAnimationTarget,
|
||||
state: State,
|
||||
state: LaunchAnimator.State,
|
||||
linearProgress: Float
|
||||
) {
|
||||
val fadeInProgress = getProgress(linearProgress, ANIMATION_DELAY_NAV_FADE_IN,
|
||||
ANIMATION_DURATION_NAV_FADE_OUT)
|
||||
val fadeInProgress = LaunchAnimator.getProgress(linearProgress,
|
||||
ANIMATION_DELAY_NAV_FADE_IN, ANIMATION_DURATION_NAV_FADE_OUT)
|
||||
|
||||
val params = SyncRtSurfaceTransactionApplier.SurfaceParams.Builder(navigationBar.leash)
|
||||
if (fadeInProgress > 0) {
|
||||
@@ -682,13 +459,13 @@ class ActivityLaunchAnimator(
|
||||
0f, (state.top - navigationBar.sourceContainerBounds.top).toFloat())
|
||||
windowCrop.set(state.left, 0, state.right, state.height)
|
||||
params
|
||||
.withAlpha(NAV_FADE_IN_INTERPOLATOR.getInterpolation(fadeInProgress))
|
||||
.withMatrix(matrix)
|
||||
.withWindowCrop(windowCrop)
|
||||
.withVisibility(true)
|
||||
.withAlpha(NAV_FADE_IN_INTERPOLATOR.getInterpolation(fadeInProgress))
|
||||
.withMatrix(matrix)
|
||||
.withWindowCrop(windowCrop)
|
||||
.withVisibility(true)
|
||||
} else {
|
||||
val fadeOutProgress = getProgress(linearProgress, 0,
|
||||
ANIMATION_DURATION_NAV_FADE_OUT)
|
||||
val fadeOutProgress = LaunchAnimator.getProgress(linearProgress, 0,
|
||||
ANIMATION_DURATION_NAV_FADE_OUT)
|
||||
params.withAlpha(1f - NAV_FADE_OUT_INTERPOLATOR.getInterpolation(fadeOutProgress))
|
||||
}
|
||||
|
||||
@@ -714,7 +491,7 @@ class ActivityLaunchAnimator(
|
||||
cancelled = true
|
||||
removeTimeout()
|
||||
context.mainExecutor.execute {
|
||||
animator?.cancel()
|
||||
animation?.cancel()
|
||||
controller.onLaunchAnimationCancelled()
|
||||
}
|
||||
}
|
||||
@@ -726,9 +503,5 @@ class ActivityLaunchAnimator(
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun lerp(start: Int, stop: Int, amount: Float): Float {
|
||||
return MathUtils.lerp(start.toFloat(), stop.toFloat(), amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
/*
|
||||
* 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) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that all dialogs currently shown won't animate into their touch surface when
|
||||
* dismissed.
|
||||
*
|
||||
* This is a temporary API meant to be called right before we both dismiss a dialog and start
|
||||
* an activity, which currently does not look good if we animate the dialog into the touch
|
||||
* surface at the same time as the activity starts.
|
||||
*
|
||||
* TODO(b/193634619): Remove this function and animate dialog into opening activity instead.
|
||||
*/
|
||||
fun disableAllCurrentDialogsExitAnimations() {
|
||||
currentAnimations.forEach { it.exitAnimationDisabled = true }
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
theme: Int,
|
||||
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, R.style.HostDialogTheme, 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
|
||||
var exitAnimationDisabled = 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
|
||||
)
|
||||
|
||||
// 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,
|
||||
|
||||
// We give it the size of its original dialog window.
|
||||
FrameLayout.LayoutParams(
|
||||
originalDialog.window.attributes.width,
|
||||
originalDialog.window.attributes.height,
|
||||
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 {
|
||||
if (exitAnimationDisabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,24 @@
|
||||
/*
|
||||
* 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.graphics.Canvas
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.Insets
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.Rect
|
||||
@@ -42,6 +59,7 @@ open class GhostedViewLaunchAnimatorController(
|
||||
override var launchContainer = ghostedView.rootView as ViewGroup
|
||||
private val launchContainerOverlay: ViewGroupOverlay
|
||||
get() = launchContainer.overlay
|
||||
private val launchContainerLocation = IntArray(2)
|
||||
|
||||
/** The ghost view that is drawn and animated instead of the ghosted view. */
|
||||
private var ghostView: GhostView? = null
|
||||
@@ -59,8 +77,12 @@ open class GhostedViewLaunchAnimatorController(
|
||||
* [backgroundView].
|
||||
*/
|
||||
private var backgroundDrawable: WrappedDrawable? = null
|
||||
private val backgroundInsets by lazy { getBackground()?.opticalInsets ?: Insets.NONE }
|
||||
private var startBackgroundAlpha: Int = 0xFF
|
||||
|
||||
private val ghostedViewLocation = IntArray(2)
|
||||
private val ghostedViewState = LaunchAnimator.State()
|
||||
|
||||
/**
|
||||
* Return the background of the [ghostedView]. This background will be used to draw the
|
||||
* background of the background view that is expanding up to the final animation position. This
|
||||
@@ -103,16 +125,24 @@ open class GhostedViewLaunchAnimatorController(
|
||||
return gradient.cornerRadii?.get(CORNER_RADIUS_BOTTOM_INDEX) ?: gradient.cornerRadius
|
||||
}
|
||||
|
||||
override fun createAnimatorState(): ActivityLaunchAnimator.State {
|
||||
val location = ghostedView.locationOnScreen
|
||||
return ActivityLaunchAnimator.State(
|
||||
top = location[1],
|
||||
bottom = location[1] + ghostedView.height,
|
||||
left = location[0],
|
||||
right = location[0] + ghostedView.width,
|
||||
override fun createAnimatorState(): LaunchAnimator.State {
|
||||
val state = LaunchAnimator.State(
|
||||
topCornerRadius = getCurrentTopCornerRadius(),
|
||||
bottomCornerRadius = getCurrentBottomCornerRadius()
|
||||
)
|
||||
fillGhostedViewState(state)
|
||||
return state
|
||||
}
|
||||
|
||||
fun fillGhostedViewState(state: LaunchAnimator.State) {
|
||||
// For the animation we are interested in the area that has a non transparent background,
|
||||
// so we have to take the optical insets into account.
|
||||
ghostedView.getLocationOnScreen(ghostedViewLocation)
|
||||
val insets = backgroundInsets
|
||||
state.top = ghostedViewLocation[1] + insets.top
|
||||
state.bottom = ghostedViewLocation[1] + ghostedView.height - insets.bottom
|
||||
state.left = ghostedViewLocation[0] + insets.left
|
||||
state.right = ghostedViewLocation[0] + ghostedView.width - insets.right
|
||||
}
|
||||
|
||||
override fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) {
|
||||
@@ -144,7 +174,7 @@ open class GhostedViewLaunchAnimatorController(
|
||||
}
|
||||
|
||||
override fun onLaunchAnimationProgress(
|
||||
state: ActivityLaunchAnimator.State,
|
||||
state: LaunchAnimator.State,
|
||||
progress: Float,
|
||||
linearProgress: Float
|
||||
) {
|
||||
@@ -162,19 +192,47 @@ open class GhostedViewLaunchAnimatorController(
|
||||
return
|
||||
}
|
||||
|
||||
val scale = min(state.widthRatio, state.heightRatio)
|
||||
ghostViewMatrix.setValues(initialGhostViewMatrixValues)
|
||||
ghostViewMatrix.postScale(scale, scale, state.startCenterX, state.startCenterY)
|
||||
// The ghost and backgrounds views were made invisible earlier. That can for instance happen
|
||||
// when animating a dialog into a view.
|
||||
if (ghostView.visibility == View.INVISIBLE) {
|
||||
ghostView.visibility = View.VISIBLE
|
||||
backgroundView.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
fillGhostedViewState(ghostedViewState)
|
||||
val leftChange = state.left - ghostedViewState.left
|
||||
val rightChange = state.right - ghostedViewState.right
|
||||
val topChange = state.top - ghostedViewState.top
|
||||
val bottomChange = state.bottom - ghostedViewState.bottom
|
||||
|
||||
val widthRatio = state.width.toFloat() / ghostedViewState.width
|
||||
val heightRatio = state.height.toFloat() / ghostedViewState.height
|
||||
val scale = min(widthRatio, heightRatio)
|
||||
|
||||
launchContainer.getLocationOnScreen(launchContainerLocation)
|
||||
GhostView.calculateMatrix(ghostedView, launchContainer, ghostViewMatrix)
|
||||
ghostViewMatrix.postScale(
|
||||
scale, scale,
|
||||
ghostedViewState.centerX - launchContainerLocation[0],
|
||||
ghostedViewState.centerY - launchContainerLocation[1]
|
||||
)
|
||||
ghostViewMatrix.postTranslate(
|
||||
(state.leftChange + state.rightChange) / 2f,
|
||||
(state.topChange + state.bottomChange) / 2f
|
||||
(leftChange + rightChange) / 2f,
|
||||
(topChange + bottomChange) / 2f
|
||||
)
|
||||
ghostView.animationMatrix = ghostViewMatrix
|
||||
|
||||
backgroundView.top = state.top
|
||||
backgroundView.bottom = state.bottom
|
||||
backgroundView.left = state.left
|
||||
backgroundView.right = state.right
|
||||
// We need to take into account the background insets for the background position.
|
||||
val insets = backgroundInsets
|
||||
val topWithInsets = state.top - insets.top
|
||||
val leftWithInsets = state.left - insets.left
|
||||
val rightWithInsets = state.right + insets.right
|
||||
val bottomWithInsets = state.bottom + insets.bottom
|
||||
|
||||
backgroundView.top = topWithInsets - launchContainerLocation[1]
|
||||
backgroundView.bottom = bottomWithInsets - launchContainerLocation[1]
|
||||
backgroundView.left = leftWithInsets - launchContainerLocation[0]
|
||||
backgroundView.right = rightWithInsets - launchContainerLocation[0]
|
||||
|
||||
val backgroundDrawable = backgroundDrawable!!
|
||||
backgroundDrawable.wrapped?.let {
|
||||
@@ -207,7 +265,7 @@ open class GhostedViewLaunchAnimatorController(
|
||||
* [drawable] is a [LayerDrawable], this will return the first layer that is a
|
||||
* [GradientDrawable].
|
||||
*/
|
||||
private fun findGradientDrawable(drawable: Drawable): GradientDrawable? {
|
||||
fun findGradientDrawable(drawable: Drawable): GradientDrawable? {
|
||||
if (drawable is GradientDrawable) {
|
||||
return drawable
|
||||
}
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
/*
|
||||
* 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.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffXfermode
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.util.Log
|
||||
import android.util.MathUtils
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.AnimationUtils
|
||||
import android.view.animation.PathInterpolator
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val TAG = "LaunchAnimator"
|
||||
|
||||
/** A base class to animate a window launch (activity or dialog) from a view . */
|
||||
class LaunchAnimator @JvmOverloads constructor(
|
||||
context: Context,
|
||||
private val isForTesting: Boolean = false
|
||||
) {
|
||||
companion object {
|
||||
internal const val DEBUG = false
|
||||
const val ANIMATION_DURATION = 500L
|
||||
private const val ANIMATION_DURATION_FADE_OUT_CONTENT = 150L
|
||||
private const val ANIMATION_DURATION_FADE_IN_WINDOW = 183L
|
||||
private const val ANIMATION_DELAY_FADE_IN_WINDOW = ANIMATION_DURATION_FADE_OUT_CONTENT
|
||||
|
||||
private val WINDOW_FADE_IN_INTERPOLATOR = PathInterpolator(0f, 0f, 0.6f, 1f)
|
||||
private val SRC_MODE = PorterDuffXfermode(PorterDuff.Mode.SRC)
|
||||
|
||||
/**
|
||||
* Given the [linearProgress] of a launch animation, return the linear progress of the
|
||||
* sub-animation starting [delay] ms after the launch animation and that lasts [duration].
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getProgress(linearProgress: Float, delay: Long, duration: Long): Float {
|
||||
return MathUtils.constrain(
|
||||
(linearProgress * ANIMATION_DURATION - delay) / duration,
|
||||
0.0f,
|
||||
1.0f
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The interpolator used for the width, height, Y position and corner radius. */
|
||||
private val animationInterpolator = AnimationUtils.loadInterpolator(context,
|
||||
R.interpolator.launch_animation_interpolator_y)
|
||||
|
||||
/** The interpolator used for the X position. */
|
||||
private val animationInterpolatorX = AnimationUtils.loadInterpolator(context,
|
||||
R.interpolator.launch_animation_interpolator_x)
|
||||
|
||||
private val launchContainerLocation = IntArray(2)
|
||||
private val cornerRadii = FloatArray(8)
|
||||
|
||||
/**
|
||||
* A controller that takes care of applying the animation to an expanding view.
|
||||
*
|
||||
* Note that all callbacks (onXXX methods) are all called on the main thread.
|
||||
*/
|
||||
interface Controller {
|
||||
/**
|
||||
* The container in which the view that started the animation will be animating together
|
||||
* with the opening window.
|
||||
*
|
||||
* This will be used to:
|
||||
* - Get the associated [Context].
|
||||
* - Compute whether we are expanding fully above the launch container.
|
||||
* - Apply surface transactions in sync with RenderThread when animating an activity
|
||||
* launch.
|
||||
*
|
||||
* This container can be changed to force this [Controller] to animate the expanding view
|
||||
* inside a different location, for instance to ensure correct layering during the
|
||||
* animation.
|
||||
*/
|
||||
var launchContainer: ViewGroup
|
||||
|
||||
/**
|
||||
* Return the [State] of the view that will be animated. We will animate from this state to
|
||||
* the final window state.
|
||||
*
|
||||
* Note: This state will be mutated and passed to [onLaunchAnimationProgress] during the
|
||||
* animation.
|
||||
*/
|
||||
fun createAnimatorState(): State
|
||||
|
||||
/**
|
||||
* The animation started. This is typically used to initialize any additional resource
|
||||
* needed for the animation. [isExpandingFullyAbove] will be true if the window is expanding
|
||||
* fully above the [launchContainer].
|
||||
*/
|
||||
fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) {}
|
||||
|
||||
/** The animation made progress and the expandable view [state] should be updated. */
|
||||
fun onLaunchAnimationProgress(state: State, progress: Float, linearProgress: Float) {}
|
||||
|
||||
/**
|
||||
* The animation ended. This will be called *if and only if* [onLaunchAnimationStart] was
|
||||
* called previously. This is typically used to clean up the resources initialized when the
|
||||
* animation was started.
|
||||
*/
|
||||
fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) {}
|
||||
}
|
||||
|
||||
/** The state of an expandable view during a [LaunchAnimator] animation. */
|
||||
open class State(
|
||||
/** The position of the view in screen space coordinates. */
|
||||
var top: Int = 0,
|
||||
var bottom: Int = 0,
|
||||
var left: Int = 0,
|
||||
var right: Int = 0,
|
||||
|
||||
var topCornerRadius: Float = 0f,
|
||||
var bottomCornerRadius: Float = 0f
|
||||
) {
|
||||
private val startTop = top
|
||||
|
||||
val width: Int
|
||||
get() = right - left
|
||||
|
||||
val height: Int
|
||||
get() = bottom - top
|
||||
|
||||
open val topChange: Int
|
||||
get() = top - startTop
|
||||
|
||||
val centerX: Float
|
||||
get() = left + width / 2f
|
||||
|
||||
val centerY: Float
|
||||
get() = top + height / 2f
|
||||
|
||||
/** Whether the expanding view should be visible or hidden. */
|
||||
var visible: Boolean = true
|
||||
}
|
||||
|
||||
interface Animation {
|
||||
/** Cancel the animation. */
|
||||
fun cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a launch animation controlled by [controller] towards [endState]. An intermediary
|
||||
* layer with [windowBackgroundColor] will fade in then fade out above the expanding view, and
|
||||
* should be the same background color as the opening (or closing) window. If [drawHole] is
|
||||
* true, then this intermediary layer will be drawn with SRC blending mode while it fades out.
|
||||
*
|
||||
* TODO(b/184121838): Remove [drawHole] and instead make the StatusBar draw this hole instead.
|
||||
*/
|
||||
fun startAnimation(
|
||||
controller: Controller,
|
||||
endState: State,
|
||||
windowBackgroundColor: Int,
|
||||
drawHole: Boolean = false
|
||||
): Animation {
|
||||
val state = controller.createAnimatorState()
|
||||
|
||||
// Start state.
|
||||
val startTop = state.top
|
||||
val startBottom = state.bottom
|
||||
val startLeft = state.left
|
||||
val startRight = state.right
|
||||
val startCenterX = (startLeft + startRight) / 2f
|
||||
val startWidth = startRight - startLeft
|
||||
val startTopCornerRadius = state.topCornerRadius
|
||||
val startBottomCornerRadius = state.bottomCornerRadius
|
||||
|
||||
// End state.
|
||||
var endTop = endState.top
|
||||
var endBottom = endState.bottom
|
||||
var endLeft = endState.left
|
||||
var endRight = endState.right
|
||||
var endCenterX = (endLeft + endRight) / 2f
|
||||
var endWidth = endRight - endLeft
|
||||
val endTopCornerRadius = endState.topCornerRadius
|
||||
val endBottomCornerRadius = endState.bottomCornerRadius
|
||||
|
||||
fun maybeUpdateEndState() {
|
||||
if (endTop != endState.top || endBottom != endState.bottom ||
|
||||
endLeft != endState.left || endRight != endState.right) {
|
||||
endTop = endState.top
|
||||
endBottom = endState.bottom
|
||||
endLeft = endState.left
|
||||
endRight = endState.right
|
||||
endCenterX = (endLeft + endRight) / 2f
|
||||
endWidth = endRight - endLeft
|
||||
}
|
||||
}
|
||||
|
||||
val launchContainer = controller.launchContainer
|
||||
val isExpandingFullyAbove = isExpandingFullyAbove(launchContainer, endState)
|
||||
|
||||
// We add an extra layer with the same color as the dialog/app splash screen background
|
||||
// color, which is usually the same color of the app background. We first fade in this layer
|
||||
// to hide the expanding view, then we fade it out with SRC mode to draw a hole in the
|
||||
// launch container and reveal the opening window.
|
||||
val windowBackgroundLayer = GradientDrawable().apply {
|
||||
setColor(windowBackgroundColor)
|
||||
alpha = 0
|
||||
}
|
||||
|
||||
// Update state.
|
||||
val animator = ValueAnimator.ofFloat(0f, 1f)
|
||||
animator.duration = if (isForTesting) 0 else ANIMATION_DURATION
|
||||
animator.interpolator = Interpolators.LINEAR
|
||||
|
||||
val launchContainerOverlay = launchContainer.overlay
|
||||
var cancelled = false
|
||||
animator.addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationStart(animation: Animator?, isReverse: Boolean) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Animation started")
|
||||
}
|
||||
controller.onLaunchAnimationStart(isExpandingFullyAbove)
|
||||
|
||||
// Add the drawable to the launch container overlay. Overlays always draw
|
||||
// drawables after views, so we know that it will be drawn above any view added
|
||||
// by the controller.
|
||||
launchContainerOverlay.add(windowBackgroundLayer)
|
||||
}
|
||||
|
||||
override fun onAnimationEnd(animation: Animator?) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Animation ended")
|
||||
}
|
||||
controller.onLaunchAnimationEnd(isExpandingFullyAbove)
|
||||
launchContainerOverlay.remove(windowBackgroundLayer)
|
||||
}
|
||||
})
|
||||
|
||||
animator.addUpdateListener { animation ->
|
||||
if (cancelled) {
|
||||
// TODO(b/184121838): Cancel the animator directly instead of just skipping the
|
||||
// update.
|
||||
return@addUpdateListener
|
||||
}
|
||||
|
||||
maybeUpdateEndState()
|
||||
|
||||
// TODO(b/184121838): Use reverse interpolators to get the same path/arc as the non
|
||||
// reversed animation.
|
||||
val linearProgress = animation.animatedFraction
|
||||
val progress = animationInterpolator.getInterpolation(linearProgress)
|
||||
val xProgress = animationInterpolatorX.getInterpolation(linearProgress)
|
||||
|
||||
val xCenter = MathUtils.lerp(startCenterX, endCenterX, xProgress)
|
||||
val halfWidth = MathUtils.lerp(startWidth, endWidth, progress) / 2f
|
||||
|
||||
state.top = MathUtils.lerp(startTop, endTop, progress).roundToInt()
|
||||
state.bottom = MathUtils.lerp(startBottom, endBottom, progress).roundToInt()
|
||||
state.left = (xCenter - halfWidth).roundToInt()
|
||||
state.right = (xCenter + halfWidth).roundToInt()
|
||||
|
||||
state.topCornerRadius =
|
||||
MathUtils.lerp(startTopCornerRadius, endTopCornerRadius, progress)
|
||||
state.bottomCornerRadius =
|
||||
MathUtils.lerp(startBottomCornerRadius, endBottomCornerRadius, progress)
|
||||
|
||||
// The expanding view can/should be hidden once it is completely covered by the opening
|
||||
// window.
|
||||
state.visible = getProgress(linearProgress, 0, ANIMATION_DURATION_FADE_OUT_CONTENT) < 1
|
||||
|
||||
applyStateToWindowBackgroundLayer(
|
||||
windowBackgroundLayer,
|
||||
state,
|
||||
linearProgress,
|
||||
launchContainer,
|
||||
drawHole
|
||||
)
|
||||
controller.onLaunchAnimationProgress(state, progress, linearProgress)
|
||||
}
|
||||
|
||||
animator.start()
|
||||
return object : Animation {
|
||||
override fun cancel() {
|
||||
cancelled = true
|
||||
animator.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Return whether we are expanding fully above the [launchContainer]. */
|
||||
internal fun isExpandingFullyAbove(launchContainer: View, endState: State): Boolean {
|
||||
launchContainer.getLocationOnScreen(launchContainerLocation)
|
||||
return endState.top <= launchContainerLocation[1] &&
|
||||
endState.bottom >= launchContainerLocation[1] + launchContainer.height &&
|
||||
endState.left <= launchContainerLocation[0] &&
|
||||
endState.right >= launchContainerLocation[0] + launchContainer.width
|
||||
}
|
||||
|
||||
private fun applyStateToWindowBackgroundLayer(
|
||||
drawable: GradientDrawable,
|
||||
state: State,
|
||||
linearProgress: Float,
|
||||
launchContainer: View,
|
||||
drawHole: Boolean
|
||||
) {
|
||||
// Update position.
|
||||
launchContainer.getLocationOnScreen(launchContainerLocation)
|
||||
drawable.setBounds(
|
||||
state.left - launchContainerLocation[0],
|
||||
state.top - launchContainerLocation[1],
|
||||
state.right - launchContainerLocation[0],
|
||||
state.bottom - launchContainerLocation[1]
|
||||
)
|
||||
|
||||
// Update radius.
|
||||
cornerRadii[0] = state.topCornerRadius
|
||||
cornerRadii[1] = state.topCornerRadius
|
||||
cornerRadii[2] = state.topCornerRadius
|
||||
cornerRadii[3] = state.topCornerRadius
|
||||
cornerRadii[4] = state.bottomCornerRadius
|
||||
cornerRadii[5] = state.bottomCornerRadius
|
||||
cornerRadii[6] = state.bottomCornerRadius
|
||||
cornerRadii[7] = state.bottomCornerRadius
|
||||
drawable.cornerRadii = cornerRadii
|
||||
|
||||
// We first fade in the background layer to hide the expanding view, then fade it out
|
||||
// with SRC mode to draw a hole punch in the status bar and reveal the opening window.
|
||||
val fadeInProgress = getProgress(linearProgress, 0, ANIMATION_DURATION_FADE_OUT_CONTENT)
|
||||
if (fadeInProgress < 1) {
|
||||
val alpha = Interpolators.LINEAR_OUT_SLOW_IN.getInterpolation(fadeInProgress)
|
||||
drawable.alpha = (alpha * 0xFF).roundToInt()
|
||||
} else {
|
||||
val fadeOutProgress = getProgress(
|
||||
linearProgress, ANIMATION_DELAY_FADE_IN_WINDOW, ANIMATION_DURATION_FADE_IN_WINDOW)
|
||||
val alpha = 1 - WINDOW_FADE_IN_INTERPOLATOR.getInterpolation(fadeOutProgress)
|
||||
drawable.alpha = (alpha * 0xFF).roundToInt()
|
||||
|
||||
if (drawHole) {
|
||||
drawable.setXfermode(SRC_MODE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (C) 2020 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.
|
||||
-->
|
||||
|
||||
<inset xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<shape android:shape="rectangle">
|
||||
<corners android:radius="8dp" />
|
||||
<solid android:color="?android:attr/colorBackground" />
|
||||
</shape>
|
||||
</inset>
|
||||
@@ -19,7 +19,7 @@
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
|
||||
android:id="@+id/internet_connectivity_dialog"
|
||||
android:layout_width="@dimen/internet_dialog_list_max_width"
|
||||
android:layout_width="@dimen/large_dialog_width"
|
||||
android:layout_height="@dimen/internet_dialog_list_max_height"
|
||||
android:background="@drawable/internet_dialog_rounded_top_corner_background"
|
||||
android:orientation="vertical">
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/media_output_dialog"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="@dimen/large_dialog_width"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@
|
||||
android:background="@drawable/qs_media_light_source"
|
||||
android:forceHasOverlappingRendering="false">
|
||||
<LinearLayout
|
||||
android:id="@+id/media_seamless_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="@dimen/qs_seamless_height"
|
||||
|
||||
@@ -104,6 +104,6 @@
|
||||
<!-- When split shade is used, this panel should be aligned to the top -->
|
||||
<dimen name="qs_detail_margin_top">0dp</dimen>
|
||||
|
||||
<!-- Internet panel related dimensions -->
|
||||
<dimen name="internet_dialog_list_max_width">624dp</dimen>
|
||||
<!-- The width of large/content heavy dialogs (e.g. Internet, Media output, etc) -->
|
||||
<dimen name="large_dialog_width">624dp</dimen>
|
||||
</resources>
|
||||
|
||||
@@ -1596,7 +1596,9 @@
|
||||
<!-- Internet panel related dimensions -->
|
||||
<dimen name="internet_dialog_list_margin">12dp</dimen>
|
||||
<dimen name="internet_dialog_list_max_height">646dp</dimen>
|
||||
<dimen name="internet_dialog_list_max_width">@dimen/match_parent</dimen>
|
||||
|
||||
<!-- The width of large/content heavy dialogs (e.g. Internet, Media output, etc) -->
|
||||
<dimen name="large_dialog_width">@dimen/match_parent</dimen>
|
||||
|
||||
<!-- Signal icon in internet dialog -->
|
||||
<dimen name="signal_strength_icon_size">24dp</dimen>
|
||||
|
||||
@@ -331,9 +331,6 @@
|
||||
<style name="Animation.ShutdownUi" parent="@android:style/Animation.Toast">
|
||||
</style>
|
||||
|
||||
<style name="Animation.MediaOutputDialog" parent="@android:style/Animation.InputMethod">
|
||||
</style>
|
||||
|
||||
<!-- Standard animations for hiding and showing the status bar. -->
|
||||
<style name="Animation.StatusBar">
|
||||
</style>
|
||||
@@ -436,10 +433,6 @@
|
||||
<item name="android:windowCloseOnTouchOutside">true</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.SystemUI.Dialog.MediaOutput">
|
||||
<item name="android:windowBackground">@drawable/media_output_dialog_background</item>
|
||||
</style>
|
||||
|
||||
<style name="QSBorderlessButton">
|
||||
<item name="android:padding">12dp</item>
|
||||
<item name="android:background">@drawable/qs_btn_borderless_rect</item>
|
||||
|
||||
@@ -364,9 +364,9 @@ public class MediaControlPanel {
|
||||
seamlessView.setVisibility(View.VISIBLE);
|
||||
setVisibleAndAlpha(collapsedSet, R.id.media_seamless, true /*visible */);
|
||||
setVisibleAndAlpha(expandedSet, R.id.media_seamless, true /*visible */);
|
||||
seamlessView.setOnClickListener(v -> {
|
||||
mMediaOutputDialogFactory.create(data.getPackageName(), true);
|
||||
});
|
||||
seamlessView.setOnClickListener(
|
||||
v -> mMediaOutputDialogFactory.create(data.getPackageName(), true,
|
||||
mPlayerViewHolder.getSeamlessButton()));
|
||||
|
||||
ImageView iconView = mPlayerViewHolder.getSeamlessIcon();
|
||||
TextView deviceName = mPlayerViewHolder.getSeamlessText();
|
||||
|
||||
@@ -43,6 +43,7 @@ class PlayerViewHolder private constructor(itemView: View) {
|
||||
val seamless = itemView.requireViewById<ViewGroup>(R.id.media_seamless)
|
||||
val seamlessIcon = itemView.requireViewById<ImageView>(R.id.media_seamless_image)
|
||||
val seamlessText = itemView.requireViewById<TextView>(R.id.media_seamless_text)
|
||||
val seamlessButton = itemView.requireViewById<View>(R.id.media_seamless_button)
|
||||
|
||||
// Seek bar
|
||||
val seekBar = itemView.requireViewById<SeekBar>(R.id.media_progress_bar)
|
||||
|
||||
@@ -45,11 +45,14 @@ public class MediaOutputAdapter extends MediaOutputBaseAdapter {
|
||||
private static final String TAG = "MediaOutputAdapter";
|
||||
private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
|
||||
|
||||
private final MediaOutputDialog mMediaOutputDialog;
|
||||
private ViewGroup mConnectedItem;
|
||||
private boolean mIncludeDynamicGroup;
|
||||
|
||||
public MediaOutputAdapter(MediaOutputController controller) {
|
||||
public MediaOutputAdapter(MediaOutputController controller,
|
||||
MediaOutputDialog mediaOutputDialog) {
|
||||
super(controller);
|
||||
mMediaOutputDialog = mediaOutputDialog;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -136,7 +139,7 @@ public class MediaOutputAdapter extends MediaOutputBaseAdapter {
|
||||
mDivider.setTransitionAlpha(1);
|
||||
mAddIcon.setVisibility(View.VISIBLE);
|
||||
mAddIcon.setTransitionAlpha(1);
|
||||
mAddIcon.setOnClickListener(v -> onEndItemClick());
|
||||
mAddIcon.setOnClickListener(this::onEndItemClick);
|
||||
} else {
|
||||
// Init non-active device layout
|
||||
mDivider.setVisibility(View.GONE);
|
||||
@@ -197,7 +200,7 @@ public class MediaOutputAdapter extends MediaOutputBaseAdapter {
|
||||
mDivider.setTransitionAlpha(1);
|
||||
mAddIcon.setVisibility(View.VISIBLE);
|
||||
mAddIcon.setTransitionAlpha(1);
|
||||
mAddIcon.setOnClickListener(v -> onEndItemClick());
|
||||
mAddIcon.setOnClickListener(this::onEndItemClick);
|
||||
} else {
|
||||
mDivider.setVisibility(View.GONE);
|
||||
mAddIcon.setVisibility(View.GONE);
|
||||
@@ -232,8 +235,8 @@ public class MediaOutputAdapter extends MediaOutputBaseAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
private void onEndItemClick() {
|
||||
mController.launchMediaOutputGroupDialog();
|
||||
private void onEndItemClick(View view) {
|
||||
mController.launchMediaOutputGroupDialog(mMediaOutputDialog.getDialogView());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ public abstract class MediaOutputBaseDialog extends SystemUIDialog implements
|
||||
};
|
||||
|
||||
public MediaOutputBaseDialog(Context context, MediaOutputController mediaOutputController) {
|
||||
super(context, R.style.Theme_SystemUI_Dialog_MediaOutput);
|
||||
super(context);
|
||||
mContext = context;
|
||||
mMediaOutputController = mediaOutputController;
|
||||
mLayoutManager = new LinearLayoutManager(mContext);
|
||||
@@ -97,15 +97,15 @@ public abstract class MediaOutputBaseDialog extends SystemUIDialog implements
|
||||
mDialogView = LayoutInflater.from(mContext).inflate(R.layout.media_output_dialog, null);
|
||||
final Window window = getWindow();
|
||||
final WindowManager.LayoutParams lp = window.getAttributes();
|
||||
lp.gravity = Gravity.BOTTOM;
|
||||
lp.gravity = Gravity.CENTER;
|
||||
// Config insets to make sure the layout is above the navigation bar
|
||||
lp.setFitInsetsTypes(statusBars() | navigationBars());
|
||||
lp.setFitInsetsSides(WindowInsets.Side.all());
|
||||
lp.setFitInsetsIgnoringVisibility(true);
|
||||
window.setAttributes(lp);
|
||||
window.setContentView(mDialogView);
|
||||
window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
window.setWindowAnimations(R.style.Animation_MediaOutputDialog);
|
||||
window.setLayout(mContext.getResources().getDimensionPixelSize(R.dimen.large_dialog_width),
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
|
||||
mHeaderTitle = mDialogView.requireViewById(R.id.header_title);
|
||||
mHeaderSubtitle = mDialogView.requireViewById(R.id.header_subtitle);
|
||||
@@ -229,4 +229,8 @@ public abstract class MediaOutputBaseDialog extends SystemUIDialog implements
|
||||
|
||||
void onHeaderIconClick() {
|
||||
}
|
||||
|
||||
View getDialogView() {
|
||||
return mDialogView;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import android.os.UserHandle;
|
||||
import android.os.UserManager;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
@@ -48,6 +49,7 @@ import com.android.settingslib.media.MediaDevice;
|
||||
import com.android.settingslib.media.MediaOutputConstants;
|
||||
import com.android.settingslib.utils.ThreadUtils;
|
||||
import com.android.systemui.R;
|
||||
import com.android.systemui.animation.DialogLaunchAnimator;
|
||||
import com.android.systemui.plugins.ActivityStarter;
|
||||
import com.android.systemui.statusbar.notification.NotificationEntryManager;
|
||||
import com.android.systemui.statusbar.notification.collection.NotificationEntry;
|
||||
@@ -73,6 +75,7 @@ public class MediaOutputController implements LocalMediaManager.DeviceCallback {
|
||||
private final MediaSessionManager mMediaSessionManager;
|
||||
private final ShadeController mShadeController;
|
||||
private final ActivityStarter mActivityStarter;
|
||||
private final DialogLaunchAnimator mDialogLaunchAnimator;
|
||||
private final List<MediaDevice> mGroupMediaDevices = new CopyOnWriteArrayList<>();
|
||||
private final boolean mAboveStatusbar;
|
||||
private final NotificationEntryManager mNotificationEntryManager;
|
||||
@@ -82,6 +85,7 @@ public class MediaOutputController implements LocalMediaManager.DeviceCallback {
|
||||
private MediaController mMediaController;
|
||||
@VisibleForTesting
|
||||
Callback mCallback;
|
||||
Callback mPreviousCallback;
|
||||
@VisibleForTesting
|
||||
LocalMediaManager mLocalMediaManager;
|
||||
|
||||
@@ -92,7 +96,8 @@ public class MediaOutputController implements LocalMediaManager.DeviceCallback {
|
||||
public MediaOutputController(@NonNull Context context, String packageName,
|
||||
boolean aboveStatusbar, MediaSessionManager mediaSessionManager, LocalBluetoothManager
|
||||
lbm, ShadeController shadeController, ActivityStarter starter,
|
||||
NotificationEntryManager notificationEntryManager, UiEventLogger uiEventLogger) {
|
||||
NotificationEntryManager notificationEntryManager, UiEventLogger uiEventLogger,
|
||||
DialogLaunchAnimator dialogLaunchAnimator) {
|
||||
mContext = context;
|
||||
mPackageName = packageName;
|
||||
mMediaSessionManager = mediaSessionManager;
|
||||
@@ -104,6 +109,7 @@ public class MediaOutputController implements LocalMediaManager.DeviceCallback {
|
||||
mLocalMediaManager = new LocalMediaManager(mContext, lbm, imm, packageName);
|
||||
mMetricLogger = new MediaOutputMetricLogger(mContext, mPackageName);
|
||||
mUiEventLogger = uiEventLogger;
|
||||
mDialogLaunchAnimator = dialogLaunchAnimator;
|
||||
}
|
||||
|
||||
void start(@NonNull Callback cb) {
|
||||
@@ -129,7 +135,19 @@ public class MediaOutputController implements LocalMediaManager.DeviceCallback {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mPreviousCallback != null) {
|
||||
Log.w(TAG,
|
||||
"Callback started when mPreviousCallback is not null, which is unexpected");
|
||||
mPreviousCallback.dismissDialog();
|
||||
}
|
||||
|
||||
// If we start the output group dialog when the output dialog is shown, we need to keep a
|
||||
// reference to the output dialog to set it back as the callback once we dismiss the output
|
||||
// group dialog.
|
||||
mPreviousCallback = mCallback;
|
||||
mCallback = cb;
|
||||
|
||||
mLocalMediaManager.unregisterCallback(this);
|
||||
mLocalMediaManager.stopScan();
|
||||
mLocalMediaManager.registerCallback(this);
|
||||
@@ -145,6 +163,15 @@ public class MediaOutputController implements LocalMediaManager.DeviceCallback {
|
||||
mLocalMediaManager.stopScan();
|
||||
}
|
||||
mMediaDevices.clear();
|
||||
|
||||
// If there was a previous callback, i.e. we just dismissed the output group dialog and are
|
||||
// now back on the output dialog, then we reset the callback to its previous value.
|
||||
mCallback = null;
|
||||
Callback previous = mPreviousCallback;
|
||||
mPreviousCallback = null;
|
||||
if (previous != null) {
|
||||
start(previous);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -436,6 +463,10 @@ public class MediaOutputController implements LocalMediaManager.DeviceCallback {
|
||||
}
|
||||
|
||||
void launchBluetoothPairing() {
|
||||
// Dismissing a dialog into its touch surface and starting an activity at the same time
|
||||
// looks bad, so let's make sure the dialog just fades out quickly.
|
||||
mDialogLaunchAnimator.disableAllCurrentDialogsExitAnimations();
|
||||
|
||||
mCallback.dismissDialog();
|
||||
final ActivityStarter.OnDismissAction postKeyguardAction = () -> {
|
||||
mContext.sendBroadcast(new Intent()
|
||||
@@ -447,14 +478,10 @@ public class MediaOutputController implements LocalMediaManager.DeviceCallback {
|
||||
mActivityStarter.dismissKeyguardThenExecute(postKeyguardAction, null, true);
|
||||
}
|
||||
|
||||
void launchMediaOutputDialog() {
|
||||
mCallback.dismissDialog();
|
||||
new MediaOutputDialog(mContext, mAboveStatusbar, this, mUiEventLogger);
|
||||
}
|
||||
|
||||
void launchMediaOutputGroupDialog() {
|
||||
mCallback.dismissDialog();
|
||||
new MediaOutputGroupDialog(mContext, mAboveStatusbar, this);
|
||||
void launchMediaOutputGroupDialog(View mediaOutputDialog) {
|
||||
// We show the output group dialog from the output dialog.
|
||||
MediaOutputGroupDialog dialog = new MediaOutputGroupDialog(mContext, mAboveStatusbar, this);
|
||||
mDialogLaunchAnimator.showFromView(dialog, mediaOutputDialog);
|
||||
}
|
||||
|
||||
boolean isActiveRemoteDevice(@NonNull MediaDevice device) {
|
||||
|
||||
@@ -40,11 +40,10 @@ public class MediaOutputDialog extends MediaOutputBaseDialog {
|
||||
mediaOutputController, UiEventLogger uiEventLogger) {
|
||||
super(context, mediaOutputController);
|
||||
mUiEventLogger = uiEventLogger;
|
||||
mAdapter = new MediaOutputAdapter(mMediaOutputController);
|
||||
mAdapter = new MediaOutputAdapter(mMediaOutputController, this);
|
||||
if (!aboveStatusbar) {
|
||||
getWindow().setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY);
|
||||
}
|
||||
show();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -18,8 +18,10 @@ package com.android.systemui.media.dialog
|
||||
|
||||
import android.content.Context
|
||||
import android.media.session.MediaSessionManager
|
||||
import android.view.View
|
||||
import com.android.internal.logging.UiEventLogger
|
||||
import com.android.settingslib.bluetooth.LocalBluetoothManager
|
||||
import com.android.systemui.animation.DialogLaunchAnimator
|
||||
import com.android.systemui.plugins.ActivityStarter
|
||||
import com.android.systemui.statusbar.notification.NotificationEntryManager
|
||||
import com.android.systemui.statusbar.phone.ShadeController
|
||||
@@ -35,19 +37,29 @@ class MediaOutputDialogFactory @Inject constructor(
|
||||
private val shadeController: ShadeController,
|
||||
private val starter: ActivityStarter,
|
||||
private val notificationEntryManager: NotificationEntryManager,
|
||||
private val uiEventLogger: UiEventLogger
|
||||
private val uiEventLogger: UiEventLogger,
|
||||
private val dialogLaunchAnimator: DialogLaunchAnimator
|
||||
) {
|
||||
companion object {
|
||||
var mediaOutputDialog: MediaOutputDialog? = null
|
||||
}
|
||||
|
||||
/** Creates a [MediaOutputDialog] for the given package. */
|
||||
fun create(packageName: String, aboveStatusBar: Boolean) {
|
||||
fun create(packageName: String, aboveStatusBar: Boolean, view: View? = null) {
|
||||
// Dismiss the previous dialog, if any.
|
||||
mediaOutputDialog?.dismiss()
|
||||
mediaOutputDialog = MediaOutputController(context, packageName, aboveStatusBar,
|
||||
mediaSessionManager, lbm, shadeController, starter, notificationEntryManager,
|
||||
uiEventLogger).run {
|
||||
MediaOutputDialog(context, aboveStatusBar, this, uiEventLogger)
|
||||
|
||||
val controller = MediaOutputController(context, packageName, aboveStatusBar,
|
||||
mediaSessionManager, lbm, shadeController, starter, notificationEntryManager,
|
||||
uiEventLogger, dialogLaunchAnimator)
|
||||
val dialog = MediaOutputDialog(context, aboveStatusBar, controller, uiEventLogger)
|
||||
mediaOutputDialog = dialog
|
||||
|
||||
// Show the dialog.
|
||||
if (view != null) {
|
||||
dialogLaunchAnimator.showFromView(dialog, view)
|
||||
} else {
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ public class MediaOutputGroupDialog extends MediaOutputBaseDialog {
|
||||
if (!aboveStatusbar) {
|
||||
getWindow().setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY);
|
||||
}
|
||||
show();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -83,6 +82,8 @@ public class MediaOutputGroupDialog extends MediaOutputBaseDialog {
|
||||
|
||||
@Override
|
||||
void onHeaderIconClick() {
|
||||
mMediaOutputController.launchMediaOutputDialog();
|
||||
// Given that we launched the media output group dialog from the media output dialog,
|
||||
// dismissing this dialog will show the media output dialog again.
|
||||
dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ public class InternetDialog extends SystemUIDialog implements
|
||||
window.setContentView(mDialogView);
|
||||
//Only fix the width for large screen or tablet.
|
||||
window.setLayout(mContext.getResources().getDimensionPixelSize(
|
||||
R.dimen.internet_dialog_list_max_width), ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
R.dimen.large_dialog_width), ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
window.setWindowAnimations(R.style.Animation_InternetDialog);
|
||||
window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
|
||||
window.addFlags(FLAG_LAYOUT_NO_LIMITS);
|
||||
|
||||
@@ -22,6 +22,9 @@ import android.content.Context;
|
||||
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;
|
||||
import com.android.systemui.dump.DumpManager;
|
||||
@@ -62,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;
|
||||
@@ -261,4 +265,29 @@ public interface StatusBarDependenciesModule {
|
||||
@Binds
|
||||
QSCarrierGroupController.SlotIndexResolver provideSlotIndexResolver(
|
||||
QSCarrierGroupController.SubscriptionManagerSlotIndexResolver impl);
|
||||
|
||||
/**
|
||||
*/
|
||||
@Provides
|
||||
@SysUISingleton
|
||||
static LaunchAnimator provideLaunchAnimator(Context context) {
|
||||
return new LaunchAnimator(context);
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
@Provides
|
||||
@SysUISingleton
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ package com.android.systemui.statusbar.notification
|
||||
|
||||
import android.util.MathUtils
|
||||
import com.android.internal.annotations.VisibleForTesting
|
||||
import com.android.systemui.animation.ActivityLaunchAnimator
|
||||
import com.android.systemui.animation.Interpolators
|
||||
import com.android.systemui.animation.LaunchAnimator
|
||||
import kotlin.math.min
|
||||
|
||||
/** Parameters for the notifications expand animations. */
|
||||
@@ -15,7 +15,7 @@ class ExpandAnimationParameters(
|
||||
|
||||
topCornerRadius: Float = 0f,
|
||||
bottomCornerRadius: Float = 0f
|
||||
) : ActivityLaunchAnimator.State(top, bottom, left, right, topCornerRadius, bottomCornerRadius) {
|
||||
) : LaunchAnimator.State(top, bottom, left, right, topCornerRadius, bottomCornerRadius) {
|
||||
@VisibleForTesting
|
||||
constructor() : this(
|
||||
top = 0, bottom = 0, left = 0, right = 0, topCornerRadius = 0f, bottomCornerRadius = 0f
|
||||
@@ -55,6 +55,6 @@ class ExpandAnimationParameters(
|
||||
}
|
||||
|
||||
fun getProgress(delay: Long, duration: Long): Float {
|
||||
return ActivityLaunchAnimator.getProgress(linearProgress, delay, duration)
|
||||
return LaunchAnimator.getProgress(linearProgress, delay, duration)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.android.systemui.statusbar.notification
|
||||
import android.view.ViewGroup
|
||||
import com.android.internal.jank.InteractionJankMonitor
|
||||
import com.android.systemui.animation.ActivityLaunchAnimator
|
||||
import com.android.systemui.animation.LaunchAnimator
|
||||
import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow
|
||||
import com.android.systemui.statusbar.notification.stack.NotificationListContainer
|
||||
import com.android.systemui.statusbar.phone.HeadsUpManagerPhone
|
||||
@@ -54,7 +55,7 @@ class NotificationLaunchAnimatorController(
|
||||
// Do nothing. Notifications are always animated inside their rootView.
|
||||
}
|
||||
|
||||
override fun createAnimatorState(): ActivityLaunchAnimator.State {
|
||||
override fun createAnimatorState(): LaunchAnimator.State {
|
||||
// If the notification panel is collapsed, the clip may be larger than the height.
|
||||
val height = max(0, notification.actualHeight - notification.clipBottomAmount)
|
||||
val location = notification.locationOnScreen
|
||||
@@ -72,12 +73,12 @@ class NotificationLaunchAnimatorController(
|
||||
notification.currentBackgroundRadiusTop
|
||||
}
|
||||
val params = ExpandAnimationParameters(
|
||||
top = windowTop,
|
||||
bottom = location[1] + height,
|
||||
left = location[0],
|
||||
right = location[0] + notification.width,
|
||||
topCornerRadius = topCornerRadius,
|
||||
bottomCornerRadius = notification.currentBackgroundRadiusBottom
|
||||
top = windowTop,
|
||||
bottom = location[1] + height,
|
||||
left = location[0],
|
||||
right = location[0] + notification.width,
|
||||
topCornerRadius = topCornerRadius,
|
||||
bottomCornerRadius = notification.currentBackgroundRadiusBottom
|
||||
)
|
||||
|
||||
params.startTranslationZ = notification.translationZ
|
||||
@@ -86,8 +87,8 @@ class NotificationLaunchAnimatorController(
|
||||
params.startClipTopAmount = notification.clipTopAmount
|
||||
if (notification.isChildInGroup) {
|
||||
params.startNotificationTop += notification.notificationParent.translationY
|
||||
val parentRoundedClip = Math.max(clipStartLocation
|
||||
- notification.notificationParent.locationOnScreen[1], 0)
|
||||
val parentRoundedClip = Math.max(
|
||||
clipStartLocation - notification.notificationParent.locationOnScreen[1], 0)
|
||||
params.parentStartRoundedTopClipping = parentRoundedClip
|
||||
|
||||
val parentClip = notification.notificationParent.clipTopAmount
|
||||
@@ -157,7 +158,7 @@ class NotificationLaunchAnimatorController(
|
||||
}
|
||||
|
||||
override fun onLaunchAnimationProgress(
|
||||
state: ActivityLaunchAnimator.State,
|
||||
state: LaunchAnimator.State,
|
||||
progress: Float,
|
||||
linearProgress: Float
|
||||
) {
|
||||
|
||||
@@ -103,8 +103,8 @@ import com.android.keyguard.dagger.KeyguardUserSwitcherComponent;
|
||||
import com.android.systemui.DejankUtils;
|
||||
import com.android.systemui.Dependency;
|
||||
import com.android.systemui.R;
|
||||
import com.android.systemui.animation.ActivityLaunchAnimator;
|
||||
import com.android.systemui.animation.Interpolators;
|
||||
import com.android.systemui.animation.LaunchAnimator;
|
||||
import com.android.systemui.biometrics.AuthController;
|
||||
import com.android.systemui.classifier.Classifier;
|
||||
import com.android.systemui.classifier.FalsingCollector;
|
||||
@@ -212,7 +212,7 @@ public class NotificationPanelViewController extends PanelViewController {
|
||||
*/
|
||||
private static final int FLING_HIDE = 2;
|
||||
private static final long ANIMATION_DELAY_ICON_FADE_IN =
|
||||
ActivityLaunchAnimator.ANIMATION_DURATION - CollapsedStatusBarFragment.FADE_IN_DURATION
|
||||
LaunchAnimator.ANIMATION_DURATION - CollapsedStatusBarFragment.FADE_IN_DURATION
|
||||
- CollapsedStatusBarFragment.FADE_IN_DELAY - 48;
|
||||
|
||||
private final DozeParameters mDozeParameters;
|
||||
@@ -3594,7 +3594,7 @@ public class NotificationPanelViewController extends PanelViewController {
|
||||
}
|
||||
|
||||
public void applyLaunchAnimationProgress(float linearProgress) {
|
||||
boolean hideIcons = ActivityLaunchAnimator.getProgress(linearProgress,
|
||||
boolean hideIcons = LaunchAnimator.getProgress(linearProgress,
|
||||
ANIMATION_DELAY_ICON_FADE_IN, 100) == 0.0f;
|
||||
if (hideIcons != mHideIconsDuringLaunchAnimation) {
|
||||
mHideIconsDuringLaunchAnimation = hideIcons;
|
||||
|
||||
@@ -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;
|
||||
@@ -669,7 +670,8 @@ public class StatusBar extends SystemUI implements
|
||||
private final SysuiStatusBarStateController mStatusBarStateController;
|
||||
|
||||
private HeadsUpAppearanceController mHeadsUpAppearanceController;
|
||||
private ActivityLaunchAnimator mActivityLaunchAnimator;
|
||||
private final ActivityLaunchAnimator mActivityLaunchAnimator;
|
||||
private final DialogLaunchAnimator mDialogLaunchAnimator;
|
||||
private NotificationLaunchAnimatorControllerProvider mNotificationAnimationProvider;
|
||||
protected StatusBarNotificationPresenter mPresenter;
|
||||
private NotificationActivityStarter mNotificationActivityStarter;
|
||||
@@ -792,7 +794,9 @@ public class StatusBar extends SystemUI implements
|
||||
UnlockedScreenOffAnimationController unlockedScreenOffAnimationController,
|
||||
Optional<StartingSurface> startingSurfaceOptional,
|
||||
TunerService tunerService,
|
||||
DumpManager dumpManager) {
|
||||
DumpManager dumpManager,
|
||||
ActivityLaunchAnimator activityLaunchAnimator,
|
||||
DialogLaunchAnimator dialogLaunchAnimator) {
|
||||
super(context);
|
||||
mNotificationsController = notificationsController;
|
||||
mLightBarController = lightBarController;
|
||||
@@ -900,6 +904,8 @@ 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);
|
||||
@@ -1467,7 +1473,7 @@ public class StatusBar extends SystemUI implements
|
||||
|
||||
private void setUpPresenter() {
|
||||
// Set up the initial notification state.
|
||||
mActivityLaunchAnimator = new ActivityLaunchAnimator(mKeyguardHandler, mContext);
|
||||
mActivityLaunchAnimator.setCallback(mKeyguardHandler);
|
||||
mNotificationAnimationProvider = new NotificationLaunchAnimatorControllerProvider(
|
||||
mNotificationShadeWindowViewController,
|
||||
mStackScrollerController.getNotificationListContainer(),
|
||||
@@ -2549,7 +2555,8 @@ public class StatusBar extends SystemUI implements
|
||||
animationController != null && !willLaunchResolverActivity && shouldAnimateLaunch(
|
||||
true /* isActivityIntent */);
|
||||
ActivityLaunchAnimator.Controller animController =
|
||||
animate ? wrapAnimationController(animationController, dismissShade) : null;
|
||||
animationController != null ? wrapAnimationController(animationController,
|
||||
dismissShade) : null;
|
||||
|
||||
// If we animate, we will dismiss the shade only once the animation is done. This is taken
|
||||
// care of by the StatusBarLaunchAnimationController.
|
||||
@@ -4426,6 +4433,8 @@ public class StatusBar extends SystemUI implements
|
||||
&& !mBiometricUnlockController.isWakeAndUnlock()) {
|
||||
mLightRevealScrim.setRevealAmount(1f - linear);
|
||||
}
|
||||
|
||||
mDialogLaunchAnimator.onDozeAmountChanged(linear);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.android.systemui.statusbar.phone
|
||||
|
||||
import com.android.systemui.animation.ActivityLaunchAnimator
|
||||
import com.android.systemui.animation.LaunchAnimator
|
||||
|
||||
/**
|
||||
* A [ActivityLaunchAnimator.Controller] that takes care of collapsing the status bar at the right
|
||||
@@ -22,7 +23,7 @@ class StatusBarLaunchAnimatorController(
|
||||
delegate.onLaunchAnimationStart(isExpandingFullyAbove)
|
||||
statusBar.notificationPanelViewController.setIsLaunchAnimationRunning(true)
|
||||
if (!isExpandingFullyAbove) {
|
||||
statusBar.collapsePanelWithDuration(ActivityLaunchAnimator.ANIMATION_DURATION.toInt())
|
||||
statusBar.collapsePanelWithDuration(LaunchAnimator.ANIMATION_DURATION.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +34,7 @@ class StatusBarLaunchAnimatorController(
|
||||
}
|
||||
|
||||
override fun onLaunchAnimationProgress(
|
||||
state: ActivityLaunchAnimator.State,
|
||||
state: LaunchAnimator.State,
|
||||
progress: Float,
|
||||
linearProgress: Float
|
||||
) {
|
||||
|
||||
@@ -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,36 @@
|
||||
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,
|
||||
theme: Int,
|
||||
onCreateCallback: () -> Unit,
|
||||
dismissOverride: (() -> Unit) -> Unit
|
||||
): Dialog {
|
||||
return SystemUIHostDialog(context, theme, onCreateCallback, dismissOverride)
|
||||
}
|
||||
|
||||
private class SystemUIHostDialog(
|
||||
context: Context,
|
||||
theme: Int,
|
||||
private val onCreateCallback: () -> Unit,
|
||||
private val dismissOverride: (() -> Unit) -> Unit
|
||||
) : SystemUIDialog(context, theme) {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
onCreateCallback()
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
dismissOverride {
|
||||
super.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ import com.android.internal.logging.MetricsLogger;
|
||||
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;
|
||||
@@ -235,7 +237,9 @@ public interface StatusBarPhoneModule {
|
||||
UnlockedScreenOffAnimationController unlockedScreenOffAnimationController,
|
||||
Optional<StartingSurface> startingSurfaceOptional,
|
||||
TunerService tunerService,
|
||||
DumpManager dumpManager) {
|
||||
DumpManager dumpManager,
|
||||
ActivityLaunchAnimator activityLaunchAnimator,
|
||||
DialogLaunchAnimator dialogLaunchAnimator) {
|
||||
return new StatusBar(
|
||||
context,
|
||||
notificationsController,
|
||||
@@ -333,6 +337,8 @@ public interface StatusBarPhoneModule {
|
||||
unlockedScreenOffAnimationController,
|
||||
startingSurfaceOptional,
|
||||
tunerService,
|
||||
dumpManager);
|
||||
dumpManager,
|
||||
activityLaunchAnimator,
|
||||
dialogLaunchAnimator);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import org.mockito.junit.MockitoJUnit
|
||||
@RunWithLooper
|
||||
class ActivityLaunchAnimatorTest : SysuiTestCase() {
|
||||
private val launchContainer = LinearLayout(mContext)
|
||||
private val launchAnimator = LaunchAnimator(mContext, isForTesting = true)
|
||||
@Mock lateinit var callback: ActivityLaunchAnimator.Callback
|
||||
@Spy private val controller = TestLaunchAnimatorController(launchContainer)
|
||||
@Mock lateinit var iCallback: IRemoteAnimationFinishedCallback
|
||||
@@ -56,7 +57,8 @@ class ActivityLaunchAnimatorTest : SysuiTestCase() {
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
activityLaunchAnimator = ActivityLaunchAnimator(callback, mContext)
|
||||
activityLaunchAnimator = ActivityLaunchAnimator(launchAnimator)
|
||||
activityLaunchAnimator.callback = callback
|
||||
}
|
||||
|
||||
private fun startIntentWithAnimation(
|
||||
@@ -120,7 +122,8 @@ class ActivityLaunchAnimatorTest : SysuiTestCase() {
|
||||
@Test
|
||||
fun animatesIfActivityIsAlreadyOpenAndIsOnKeyguard() {
|
||||
`when`(callback.isOnKeyguard()).thenReturn(true)
|
||||
val animator = ActivityLaunchAnimator(callback, context)
|
||||
val animator = ActivityLaunchAnimator(launchAnimator)
|
||||
animator.callback = callback
|
||||
|
||||
val willAnimateCaptor = ArgumentCaptor.forClass(Boolean::class.java)
|
||||
var animationAdapter: RemoteAnimationAdapter? = null
|
||||
@@ -208,7 +211,7 @@ class ActivityLaunchAnimatorTest : SysuiTestCase() {
|
||||
private class TestLaunchAnimatorController(
|
||||
override var launchContainer: ViewGroup
|
||||
) : ActivityLaunchAnimator.Controller {
|
||||
override fun createAnimatorState() = ActivityLaunchAnimator.State(
|
||||
override fun createAnimatorState() = LaunchAnimator.State(
|
||||
top = 100,
|
||||
bottom = 200,
|
||||
left = 300,
|
||||
@@ -232,7 +235,7 @@ private class TestLaunchAnimatorController(
|
||||
}
|
||||
|
||||
override fun onLaunchAnimationProgress(
|
||||
state: ActivityLaunchAnimator.State,
|
||||
state: LaunchAnimator.State,
|
||||
progress: Float,
|
||||
linearProgress: Float
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
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,
|
||||
theme: Int,
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ class GhostedViewLaunchAnimatorControllerTest : SysuiTestCase() {
|
||||
fun animatingOrphanViewDoesNotCrash() {
|
||||
val ghostedView = LinearLayout(mContext)
|
||||
val controller = GhostedViewLaunchAnimatorController(ghostedView)
|
||||
val state = ActivityLaunchAnimator.State(top = 0, bottom = 0, left = 0, right = 0)
|
||||
val state = LaunchAnimator.State(top = 0, bottom = 0, left = 0, right = 0)
|
||||
|
||||
controller.onIntentStarted(willAnimate = true)
|
||||
controller.onLaunchAnimationStart(isExpandingFullyAbove = true)
|
||||
|
||||
@@ -54,6 +54,7 @@ public class MediaOutputAdapterTest extends SysuiTestCase {
|
||||
|
||||
// Mock
|
||||
private MediaOutputController mMediaOutputController = mock(MediaOutputController.class);
|
||||
private MediaOutputDialog mMediaOutputDialog = mock(MediaOutputDialog.class);
|
||||
private MediaDevice mMediaDevice1 = mock(MediaDevice.class);
|
||||
private MediaDevice mMediaDevice2 = mock(MediaDevice.class);
|
||||
private Icon mIcon = mock(Icon.class);
|
||||
@@ -65,7 +66,7 @@ public class MediaOutputAdapterTest extends SysuiTestCase {
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
mMediaOutputAdapter = new MediaOutputAdapter(mMediaOutputController);
|
||||
mMediaOutputAdapter = new MediaOutputAdapter(mMediaOutputController, mMediaOutputDialog);
|
||||
mViewHolder = (MediaOutputAdapter.MediaDeviceViewHolder) mMediaOutputAdapter
|
||||
.onCreateViewHolder(new LinearLayout(mContext), 0);
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import com.android.internal.logging.UiEventLogger;
|
||||
import com.android.settingslib.bluetooth.LocalBluetoothManager;
|
||||
import com.android.systemui.R;
|
||||
import com.android.systemui.SysuiTestCase;
|
||||
import com.android.systemui.animation.DialogLaunchAnimator;
|
||||
import com.android.systemui.plugins.ActivityStarter;
|
||||
import com.android.systemui.statusbar.notification.NotificationEntryManager;
|
||||
import com.android.systemui.statusbar.phone.ShadeController;
|
||||
@@ -63,6 +64,7 @@ public class MediaOutputBaseDialogTest extends SysuiTestCase {
|
||||
private NotificationEntryManager mNotificationEntryManager =
|
||||
mock(NotificationEntryManager.class);
|
||||
private final UiEventLogger mUiEventLogger = mock(UiEventLogger.class);
|
||||
private final DialogLaunchAnimator mDialogLaunchAnimator = mock(DialogLaunchAnimator.class);
|
||||
|
||||
private MediaOutputBaseDialogImpl mMediaOutputBaseDialogImpl;
|
||||
private MediaOutputController mMediaOutputController;
|
||||
@@ -75,7 +77,7 @@ public class MediaOutputBaseDialogTest extends SysuiTestCase {
|
||||
public void setUp() {
|
||||
mMediaOutputController = new MediaOutputController(mContext, TEST_PACKAGE, false,
|
||||
mMediaSessionManager, mLocalBluetoothManager, mShadeController, mStarter,
|
||||
mNotificationEntryManager, mUiEventLogger);
|
||||
mNotificationEntryManager, mUiEventLogger, mDialogLaunchAnimator);
|
||||
mMediaOutputBaseDialogImpl = new MediaOutputBaseDialogImpl(mContext,
|
||||
mMediaOutputController);
|
||||
mMediaOutputBaseDialogImpl.onCreate(new Bundle());
|
||||
|
||||
@@ -49,6 +49,7 @@ import com.android.settingslib.media.LocalMediaManager;
|
||||
import com.android.settingslib.media.MediaDevice;
|
||||
import com.android.systemui.R;
|
||||
import com.android.systemui.SysuiTestCase;
|
||||
import com.android.systemui.animation.DialogLaunchAnimator;
|
||||
import com.android.systemui.plugins.ActivityStarter;
|
||||
import com.android.systemui.statusbar.notification.NotificationEntryManager;
|
||||
import com.android.systemui.statusbar.notification.collection.NotificationEntry;
|
||||
@@ -91,6 +92,7 @@ public class MediaOutputControllerTest extends SysuiTestCase {
|
||||
private NotificationEntryManager mNotificationEntryManager =
|
||||
mock(NotificationEntryManager.class);
|
||||
private final UiEventLogger mUiEventLogger = mock(UiEventLogger.class);
|
||||
private final DialogLaunchAnimator mDialogLaunchAnimator = mock(DialogLaunchAnimator.class);
|
||||
|
||||
private Context mSpyContext;
|
||||
private MediaOutputController mMediaOutputController;
|
||||
@@ -113,7 +115,7 @@ public class MediaOutputControllerTest extends SysuiTestCase {
|
||||
|
||||
mMediaOutputController = new MediaOutputController(mSpyContext, TEST_PACKAGE_NAME, false,
|
||||
mMediaSessionManager, mLocalBluetoothManager, mShadeController, mStarter,
|
||||
mNotificationEntryManager, mUiEventLogger);
|
||||
mNotificationEntryManager, mUiEventLogger, mDialogLaunchAnimator);
|
||||
mLocalMediaManager = spy(mMediaOutputController.mLocalMediaManager);
|
||||
mMediaOutputController.mLocalMediaManager = mLocalMediaManager;
|
||||
MediaDescription.Builder builder = new MediaDescription.Builder();
|
||||
@@ -157,7 +159,7 @@ public class MediaOutputControllerTest extends SysuiTestCase {
|
||||
public void start_withoutPackageName_verifyMediaControllerInit() {
|
||||
mMediaOutputController = new MediaOutputController(mSpyContext, null, false,
|
||||
mMediaSessionManager, mLocalBluetoothManager, mShadeController, mStarter,
|
||||
mNotificationEntryManager, mUiEventLogger);
|
||||
mNotificationEntryManager, mUiEventLogger, mDialogLaunchAnimator);
|
||||
|
||||
mMediaOutputController.start(mCb);
|
||||
|
||||
@@ -178,7 +180,7 @@ public class MediaOutputControllerTest extends SysuiTestCase {
|
||||
public void stop_withoutPackageName_verifyMediaControllerDeinit() {
|
||||
mMediaOutputController = new MediaOutputController(mSpyContext, null, false,
|
||||
mMediaSessionManager, mLocalBluetoothManager, mShadeController, mStarter,
|
||||
mNotificationEntryManager, mUiEventLogger);
|
||||
mNotificationEntryManager, mUiEventLogger, mDialogLaunchAnimator);
|
||||
|
||||
mMediaOutputController.start(mCb);
|
||||
|
||||
@@ -449,7 +451,7 @@ public class MediaOutputControllerTest extends SysuiTestCase {
|
||||
public void getNotificationLargeIcon_withoutPackageName_returnsNull() {
|
||||
mMediaOutputController = new MediaOutputController(mSpyContext, null, false,
|
||||
mMediaSessionManager, mLocalBluetoothManager, mShadeController, mStarter,
|
||||
mNotificationEntryManager, mUiEventLogger);
|
||||
mNotificationEntryManager, mUiEventLogger, mDialogLaunchAnimator);
|
||||
|
||||
assertThat(mMediaOutputController.getNotificationIcon()).isNull();
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.android.settingslib.bluetooth.LocalBluetoothManager;
|
||||
import com.android.settingslib.media.LocalMediaManager;
|
||||
import com.android.settingslib.media.MediaDevice;
|
||||
import com.android.systemui.SysuiTestCase;
|
||||
import com.android.systemui.animation.DialogLaunchAnimator;
|
||||
import com.android.systemui.plugins.ActivityStarter;
|
||||
import com.android.systemui.statusbar.notification.NotificationEntryManager;
|
||||
import com.android.systemui.statusbar.phone.ShadeController;
|
||||
@@ -65,6 +66,7 @@ public class MediaOutputDialogTest extends SysuiTestCase {
|
||||
private final NotificationEntryManager mNotificationEntryManager =
|
||||
mock(NotificationEntryManager.class);
|
||||
private final UiEventLogger mUiEventLogger = mock(UiEventLogger.class);
|
||||
private final DialogLaunchAnimator mDialogLaunchAnimator = mock(DialogLaunchAnimator.class);
|
||||
|
||||
private MediaOutputDialog mMediaOutputDialog;
|
||||
private MediaOutputController mMediaOutputController;
|
||||
@@ -74,10 +76,11 @@ public class MediaOutputDialogTest extends SysuiTestCase {
|
||||
public void setUp() {
|
||||
mMediaOutputController = new MediaOutputController(mContext, TEST_PACKAGE, false,
|
||||
mMediaSessionManager, mLocalBluetoothManager, mShadeController, mStarter,
|
||||
mNotificationEntryManager, mUiEventLogger);
|
||||
mNotificationEntryManager, mUiEventLogger, mDialogLaunchAnimator);
|
||||
mMediaOutputController.mLocalMediaManager = mLocalMediaManager;
|
||||
mMediaOutputDialog = new MediaOutputDialog(mContext, false,
|
||||
mMediaOutputController, mUiEventLogger);
|
||||
mMediaOutputDialog.show();
|
||||
|
||||
when(mLocalMediaManager.getCurrentConnectedDevice()).thenReturn(mMediaDevice);
|
||||
when(mMediaDevice.getFeatures()).thenReturn(mFeatures);
|
||||
@@ -123,6 +126,7 @@ public class MediaOutputDialogTest extends SysuiTestCase {
|
||||
public void onCreate_ShouldLogVisibility() {
|
||||
MediaOutputDialog testDialog = new MediaOutputDialog(mContext, false,
|
||||
mMediaOutputController, mUiEventLogger);
|
||||
testDialog.show();
|
||||
|
||||
testDialog.dismissDialog();
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import com.android.settingslib.media.LocalMediaManager;
|
||||
import com.android.settingslib.media.MediaDevice;
|
||||
import com.android.systemui.R;
|
||||
import com.android.systemui.SysuiTestCase;
|
||||
import com.android.systemui.animation.DialogLaunchAnimator;
|
||||
import com.android.systemui.plugins.ActivityStarter;
|
||||
import com.android.systemui.statusbar.notification.NotificationEntryManager;
|
||||
import com.android.systemui.statusbar.phone.ShadeController;
|
||||
@@ -64,6 +65,7 @@ public class MediaOutputGroupDialogTest extends SysuiTestCase {
|
||||
private NotificationEntryManager mNotificationEntryManager =
|
||||
mock(NotificationEntryManager.class);
|
||||
private final UiEventLogger mUiEventLogger = mock(UiEventLogger.class);
|
||||
private final DialogLaunchAnimator mDialogLaunchAnimator = mock(DialogLaunchAnimator.class);
|
||||
|
||||
private MediaOutputGroupDialog mMediaOutputGroupDialog;
|
||||
private MediaOutputController mMediaOutputController;
|
||||
@@ -73,10 +75,11 @@ public class MediaOutputGroupDialogTest extends SysuiTestCase {
|
||||
public void setUp() {
|
||||
mMediaOutputController = new MediaOutputController(mContext, TEST_PACKAGE, false,
|
||||
mMediaSessionManager, mLocalBluetoothManager, mShadeController, mStarter,
|
||||
mNotificationEntryManager, mUiEventLogger);
|
||||
mNotificationEntryManager, mUiEventLogger, mDialogLaunchAnimator);
|
||||
mMediaOutputController.mLocalMediaManager = mLocalMediaManager;
|
||||
mMediaOutputGroupDialog = new MediaOutputGroupDialog(mContext, false,
|
||||
mMediaOutputController);
|
||||
mMediaOutputGroupDialog.show();
|
||||
when(mLocalMediaManager.getSelectedMediaDevice()).thenReturn(mMediaDevices);
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,8 @@ import com.android.keyguard.ViewMediatorCallback;
|
||||
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;
|
||||
@@ -274,6 +276,8 @@ public class StatusBarTest extends SysuiTestCase {
|
||||
@Mock private StartingSurface mStartingSurface;
|
||||
@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);
|
||||
@@ -452,7 +456,9 @@ public class StatusBarTest extends SysuiTestCase {
|
||||
mUnlockedScreenOffAnimationController,
|
||||
Optional.of(mStartingSurface),
|
||||
mTunerService,
|
||||
mock(DumpManager.class));
|
||||
mock(DumpManager.class),
|
||||
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