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