1 /* 2 * Copyright (C) 2022 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.compose.animation 18 19 import android.content.ComponentName 20 import android.view.View 21 import android.view.ViewGroup 22 import android.view.ViewGroupOverlay 23 import android.view.ViewRootImpl 24 import androidx.compose.foundation.BorderStroke 25 import androidx.compose.material3.contentColorFor 26 import androidx.compose.runtime.Composable 27 import androidx.compose.runtime.DisposableEffect 28 import androidx.compose.runtime.MutableState 29 import androidx.compose.runtime.State 30 import androidx.compose.runtime.mutableStateOf 31 import androidx.compose.runtime.remember 32 import androidx.compose.ui.geometry.Offset 33 import androidx.compose.ui.geometry.Rect 34 import androidx.compose.ui.geometry.Size 35 import androidx.compose.ui.graphics.Color 36 import androidx.compose.ui.graphics.Outline 37 import androidx.compose.ui.graphics.Shape 38 import androidx.compose.ui.platform.LocalDensity 39 import androidx.compose.ui.platform.LocalLayoutDirection 40 import androidx.compose.ui.platform.LocalView 41 import androidx.compose.ui.unit.Density 42 import androidx.compose.ui.unit.LayoutDirection 43 import com.android.internal.jank.InteractionJankMonitor 44 import com.android.systemui.animation.ActivityTransitionAnimator 45 import com.android.systemui.animation.DialogCuj 46 import com.android.systemui.animation.DialogTransitionAnimator 47 import com.android.systemui.animation.Expandable 48 import com.android.systemui.animation.TransitionAnimator 49 import kotlin.math.roundToInt 50 51 /** A controller that can control animated launches from an [Expandable]. */ 52 interface ExpandableController { 53 /** The [Expandable] controlled by this controller. */ 54 val expandable: Expandable 55 56 /** Called when the [Expandable] stop being included in the composition. */ onDisposenull57 fun onDispose() 58 } 59 60 /** 61 * Create an [ExpandableController] to control an [Expandable]. This is useful if you need to create 62 * the controller before the [Expandable], for instance to handle clicks outside of the Expandable 63 * that would still trigger a dialog/activity launch animation. 64 */ 65 @Composable 66 fun rememberExpandableController( 67 color: Color, 68 shape: Shape, 69 contentColor: Color = contentColorFor(color), 70 borderStroke: BorderStroke? = null, 71 ): ExpandableController { 72 val composeViewRoot = LocalView.current 73 val density = LocalDensity.current 74 val layoutDirection = LocalLayoutDirection.current 75 76 // The current animation state, if we are currently animating a dialog or activity. 77 val animatorState = remember { mutableStateOf<TransitionAnimator.State?>(null) } 78 79 // Whether a dialog controlled by this ExpandableController is currently showing. 80 val isDialogShowing = remember { mutableStateOf(false) } 81 82 // The overlay in which we should animate the launch. 83 val overlay = remember { mutableStateOf<ViewGroupOverlay?>(null) } 84 85 // The current [ComposeView] being animated in the [overlay], if any. 86 val currentComposeViewInOverlay = remember { mutableStateOf<View?>(null) } 87 88 // The bounds in [composeViewRoot] of the expandable controlled by this controller. 89 val boundsInComposeViewRoot = remember { mutableStateOf(Rect.Zero) } 90 91 // Whether this composable is still composed. We only do the dialog exit animation if this is 92 // true. 93 val isComposed = remember { mutableStateOf(true) } 94 95 val controller = 96 remember( 97 color, 98 contentColor, 99 shape, 100 borderStroke, 101 composeViewRoot, 102 density, 103 layoutDirection, 104 ) { 105 ExpandableControllerImpl( 106 color, 107 contentColor, 108 shape, 109 borderStroke, 110 composeViewRoot, 111 density, 112 animatorState, 113 isDialogShowing, 114 overlay, 115 currentComposeViewInOverlay, 116 boundsInComposeViewRoot, 117 layoutDirection, 118 isComposed, 119 ) 120 } 121 122 DisposableEffect(Unit) { 123 onDispose { 124 isComposed.value = false 125 if (TransitionAnimator.returnAnimationsEnabled()) { 126 controller.onDispose() 127 } 128 } 129 } 130 131 return controller 132 } 133 134 internal class ExpandableControllerImpl( 135 internal val color: Color, 136 internal val contentColor: Color, 137 internal val shape: Shape, 138 internal val borderStroke: BorderStroke?, 139 internal val composeViewRoot: View, 140 internal val density: Density, 141 internal val animatorState: MutableState<TransitionAnimator.State?>, 142 internal val isDialogShowing: MutableState<Boolean>, 143 internal val overlay: MutableState<ViewGroupOverlay?>, 144 internal val currentComposeViewInOverlay: MutableState<View?>, 145 internal val boundsInComposeViewRoot: MutableState<Rect>, 146 private val layoutDirection: LayoutDirection, 147 private val isComposed: State<Boolean>, 148 ) : ExpandableController { 149 /** The [ActivityTransitionAnimator.Controller] to be cleaned up [onDispose]. */ 150 private var activityControllerForDisposal: ActivityTransitionAnimator.Controller? = null 151 152 override val expandable: Expandable = 153 object : Expandable { activityTransitionControllernull154 override fun activityTransitionController( 155 launchCujType: Int?, 156 cookie: ActivityTransitionAnimator.TransitionCookie?, 157 component: ComponentName?, 158 returnCujType: Int?, 159 isEphemeral: Boolean, 160 ): ActivityTransitionAnimator.Controller? { 161 if (!isComposed.value) { 162 return null 163 } 164 165 val controller = activityController(launchCujType, cookie, component, returnCujType) 166 if (TransitionAnimator.returnAnimationsEnabled() && isEphemeral) { 167 activityControllerForDisposal?.onDispose() 168 activityControllerForDisposal = controller 169 } 170 171 return controller 172 } 173 dialogTransitionControllernull174 override fun dialogTransitionController( 175 cuj: DialogCuj? 176 ): DialogTransitionAnimator.Controller? { 177 if (!isComposed.value) { 178 return null 179 } 180 181 return dialogController(cuj) 182 } 183 } 184 onDisposenull185 override fun onDispose() { 186 activityControllerForDisposal?.onDispose() 187 activityControllerForDisposal = null 188 } 189 190 /** 191 * Create a [TransitionAnimator.Controller] that is going to be used to drive an activity or 192 * dialog animation. This controller will: 193 * 1. Compute the start/end animation state using [boundsInComposeViewRoot] and the location of 194 * composeViewRoot on the screen. 195 * 2. Update [animatorState] with the current animation state if we are animating, or null 196 * otherwise. 197 */ transitionControllernull198 private fun transitionController(): TransitionAnimator.Controller { 199 return object : TransitionAnimator.Controller { 200 private val rootLocationOnScreen = intArrayOf(0, 0) 201 202 override var transitionContainer: ViewGroup = composeViewRoot.rootView as ViewGroup 203 204 override val isLaunching: Boolean = true 205 206 override fun onTransitionAnimationEnd(isExpandingFullyAbove: Boolean) { 207 animatorState.value = null 208 } 209 210 override fun onTransitionAnimationProgress( 211 state: TransitionAnimator.State, 212 progress: Float, 213 linearProgress: Float, 214 ) { 215 // We copy state given that it's always the same object that is mutated by 216 // ActivityTransitionAnimator. 217 animatorState.value = 218 TransitionAnimator.State( 219 state.top, 220 state.bottom, 221 state.left, 222 state.right, 223 state.topCornerRadius, 224 state.bottomCornerRadius, 225 ) 226 .apply { visible = state.visible } 227 228 // Force measure and layout the ComposeView in the overlay whenever the animation 229 // state changes. 230 currentComposeViewInOverlay.value?.let { 231 measureAndLayoutComposeViewInOverlay(it, state) 232 } 233 } 234 235 override fun createAnimatorState(): TransitionAnimator.State { 236 val boundsInRoot = boundsInComposeViewRoot.value 237 val outline = 238 shape.createOutline( 239 Size(boundsInRoot.width, boundsInRoot.height), 240 layoutDirection, 241 density, 242 ) 243 244 val (topCornerRadius, bottomCornerRadius) = 245 when (outline) { 246 is Outline.Rectangle -> 0f to 0f 247 is Outline.Rounded -> { 248 val roundRect = outline.roundRect 249 250 // TODO(b/230830644): Add better support different corner radii. 251 val topCornerRadius = 252 maxOf( 253 roundRect.topLeftCornerRadius.x, 254 roundRect.topLeftCornerRadius.y, 255 roundRect.topRightCornerRadius.x, 256 roundRect.topRightCornerRadius.y, 257 ) 258 val bottomCornerRadius = 259 maxOf( 260 roundRect.bottomLeftCornerRadius.x, 261 roundRect.bottomLeftCornerRadius.y, 262 roundRect.bottomRightCornerRadius.x, 263 roundRect.bottomRightCornerRadius.y, 264 ) 265 266 topCornerRadius to bottomCornerRadius 267 } 268 else -> 269 error( 270 "ExpandableState only supports (rounded) rectangles at the " + 271 "moment." 272 ) 273 } 274 275 val rootLocation = rootLocationOnScreen() 276 return TransitionAnimator.State( 277 top = rootLocation.y.roundToInt(), 278 bottom = (rootLocation.y + boundsInRoot.height).roundToInt(), 279 left = rootLocation.x.roundToInt(), 280 right = (rootLocation.x + boundsInRoot.width).roundToInt(), 281 topCornerRadius = topCornerRadius, 282 bottomCornerRadius = bottomCornerRadius, 283 ) 284 } 285 286 private fun rootLocationOnScreen(): Offset { 287 composeViewRoot.getLocationOnScreen(rootLocationOnScreen) 288 val boundsInRoot = boundsInComposeViewRoot.value 289 val x = rootLocationOnScreen[0] + boundsInRoot.left 290 val y = rootLocationOnScreen[1] + boundsInRoot.top 291 return Offset(x, y) 292 } 293 } 294 } 295 296 /** Create an [ActivityTransitionAnimator.Controller] that can be used to animate activities. */ activityControllernull297 private fun activityController( 298 launchCujType: Int?, 299 cookie: ActivityTransitionAnimator.TransitionCookie?, 300 component: ComponentName?, 301 returnCujType: Int?, 302 ): ActivityTransitionAnimator.Controller { 303 val delegate = transitionController() 304 return object : 305 ActivityTransitionAnimator.Controller, TransitionAnimator.Controller by delegate { 306 /** 307 * CUJ identifier accounting for whether this controller is for a launch or a return. 308 */ 309 private val cujType: Int? 310 get() = 311 if (isLaunching) { 312 launchCujType 313 } else { 314 returnCujType 315 } 316 317 override val transitionCookie = cookie 318 override val component = component 319 320 override fun onTransitionAnimationStart(isExpandingFullyAbove: Boolean) { 321 delegate.onTransitionAnimationStart(isExpandingFullyAbove) 322 overlay.value = composeViewRoot.rootView.overlay as ViewGroupOverlay 323 cujType?.let { InteractionJankMonitor.getInstance().begin(composeViewRoot, it) } 324 } 325 326 override fun onTransitionAnimationEnd(isExpandingFullyAbove: Boolean) { 327 cujType?.let { InteractionJankMonitor.getInstance().end(it) } 328 delegate.onTransitionAnimationEnd(isExpandingFullyAbove) 329 overlay.value = null 330 } 331 } 332 } 333 dialogControllernull334 private fun dialogController(cuj: DialogCuj?): DialogTransitionAnimator.Controller { 335 return object : DialogTransitionAnimator.Controller { 336 override val viewRoot: ViewRootImpl? = composeViewRoot.viewRootImpl 337 override val sourceIdentity: Any = this@ExpandableControllerImpl 338 override val cuj: DialogCuj? = cuj 339 340 override fun startDrawingInOverlayOf(viewGroup: ViewGroup) { 341 val newOverlay = viewGroup.overlay as ViewGroupOverlay 342 if (newOverlay != overlay.value) { 343 overlay.value = newOverlay 344 } 345 } 346 347 override fun stopDrawingInOverlay() { 348 if (overlay.value != null) { 349 overlay.value = null 350 } 351 } 352 353 override fun createTransitionController(): TransitionAnimator.Controller { 354 val delegate = transitionController() 355 return object : TransitionAnimator.Controller by delegate { 356 override fun onTransitionAnimationEnd(isExpandingFullyAbove: Boolean) { 357 delegate.onTransitionAnimationEnd(isExpandingFullyAbove) 358 359 // Make sure we don't draw this expandable when the dialog is showing. 360 isDialogShowing.value = true 361 } 362 } 363 } 364 365 override fun createExitController(): TransitionAnimator.Controller { 366 val delegate = transitionController() 367 return object : TransitionAnimator.Controller by delegate { 368 override fun onTransitionAnimationEnd(isExpandingFullyAbove: Boolean) { 369 delegate.onTransitionAnimationEnd(isExpandingFullyAbove) 370 isDialogShowing.value = false 371 } 372 } 373 } 374 375 override fun shouldAnimateExit(): Boolean = 376 isComposed.value && composeViewRoot.isAttachedToWindow && composeViewRoot.isShown 377 378 override fun onExitAnimationCancelled() { 379 isDialogShowing.value = false 380 } 381 382 override fun jankConfigurationBuilder(): InteractionJankMonitor.Configuration.Builder? { 383 val type = cuj?.cujType ?: return null 384 return InteractionJankMonitor.Configuration.Builder.withView(type, composeViewRoot) 385 } 386 } 387 } 388 } 389