blob: ebe96ebf298896394cff637ad1599eab590e0fca [file] [log] [blame]
/*
* 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.Interpolator
import com.android.systemui.animation.Interpolators.LINEAR
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(
private val timings: Timings,
private val interpolators: Interpolators
) {
companion object {
internal const val DEBUG = false
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(
timings: Timings,
linearProgress: Float,
delay: Long,
duration: Long
): Float {
return MathUtils.constrain(
(linearProgress * timings.totalDuration - delay) / duration,
0.0f,
1.0f
)
}
}
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()
}
/** The timings (durations and delays) used by this animator. */
class Timings(
/** The total duration of the animation. */
val totalDuration: Long,
/** The time to wait before fading out the expanding content. */
val contentBeforeFadeOutDelay: Long,
/** The duration of the expanding content fade out. */
val contentBeforeFadeOutDuration: Long,
/**
* The time to wait before fading in the expanded content (usually an activity or dialog
* window).
*/
val contentAfterFadeInDelay: Long,
/** The duration of the expanded content fade in. */
val contentAfterFadeInDuration: Long
)
/** The interpolators used by this animator. */
data class Interpolators(
/** The interpolator used for the Y position, width, height and corner radius. */
val positionInterpolator: Interpolator,
/**
* The interpolator used for the X position. This can be different than
* [positionInterpolator] to create an arc-path during the animation.
*/
val positionXInterpolator: Interpolator = positionInterpolator,
/** The interpolator used when fading out the expanding content. */
val contentBeforeFadeOutInterpolator: Interpolator,
/** The interpolator used when fading in the expanded content. */
val contentAfterFadeInInterpolator: Interpolator
)
/**
* 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 = timings.totalDuration
animator.interpolator = 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 = interpolators.positionInterpolator.getInterpolation(linearProgress)
val xProgress = interpolators.positionXInterpolator.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(
timings,
linearProgress,
timings.contentBeforeFadeOutDelay,
timings.contentBeforeFadeOutDuration
) < 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(
timings,
linearProgress,
timings.contentBeforeFadeOutDelay,
timings.contentBeforeFadeOutDuration
)
if (fadeInProgress < 1) {
val alpha =
interpolators.contentBeforeFadeOutInterpolator.getInterpolation(fadeInProgress)
drawable.alpha = (alpha * 0xFF).roundToInt()
} else {
val fadeOutProgress = getProgress(
timings,
linearProgress,
timings.contentAfterFadeInDelay,
timings.contentAfterFadeInDuration
)
val alpha =
1 - interpolators.contentAfterFadeInInterpolator.getInterpolation(fadeOutProgress)
drawable.alpha = (alpha * 0xFF).roundToInt()
if (drawHole) {
drawable.setXfermode(SRC_MODE)
}
}
}
}