• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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