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