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