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
rememberExpandableControllernull62 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 {
activityLaunchControllernull136 override fun activityLaunchController(
137 cujType: Int?,
138 ): ActivityLaunchAnimator.Controller? {
139 if (!isComposed.value) {
140 return null
141 }
142
143 return activityController(cujType)
144 }
145
dialogLaunchControllernull146 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 */
launchControllernull163 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. */
activityControllernull260 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
dialogControllernull275 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