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