• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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