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