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