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