1 /*
<lambda>null2 * 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.Context
20 import android.view.View
21 import android.view.ViewGroup
22 import android.view.ViewGroupOverlay
23 import androidx.compose.foundation.BorderStroke
24 import androidx.compose.foundation.background
25 import androidx.compose.foundation.border
26 import androidx.compose.foundation.clickable
27 import androidx.compose.foundation.interaction.MutableInteractionSource
28 import androidx.compose.foundation.layout.Box
29 import androidx.compose.foundation.layout.Spacer
30 import androidx.compose.foundation.layout.defaultMinSize
31 import androidx.compose.foundation.layout.fillMaxSize
32 import androidx.compose.foundation.layout.requiredSize
33 import androidx.compose.foundation.shape.RoundedCornerShape
34 import androidx.compose.material3.ExperimentalMaterial3Api
35 import androidx.compose.material3.LocalContentColor
36 import androidx.compose.material3.contentColorFor
37 import androidx.compose.material3.minimumInteractiveComponentSize
38 import androidx.compose.runtime.Composable
39 import androidx.compose.runtime.CompositionLocalProvider
40 import androidx.compose.runtime.DisposableEffect
41 import androidx.compose.runtime.State
42 import androidx.compose.runtime.derivedStateOf
43 import androidx.compose.runtime.getValue
44 import androidx.compose.runtime.movableContentOf
45 import androidx.compose.runtime.mutableStateOf
46 import androidx.compose.runtime.remember
47 import androidx.compose.runtime.rememberCompositionContext
48 import androidx.compose.runtime.setValue
49 import androidx.compose.ui.Alignment
50 import androidx.compose.ui.Modifier
51 import androidx.compose.ui.draw.clip
52 import androidx.compose.ui.draw.drawWithContent
53 import androidx.compose.ui.geometry.CornerRadius
54 import androidx.compose.ui.geometry.Offset
55 import androidx.compose.ui.geometry.RoundRect
56 import androidx.compose.ui.geometry.Size
57 import androidx.compose.ui.graphics.Color
58 import androidx.compose.ui.graphics.Outline
59 import androidx.compose.ui.graphics.Path
60 import androidx.compose.ui.graphics.PathOperation
61 import androidx.compose.ui.graphics.Shape
62 import androidx.compose.ui.graphics.drawOutline
63 import androidx.compose.ui.graphics.drawscope.ContentDrawScope
64 import androidx.compose.ui.graphics.drawscope.Stroke
65 import androidx.compose.ui.graphics.drawscope.scale
66 import androidx.compose.ui.layout.boundsInRoot
67 import androidx.compose.ui.layout.findRootCoordinates
68 import androidx.compose.ui.layout.onGloballyPositioned
69 import androidx.compose.ui.platform.ComposeView
70 import androidx.compose.ui.platform.LocalContext
71 import androidx.compose.ui.unit.Density
72 import androidx.compose.ui.unit.dp
73 import androidx.lifecycle.ViewTreeLifecycleOwner
74 import androidx.lifecycle.ViewTreeViewModelStoreOwner
75 import com.android.systemui.animation.Expandable
76 import com.android.systemui.animation.LaunchAnimator
77 import kotlin.math.max
78 import kotlin.math.min
79
80 /**
81 * Create an expandable shape that can launch into an Activity or a Dialog.
82 *
83 * If this expandable should be expanded when it is clicked directly, then you should specify a
84 * [onClick] handler, which will ensure that this expandable interactive size and background size
85 * are consistent with the M3 components (48dp and 40dp respectively).
86 *
87 * If this expandable should be expanded when a children component is clicked, like a button inside
88 * the expandable, then you can use the Expandable parameter passed to the [content] lambda.
89 *
90 * Example:
91 * ```
92 * Expandable(
93 * color = MaterialTheme.colorScheme.primary,
94 * shape = RoundedCornerShape(16.dp),
95 *
96 * // For activities:
97 * onClick = { expandable ->
98 * activityStarter.startActivity(intent, expandable.activityLaunchController())
99 * },
100 *
101 * // For dialogs:
102 * onClick = { expandable ->
103 * dialogLaunchAnimator.show(dialog, controller.dialogLaunchController())
104 * },
105 * ) {
106 * ...
107 * }
108 * ```
109 *
110 * @sample com.android.systemui.compose.gallery.ActivityLaunchScreen
111 * @sample com.android.systemui.compose.gallery.DialogLaunchScreen
112 */
113 @Composable
114 fun Expandable(
115 color: Color,
116 shape: Shape,
117 modifier: Modifier = Modifier,
118 contentColor: Color = contentColorFor(color),
119 borderStroke: BorderStroke? = null,
120 onClick: ((Expandable) -> Unit)? = null,
121 interactionSource: MutableInteractionSource? = null,
122 content: @Composable (Expandable) -> Unit,
123 ) {
124 Expandable(
125 rememberExpandableController(color, shape, contentColor, borderStroke),
126 modifier,
127 onClick,
128 interactionSource,
129 content,
130 )
131 }
132
133 /**
134 * Create an expandable shape that can launch into an Activity or a Dialog.
135 *
136 * This overload can be used in cases where you need to create the [ExpandableController] before
137 * composing this [Expandable], for instance if something outside of this Expandable can trigger a
138 * launch animation
139 *
140 * Example:
141 * ```
142 * // The controller that you can use to trigger the animations from anywhere.
143 * val controller =
144 * rememberExpandableController(
145 * color = MaterialTheme.colorScheme.primary,
146 * shape = RoundedCornerShape(16.dp),
147 * )
148 *
149 * Expandable(controller) {
150 * ...
151 * }
152 * ```
153 *
154 * @sample com.android.systemui.compose.gallery.ActivityLaunchScreen
155 * @sample com.android.systemui.compose.gallery.DialogLaunchScreen
156 */
157 @OptIn(ExperimentalMaterial3Api::class)
158 @Composable
Expandablenull159 fun Expandable(
160 controller: ExpandableController,
161 modifier: Modifier = Modifier,
162 onClick: ((Expandable) -> Unit)? = null,
163 interactionSource: MutableInteractionSource? = null,
164 content: @Composable (Expandable) -> Unit,
165 ) {
166 val controller = controller as ExpandableControllerImpl
167 val color = controller.color
168 val contentColor = controller.contentColor
169 val shape = controller.shape
170
171 val wrappedContent =
172 remember(content) {
173 movableContentOf { expandable: Expandable ->
174 CompositionLocalProvider(
175 LocalContentColor provides contentColor,
176 ) {
177 // We make sure that the content itself (wrapped by the background) is at least
178 // 40.dp, which is the same as the M3 buttons. This applies even if onClick is
179 // null, to make it easier to write expandables that are sometimes clickable and
180 // sometimes not. There shouldn't be any Expandable smaller than 40dp because if
181 // the expandable is not clickable directly, then something in its content
182 // should be (and with a size >= 40dp).
183 val minSize = 40.dp
184 Box(
185 Modifier.defaultMinSize(minWidth = minSize, minHeight = minSize),
186 contentAlignment = Alignment.Center,
187 ) {
188 content(expandable)
189 }
190 }
191 }
192 }
193
194 var thisExpandableSize by remember { mutableStateOf(Size.Zero) }
195
196 /** Set the current element size as this Expandable size. */
197 fun Modifier.updateExpandableSize(): Modifier {
198 return this.onGloballyPositioned { coords ->
199 thisExpandableSize =
200 coords
201 .findRootCoordinates()
202 // Make sure that we report the actual size, and not the visual/clipped one.
203 .localBoundingBoxOf(coords, clipBounds = false)
204 .size
205 }
206 }
207
208 // Make sure we don't read animatorState directly here to avoid recomposition every time the
209 // state changes (i.e. every frame of the animation).
210 val isAnimating by remember {
211 derivedStateOf {
212 controller.animatorState.value != null && controller.overlay.value != null
213 }
214 }
215
216 // If this expandable is expanded when it's being directly clicked on, let's ensure that it has
217 // the minimum interactive size followed by all M3 components (48.dp).
218 val minInteractiveSizeModifier =
219 if (onClick != null) {
220 Modifier.minimumInteractiveComponentSize()
221 } else {
222 Modifier
223 }
224
225 when {
226 isAnimating -> {
227 // Don't compose the movable content during the animation, as it should be composed only
228 // once at all times. We make this spacer exactly the same size as this Expandable when
229 // it is visible.
230 Spacer(
231 modifier.requiredSize(with(controller.density) { thisExpandableSize.toDpSize() })
232 )
233
234 // The content and its animated background in the overlay. We draw it only when we are
235 // animating.
236 AnimatedContentInOverlay(
237 color,
238 controller.boundsInComposeViewRoot.value.size,
239 controller.animatorState,
240 controller.overlay.value
241 ?: error("AnimatedContentInOverlay shouldn't be composed with null overlay."),
242 controller,
243 wrappedContent,
244 controller.composeViewRoot,
245 { controller.currentComposeViewInOverlay.value = it },
246 controller.density,
247 )
248 }
249 controller.isDialogShowing.value -> {
250 Box(
251 modifier
252 .updateExpandableSize()
253 .then(minInteractiveSizeModifier)
254 .drawWithContent { /* Don't draw anything when the dialog is shown. */}
255 .onGloballyPositioned {
256 controller.boundsInComposeViewRoot.value = it.boundsInRoot()
257 }
258 ) {
259 wrappedContent(controller.expandable)
260 }
261 }
262 else -> {
263 val clickModifier =
264 if (onClick != null) {
265 if (interactionSource != null) {
266 // If the caller provided an interaction source, then that means that they
267 // will draw the click indication themselves.
268 Modifier.clickable(interactionSource, indication = null) {
269 onClick(controller.expandable)
270 }
271 } else {
272 // If no interaction source is provided, we draw the default indication (a
273 // ripple) and make sure it's clipped by the expandable shape.
274 Modifier.clip(shape).clickable { onClick(controller.expandable) }
275 }
276 } else {
277 Modifier
278 }
279
280 Box(
281 modifier
282 .updateExpandableSize()
283 .then(minInteractiveSizeModifier)
284 .then(clickModifier)
285 .background(color, shape)
286 .border(controller)
287 .onGloballyPositioned {
288 controller.boundsInComposeViewRoot.value = it.boundsInRoot()
289 },
290 ) {
291 wrappedContent(controller.expandable)
292 }
293 }
294 }
295 }
296
297 /** Draw [content] in [overlay] while respecting its screen position given by [animatorState]. */
298 @Composable
AnimatedContentInOverlaynull299 private fun AnimatedContentInOverlay(
300 color: Color,
301 sizeInOriginalLayout: Size,
302 animatorState: State<LaunchAnimator.State?>,
303 overlay: ViewGroupOverlay,
304 controller: ExpandableControllerImpl,
305 content: @Composable (Expandable) -> Unit,
306 composeViewRoot: View,
307 onOverlayComposeViewChanged: (View?) -> Unit,
308 density: Density,
309 ) {
310 val compositionContext = rememberCompositionContext()
311 val context = LocalContext.current
312
313 // Create the ComposeView and force its content composition so that the movableContent is
314 // composed exactly once when we start animating.
315 val composeViewInOverlay =
316 remember(context, density) {
317 val startWidth = sizeInOriginalLayout.width
318 val startHeight = sizeInOriginalLayout.height
319 val contentModifier =
320 Modifier
321 // Draw the content with the same size as it was at the start of the animation
322 // so that its content is laid out exactly the same way.
323 .requiredSize(with(density) { sizeInOriginalLayout.toDpSize() })
324 .drawWithContent {
325 val animatorState = animatorState.value ?: return@drawWithContent
326
327 // Scale the content with the background while keeping its aspect ratio.
328 val widthRatio =
329 if (startWidth != 0f) {
330 animatorState.width.toFloat() / startWidth
331 } else {
332 1f
333 }
334 val heightRatio =
335 if (startHeight != 0f) {
336 animatorState.height.toFloat() / startHeight
337 } else {
338 1f
339 }
340 val scale = min(widthRatio, heightRatio)
341 scale(scale) { this@drawWithContent.drawContent() }
342 }
343
344 val composeView =
345 ComposeView(context).apply {
346 setContent {
347 Box(
348 Modifier.fillMaxSize().drawWithContent {
349 val animatorState = animatorState.value ?: return@drawWithContent
350 if (!animatorState.visible) {
351 return@drawWithContent
352 }
353
354 drawBackground(animatorState, color, controller.borderStroke)
355 drawContent()
356 },
357 // We center the content in the expanding container.
358 contentAlignment = Alignment.Center,
359 ) {
360 Box(contentModifier) { content(controller.expandable) }
361 }
362 }
363 }
364
365 // Set the owners.
366 val overlayViewGroup =
367 getOverlayViewGroup(
368 context,
369 overlay,
370 )
371 ViewTreeLifecycleOwner.set(
372 overlayViewGroup,
373 ViewTreeLifecycleOwner.get(composeViewRoot),
374 )
375 ViewTreeViewModelStoreOwner.set(
376 overlayViewGroup,
377 ViewTreeViewModelStoreOwner.get(composeViewRoot),
378 )
379 ViewTreeSavedStateRegistryOwner.set(
380 overlayViewGroup,
381 ViewTreeSavedStateRegistryOwner.get(composeViewRoot),
382 )
383
384 composeView.setParentCompositionContext(compositionContext)
385
386 composeView
387 }
388
389 DisposableEffect(overlay, composeViewInOverlay) {
390 // Add the ComposeView to the overlay.
391 overlay.add(composeViewInOverlay)
392
393 val startState =
394 animatorState.value
395 ?: throw IllegalStateException(
396 "AnimatedContentInOverlay shouldn't be composed with null animatorState."
397 )
398 measureAndLayoutComposeViewInOverlay(composeViewInOverlay, startState)
399 onOverlayComposeViewChanged(composeViewInOverlay)
400
401 onDispose {
402 composeViewInOverlay.disposeComposition()
403 overlay.remove(composeViewInOverlay)
404 onOverlayComposeViewChanged(null)
405 }
406 }
407 }
408
measureAndLayoutComposeViewInOverlaynull409 internal fun measureAndLayoutComposeViewInOverlay(
410 view: View,
411 state: LaunchAnimator.State,
412 ) {
413 val exactWidth = state.width
414 val exactHeight = state.height
415 view.measure(
416 View.MeasureSpec.makeSafeMeasureSpec(exactWidth, View.MeasureSpec.EXACTLY),
417 View.MeasureSpec.makeSafeMeasureSpec(exactHeight, View.MeasureSpec.EXACTLY),
418 )
419
420 val parent = view.parent as ViewGroup
421 val parentLocation = parent.locationOnScreen
422 val offsetX = parentLocation[0]
423 val offsetY = parentLocation[1]
424 view.layout(
425 state.left - offsetX,
426 state.top - offsetY,
427 state.right - offsetX,
428 state.bottom - offsetY,
429 )
430 }
431
432 // TODO(b/230830644): Add hidden API to ViewGroupOverlay to access this ViewGroup directly?
getOverlayViewGroupnull433 private fun getOverlayViewGroup(context: Context, overlay: ViewGroupOverlay): ViewGroup {
434 val view = View(context)
435 overlay.add(view)
436 var current = view.parent
437 while (current.parent != null) {
438 current = current.parent
439 }
440 overlay.remove(view)
441 return current as ViewGroup
442 }
443
bordernull444 private fun Modifier.border(controller: ExpandableControllerImpl): Modifier {
445 return if (controller.borderStroke != null) {
446 this.border(controller.borderStroke, controller.shape)
447 } else {
448 this
449 }
450 }
451
drawBackgroundnull452 private fun ContentDrawScope.drawBackground(
453 animatorState: LaunchAnimator.State,
454 color: Color,
455 border: BorderStroke?,
456 ) {
457 val topRadius = animatorState.topCornerRadius
458 val bottomRadius = animatorState.bottomCornerRadius
459 if (topRadius == bottomRadius) {
460 // Shortcut to avoid Outline calculation and allocation.
461 val cornerRadius = CornerRadius(topRadius)
462
463 // Draw the background.
464 drawRoundRect(color, cornerRadius = cornerRadius)
465
466 // Draw the border.
467 if (border != null) {
468 // Copied from androidx.compose.foundation.Border.kt
469 val strokeWidth = border.width.toPx()
470 val halfStroke = strokeWidth / 2
471 val borderStroke = Stroke(strokeWidth)
472
473 drawRoundRect(
474 brush = border.brush,
475 topLeft = Offset(halfStroke, halfStroke),
476 size = Size(size.width - strokeWidth, size.height - strokeWidth),
477 cornerRadius = cornerRadius.shrink(halfStroke),
478 style = borderStroke
479 )
480 }
481 } else {
482 val shape =
483 RoundedCornerShape(
484 topStart = topRadius,
485 topEnd = topRadius,
486 bottomStart = bottomRadius,
487 bottomEnd = bottomRadius,
488 )
489 val outline = shape.createOutline(size, layoutDirection, this)
490
491 // Draw the background.
492 drawOutline(outline, color = color)
493
494 // Draw the border.
495 if (border != null) {
496 // Copied from androidx.compose.foundation.Border.kt.
497 val strokeWidth = border.width.toPx()
498 val path =
499 createRoundRectPath(
500 (outline as Outline.Rounded).roundRect,
501 strokeWidth,
502 )
503
504 drawPath(path, border.brush)
505 }
506 }
507 }
508
509 /**
510 * Helper method that creates a round rect with the inner region removed by the given stroke width.
511 *
512 * Copied from androidx.compose.foundation.Border.kt.
513 */
createRoundRectPathnull514 private fun createRoundRectPath(
515 roundedRect: RoundRect,
516 strokeWidth: Float,
517 ): Path {
518 return Path().apply {
519 addRoundRect(roundedRect)
520 val insetPath =
521 Path().apply { addRoundRect(createInsetRoundedRect(strokeWidth, roundedRect)) }
522 op(this, insetPath, PathOperation.Difference)
523 }
524 }
525
526 /* Copied from androidx.compose.foundation.Border.kt. */
createInsetRoundedRectnull527 private fun createInsetRoundedRect(widthPx: Float, roundedRect: RoundRect) =
528 RoundRect(
529 left = widthPx,
530 top = widthPx,
531 right = roundedRect.width - widthPx,
532 bottom = roundedRect.height - widthPx,
533 topLeftCornerRadius = roundedRect.topLeftCornerRadius.shrink(widthPx),
534 topRightCornerRadius = roundedRect.topRightCornerRadius.shrink(widthPx),
535 bottomLeftCornerRadius = roundedRect.bottomLeftCornerRadius.shrink(widthPx),
536 bottomRightCornerRadius = roundedRect.bottomRightCornerRadius.shrink(widthPx)
537 )
538
539 /**
540 * Helper method to shrink the corner radius by the given value, clamping to 0 if the resultant
541 * corner radius would be negative.
542 *
543 * Copied from androidx.compose.foundation.Border.kt.
544 */
545 private fun CornerRadius.shrink(value: Float): CornerRadius =
546 CornerRadius(max(0f, this.x - value), max(0f, this.y - value))
547