1 /* 2 * Copyright (C) 2021 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.systemui.animation 18 19 import android.content.ComponentName 20 import android.graphics.Canvas 21 import android.graphics.ColorFilter 22 import android.graphics.Insets 23 import android.graphics.Matrix 24 import android.graphics.PixelFormat 25 import android.graphics.Rect 26 import android.graphics.drawable.Drawable 27 import android.graphics.drawable.GradientDrawable 28 import android.graphics.drawable.InsetDrawable 29 import android.graphics.drawable.LayerDrawable 30 import android.graphics.drawable.StateListDrawable 31 import android.util.Log 32 import android.view.GhostView 33 import android.view.View 34 import android.view.ViewGroup 35 import android.view.ViewGroupOverlay 36 import android.widget.FrameLayout 37 import com.android.internal.jank.Cuj.CujType 38 import com.android.internal.jank.InteractionJankMonitor 39 import java.util.LinkedList 40 import kotlin.math.min 41 import kotlin.math.roundToInt 42 43 private const val TAG = "GhostedViewTransitionAnimatorController" 44 45 /** 46 * A base implementation of [ActivityTransitionAnimator.Controller] which creates a 47 * [ghost][GhostView] of [ghostedView] as well as an expandable background view, which are drawn and 48 * animated instead of the ghosted view. 49 * 50 * Important: [ghostedView] must be attached to a [ViewGroup] when calling this function and during 51 * the animation. It must also implement [LaunchableView], otherwise an exception will be thrown 52 * during this controller instantiation. 53 * 54 * Note: Avoid instantiating this directly and call [ActivityTransitionAnimator.Controller.fromView] 55 * whenever possible instead. 56 */ 57 open class GhostedViewTransitionAnimatorController 58 @JvmOverloads 59 constructor( 60 /** The view that will be ghosted and from which the background will be extracted. */ 61 private val ghostedView: View, 62 63 /** The [CujType] associated to this launch animation. */ 64 private val launchCujType: Int? = null, 65 override val transitionCookie: ActivityTransitionAnimator.TransitionCookie? = null, 66 override val component: ComponentName? = null, 67 68 /** The [CujType] associated to this return animation. */ 69 private val returnCujType: Int? = null, 70 private var interactionJankMonitor: InteractionJankMonitor = 71 InteractionJankMonitor.getInstance(), 72 ) : ActivityTransitionAnimator.Controller { 73 override val isLaunching: Boolean = true 74 75 /** The container to which we will add the ghost view and expanding background. */ 76 override var transitionContainer = ghostedView.rootView as ViewGroup 77 private val transitionContainerOverlay: ViewGroupOverlay 78 get() = transitionContainer.overlay 79 80 private val transitionContainerLocation = IntArray(2) 81 82 /** The ghost view that is drawn and animated instead of the ghosted view. */ 83 private var ghostView: GhostView? = null <lambda>null84 private val initialGhostViewMatrixValues = FloatArray(9) { 0f } 85 private val ghostViewMatrix = Matrix() 86 87 /** 88 * The expanding background view that will be added to [transitionContainer] (below [ghostView]) 89 * and animate. 90 */ 91 private var backgroundView: FrameLayout? = null 92 93 /** 94 * The drawable wrapping the [ghostedView] background and used as background for 95 * [backgroundView]. 96 */ 97 private var backgroundDrawable: WrappedDrawable? = null <lambda>null98 private val backgroundInsets by lazy { background?.opticalInsets ?: Insets.NONE } 99 private var startBackgroundAlpha: Int = 0xFF 100 101 private val ghostedViewLocation = IntArray(2) 102 private val ghostedViewState = TransitionAnimator.State() 103 104 /** 105 * The background of the [ghostedView]. This background will be used to draw the background of 106 * the background view that is expanding up to the final animation position. 107 * 108 * Note that during the animation, the alpha value value of this background will be set to 0, 109 * then set back to its initial value at the end of the animation. 110 */ 111 private val background: Drawable? 112 113 /** CUJ identifier accounting for whether this controller is for a launch or a return. */ 114 private val cujType: Int? 115 get() = 116 if (isLaunching) { 117 launchCujType 118 } else { 119 returnCujType 120 } 121 122 init { 123 // Make sure the View we launch from implements LaunchableView to avoid visibility issues. 124 if (ghostedView !is LaunchableView) { 125 throw IllegalArgumentException( 126 "A GhostedViewLaunchAnimatorController was created from a View that does not " + 127 "implement LaunchableView. This can lead to subtle bugs where the visibility " + 128 "of the View we are launching from is not what we expected." 129 ) 130 } 131 132 /** Find the first view with a background in [view] and its children. */ findBackgroundnull133 fun findBackground(view: View): Drawable? { 134 if (view.background != null) { 135 return view.background 136 } 137 138 // Perform a BFS to find the largest View with background. 139 val views = LinkedList<View>().apply { add(view) } 140 141 while (views.isNotEmpty()) { 142 val v = views.removeAt(0) 143 if (v.background != null) { 144 return v.background 145 } 146 147 if (v is ViewGroup) { 148 for (i in 0 until v.childCount) { 149 views.add(v.getChildAt(i)) 150 } 151 } 152 } 153 154 return null 155 } 156 157 background = findBackground(ghostedView) 158 } 159 160 /** 161 * Set the corner radius of [background]. The background is the one that was returned by 162 * [getBackground]. 163 */ setBackgroundCornerRadiusnull164 protected open fun setBackgroundCornerRadius( 165 background: Drawable, 166 topCornerRadius: Float, 167 bottomCornerRadius: Float 168 ) { 169 // By default, we rely on WrappedDrawable to set/restore the background radii before/after 170 // each draw. 171 backgroundDrawable?.setBackgroundRadius(topCornerRadius, bottomCornerRadius) 172 } 173 174 /** Return the current top corner radius of the background. */ getCurrentTopCornerRadiusnull175 protected open fun getCurrentTopCornerRadius(): Float { 176 val drawable = background ?: return 0f 177 val gradient = findGradientDrawable(drawable) ?: return 0f 178 179 // TODO(b/184121838): Support more than symmetric top & bottom radius. 180 val radius = gradient.cornerRadii?.get(CORNER_RADIUS_TOP_INDEX) ?: gradient.cornerRadius 181 return radius * ghostedView.scaleX 182 } 183 184 /** Return the current bottom corner radius of the background. */ getCurrentBottomCornerRadiusnull185 protected open fun getCurrentBottomCornerRadius(): Float { 186 val drawable = background ?: return 0f 187 val gradient = findGradientDrawable(drawable) ?: return 0f 188 189 // TODO(b/184121838): Support more than symmetric top & bottom radius. 190 val radius = gradient.cornerRadii?.get(CORNER_RADIUS_BOTTOM_INDEX) ?: gradient.cornerRadius 191 return radius * ghostedView.scaleX 192 } 193 createAnimatorStatenull194 override fun createAnimatorState(): TransitionAnimator.State { 195 val state = 196 TransitionAnimator.State( 197 topCornerRadius = getCurrentTopCornerRadius(), 198 bottomCornerRadius = getCurrentBottomCornerRadius() 199 ) 200 fillGhostedViewState(state) 201 return state 202 } 203 fillGhostedViewStatenull204 fun fillGhostedViewState(state: TransitionAnimator.State) { 205 // For the animation we are interested in the area that has a non transparent background, 206 // so we have to take the optical insets into account. 207 ghostedView.getLocationOnScreen(ghostedViewLocation) 208 val insets = backgroundInsets 209 val boundCorrections: Rect = 210 if (ghostedView is LaunchableView) { 211 ghostedView.getPaddingForLaunchAnimation() 212 } else { 213 Rect() 214 } 215 state.top = ghostedViewLocation[1] + insets.top + boundCorrections.top 216 state.bottom = 217 ghostedViewLocation[1] + (ghostedView.height * ghostedView.scaleY).roundToInt() - 218 insets.bottom + boundCorrections.bottom 219 state.left = ghostedViewLocation[0] + insets.left + boundCorrections.left 220 state.right = 221 ghostedViewLocation[0] + (ghostedView.width * ghostedView.scaleX).roundToInt() - 222 insets.right + boundCorrections.right 223 } 224 onTransitionAnimationStartnull225 override fun onTransitionAnimationStart(isExpandingFullyAbove: Boolean) { 226 if (ghostedView.parent !is ViewGroup) { 227 // This should usually not happen, but let's make sure we don't crash if the view was 228 // detached right before we started the animation. 229 Log.w(TAG, "Skipping animation as ghostedView is not attached to a ViewGroup") 230 return 231 } 232 233 backgroundView = 234 FrameLayout(transitionContainer.context).also { transitionContainerOverlay.add(it) } 235 236 // We wrap the ghosted view background and use it to draw the expandable background. Its 237 // alpha will be set to 0 as soon as we start drawing the expanding background. 238 startBackgroundAlpha = background?.alpha ?: 0xFF 239 backgroundDrawable = WrappedDrawable(background) 240 backgroundView?.background = backgroundDrawable 241 242 // Delay the calls to `ghostedView.setVisibility()` during the animation. This must be 243 // called before `GhostView.addGhost()` is called because the latter will change the 244 // *transition* visibility, which won't be blocked and will affect the normal View 245 // visibility that is saved by `setShouldBlockVisibilityChanges()` for a later restoration. 246 (ghostedView as? LaunchableView)?.setShouldBlockVisibilityChanges(true) 247 248 // Create a ghost of the view that will be moving and fading out. This allows to fade out 249 // the content before fading out the background. 250 ghostView = GhostView.addGhost(ghostedView, transitionContainer) 251 252 // [GhostView.addGhost], the result of which is our [ghostView], creates a [GhostView], and 253 // adds it first to a [FrameLayout] container. It then adds _that_ container to an 254 // [OverlayViewGroup]. We need to turn off clipping for that container view. Currently, 255 // however, the only way to get a reference to that overlay is by going through our 256 // [ghostView]. The [OverlayViewGroup] will always be its grandparent view. 257 // TODO(b/306652954) reference the overlay view group directly if we can 258 (ghostView?.parent?.parent as? ViewGroup)?.let { 259 it.clipChildren = false 260 it.clipToPadding = false 261 } 262 263 val matrix = ghostView?.animationMatrix ?: Matrix.IDENTITY_MATRIX 264 matrix.getValues(initialGhostViewMatrixValues) 265 266 cujType?.let { interactionJankMonitor.begin(ghostedView, it) } 267 } 268 onTransitionAnimationProgressnull269 override fun onTransitionAnimationProgress( 270 state: TransitionAnimator.State, 271 progress: Float, 272 linearProgress: Float 273 ) { 274 val ghostView = this.ghostView ?: return 275 val backgroundView = this.backgroundView!! 276 277 if (!state.visible || !ghostedView.isAttachedToWindow) { 278 if (ghostView.visibility == View.VISIBLE) { 279 // Making the ghost view invisible will make the ghosted view visible, so order is 280 // important here. 281 ghostView.visibility = View.INVISIBLE 282 283 // Make the ghosted view invisible again. We use the transition visibility like 284 // GhostView does so that we don't mess up with the accessibility tree (see 285 // b/204944038#comment17). 286 ghostedView.setTransitionVisibility(View.INVISIBLE) 287 backgroundView.visibility = View.INVISIBLE 288 } 289 return 290 } 291 292 // The ghost and backgrounds views were made invisible earlier. That can for instance happen 293 // when animating a dialog into a view. 294 if (ghostView.visibility == View.INVISIBLE) { 295 ghostView.visibility = View.VISIBLE 296 backgroundView.visibility = View.VISIBLE 297 } 298 299 fillGhostedViewState(ghostedViewState) 300 val leftChange = state.left - ghostedViewState.left 301 val rightChange = state.right - ghostedViewState.right 302 val topChange = state.top - ghostedViewState.top 303 val bottomChange = state.bottom - ghostedViewState.bottom 304 305 val widthRatio = state.width.toFloat() / ghostedViewState.width 306 val heightRatio = state.height.toFloat() / ghostedViewState.height 307 val scale = min(widthRatio, heightRatio) 308 309 if (ghostedView.parent is ViewGroup) { 310 // Recalculate the matrix in case the ghosted view moved. We ensure that the ghosted 311 // view is still attached to a ViewGroup, otherwise calculateMatrix will throw. 312 GhostView.calculateMatrix(ghostedView, transitionContainer, ghostViewMatrix) 313 } 314 315 transitionContainer.getLocationOnScreen(transitionContainerLocation) 316 ghostViewMatrix.postScale( 317 scale, 318 scale, 319 ghostedViewState.centerX - transitionContainerLocation[0], 320 ghostedViewState.centerY - transitionContainerLocation[1] 321 ) 322 ghostViewMatrix.postTranslate( 323 (leftChange + rightChange) / 2f, 324 (topChange + bottomChange) / 2f 325 ) 326 ghostView.animationMatrix = ghostViewMatrix 327 328 // We need to take into account the background insets for the background position. 329 val insets = backgroundInsets 330 val topWithInsets = state.top - insets.top 331 val leftWithInsets = state.left - insets.left 332 val rightWithInsets = state.right + insets.right 333 val bottomWithInsets = state.bottom + insets.bottom 334 335 backgroundView.top = topWithInsets - transitionContainerLocation[1] 336 backgroundView.bottom = bottomWithInsets - transitionContainerLocation[1] 337 backgroundView.left = leftWithInsets - transitionContainerLocation[0] 338 backgroundView.right = rightWithInsets - transitionContainerLocation[0] 339 340 val backgroundDrawable = backgroundDrawable!! 341 backgroundDrawable.wrapped?.let { 342 setBackgroundCornerRadius(it, state.topCornerRadius, state.bottomCornerRadius) 343 } 344 } 345 onTransitionAnimationEndnull346 override fun onTransitionAnimationEnd(isExpandingFullyAbove: Boolean) { 347 if (ghostView == null) { 348 // We didn't actually run the animation. 349 return 350 } 351 352 cujType?.let { interactionJankMonitor.end(it) } 353 354 backgroundDrawable?.wrapped?.alpha = startBackgroundAlpha 355 356 GhostView.removeGhost(ghostedView) 357 backgroundView?.let { transitionContainerOverlay.remove(it) } 358 359 if (ghostedView is LaunchableView) { 360 // Restore the ghosted view visibility. 361 ghostedView.setShouldBlockVisibilityChanges(false) 362 ghostedView.onActivityLaunchAnimationEnd() 363 } else { 364 // Make the ghosted view visible. We ensure that the view is considered VISIBLE by 365 // accessibility by first making it INVISIBLE then VISIBLE (see b/204944038#comment17 366 // for more info). 367 ghostedView.visibility = View.INVISIBLE 368 ghostedView.visibility = View.VISIBLE 369 ghostedView.invalidate() 370 } 371 } 372 373 companion object { 374 private const val CORNER_RADIUS_TOP_INDEX = 0 375 private const val CORNER_RADIUS_BOTTOM_INDEX = 4 376 377 /** 378 * Return the first [GradientDrawable] found in [drawable], or null if none is found. If 379 * [drawable] is a [LayerDrawable], this will return the first layer that has a 380 * [GradientDrawable]. 381 */ findGradientDrawablenull382 fun findGradientDrawable(drawable: Drawable): GradientDrawable? { 383 if (drawable is GradientDrawable) { 384 return drawable 385 } 386 387 if (drawable is InsetDrawable) { 388 return drawable.drawable?.let { findGradientDrawable(it) } 389 } 390 391 if (drawable is LayerDrawable) { 392 for (i in 0 until drawable.numberOfLayers) { 393 val maybeGradient = findGradientDrawable(drawable.getDrawable(i)) 394 if (maybeGradient != null) { 395 return maybeGradient 396 } 397 } 398 } 399 400 if (drawable is StateListDrawable) { 401 return findGradientDrawable(drawable.current) 402 } 403 404 return null 405 } 406 } 407 408 private class WrappedDrawable(val wrapped: Drawable?) : Drawable() { 409 private var currentAlpha = 0xFF 410 private var previousBounds = Rect() 411 <lambda>null412 private var cornerRadii = FloatArray(8) { -1f } 413 private var previousCornerRadii = FloatArray(8) 414 drawnull415 override fun draw(canvas: Canvas) { 416 val wrapped = this.wrapped ?: return 417 418 wrapped.copyBounds(previousBounds) 419 420 wrapped.alpha = currentAlpha 421 wrapped.bounds = bounds 422 applyBackgroundRadii() 423 424 wrapped.draw(canvas) 425 426 // The background view (and therefore this drawable) is drawn before the ghost view, so 427 // the ghosted view background alpha should always be 0 when it is drawn above the 428 // background. 429 wrapped.alpha = 0 430 wrapped.bounds = previousBounds 431 restoreBackgroundRadii() 432 } 433 setAlphanull434 override fun setAlpha(alpha: Int) { 435 if (alpha != currentAlpha) { 436 currentAlpha = alpha 437 invalidateSelf() 438 } 439 } 440 getAlphanull441 override fun getAlpha() = currentAlpha 442 443 override fun getOpacity(): Int { 444 val wrapped = this.wrapped ?: return PixelFormat.TRANSPARENT 445 446 val previousAlpha = wrapped.alpha 447 wrapped.alpha = currentAlpha 448 val opacity = wrapped.opacity 449 wrapped.alpha = previousAlpha 450 return opacity 451 } 452 setColorFilternull453 override fun setColorFilter(filter: ColorFilter?) { 454 wrapped?.colorFilter = filter 455 } 456 setBackgroundRadiusnull457 fun setBackgroundRadius(topCornerRadius: Float, bottomCornerRadius: Float) { 458 updateRadii(cornerRadii, topCornerRadius, bottomCornerRadius) 459 invalidateSelf() 460 } 461 updateRadiinull462 private fun updateRadii( 463 radii: FloatArray, 464 topCornerRadius: Float, 465 bottomCornerRadius: Float 466 ) { 467 radii[0] = topCornerRadius 468 radii[1] = topCornerRadius 469 radii[2] = topCornerRadius 470 radii[3] = topCornerRadius 471 472 radii[4] = bottomCornerRadius 473 radii[5] = bottomCornerRadius 474 radii[6] = bottomCornerRadius 475 radii[7] = bottomCornerRadius 476 } 477 applyBackgroundRadiinull478 private fun applyBackgroundRadii() { 479 if (cornerRadii[0] < 0 || wrapped == null) { 480 return 481 } 482 483 savePreviousBackgroundRadii(wrapped) 484 applyBackgroundRadii(wrapped, cornerRadii) 485 } 486 savePreviousBackgroundRadiinull487 private fun savePreviousBackgroundRadii(background: Drawable) { 488 // TODO(b/184121838): This method assumes that all GradientDrawable in background will 489 // have the same radius. Should we save/restore the radii for each layer instead? 490 val gradient = findGradientDrawable(background) ?: return 491 492 // TODO(b/184121838): GradientDrawable#getCornerRadii clones its radii array. Should we 493 // try to avoid that? 494 val radii = gradient.cornerRadii 495 if (radii != null) { 496 radii.copyInto(previousCornerRadii) 497 } else { 498 // Copy the cornerRadius into previousCornerRadii. 499 val radius = gradient.cornerRadius 500 updateRadii(previousCornerRadii, radius, radius) 501 } 502 } 503 applyBackgroundRadiinull504 private fun applyBackgroundRadii(drawable: Drawable, radii: FloatArray) { 505 if (drawable is GradientDrawable) { 506 drawable.cornerRadii = radii 507 return 508 } 509 510 if (drawable is InsetDrawable) { 511 drawable.drawable?.let { applyBackgroundRadii(it, radii) } 512 return 513 } 514 515 if (drawable !is LayerDrawable) { 516 return 517 } 518 519 for (i in 0 until drawable.numberOfLayers) { 520 applyBackgroundRadii(drawable.getDrawable(i), radii) 521 } 522 } 523 restoreBackgroundRadiinull524 private fun restoreBackgroundRadii() { 525 if (cornerRadii[0] < 0 || wrapped == null) { 526 return 527 } 528 529 applyBackgroundRadii(wrapped, previousCornerRadii) 530 } 531 } 532 } 533