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