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.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.LaunchAnimator 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.activityLaunchController()) 101 * }, 102 * 103 * // For dialogs: 104 * onClick = { expandable -> 105 * dialogLaunchAnimator.show(dialog, controller.dialogLaunchController()) 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 161 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 301 private fun AnimatedContentInOverlay( 302 color: Color, 303 sizeInOriginalLayout: Size, 304 animatorState: State<LaunchAnimator.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 408 internal fun measureAndLayoutComposeViewInOverlay( 409 view: View, 410 state: LaunchAnimator.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? 432 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 443 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 451 private fun ContentDrawScope.drawBackground( 452 animatorState: LaunchAnimator.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 */ 513 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. */ 526 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