summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/SystemUI/compose/core/src/com/android/compose/animation/Bounceable.kt123
-rw-r--r--packages/SystemUI/compose/core/tests/src/com/android/compose/animation/BounceableTest.kt212
2 files changed, 335 insertions, 0 deletions
diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/Bounceable.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/Bounceable.kt
new file mode 100644
index 000000000000..3f2f84b95977
--- /dev/null
+++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/Bounceable.kt
@@ -0,0 +1,123 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.compose.animation
+
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Dp
+import kotlin.math.roundToInt
+
+/** A component that can bounce in one dimension, for instance when it is tapped. */
+interface Bounceable {
+ val bounce: Dp
+}
+
+/**
+ * Bounce a composable in the given [orientation] when this [bounceable], the [previousBounceable]
+ * or [nextBounceable] is bouncing.
+ *
+ * Important: This modifier should be used on composables that have a fixed size in [orientation],
+ * i.e. they should be placed *after* modifiers like Modifier.fillMaxWidth() or Modifier.height().
+ *
+ * @param bounceable the [Bounceable] associated to the current composable that will make this
+ * composable size grow when bouncing.
+ * @param previousBounceable the [Bounceable] associated to the previous composable in [orientation]
+ * that will make this composable shrink when bouncing.
+ * @param nextBounceable the [Bounceable] associated to the next composable in [orientation] that
+ * will make this composable shrink when bouncing.
+ * @param orientation the orientation in which this bounceable should grow/shrink.
+ * @param bounceEnd whether this bounceable should bounce on the end (right in LTR layouts, left in
+ * RTL layouts) side. This can be used for grids for which the last item does not align perfectly
+ * with the end of the grid.
+ */
+fun Modifier.bounceable(
+ bounceable: Bounceable,
+ previousBounceable: Bounceable?,
+ nextBounceable: Bounceable?,
+ orientation: Orientation,
+ bounceEnd: Boolean = nextBounceable != null,
+): Modifier {
+ return layout { measurable, constraints ->
+ // The constraints in the orientation should be fixed, otherwise there is no way to know
+ // what the size of our child node will be without this animation code.
+ checkFixedSize(constraints, orientation)
+
+ var sizePrevious = 0f
+ var sizeNext = 0f
+
+ if (previousBounceable != null) {
+ sizePrevious += bounceable.bounce.toPx() - previousBounceable.bounce.toPx()
+ }
+
+ if (nextBounceable != null) {
+ sizeNext += bounceable.bounce.toPx() - nextBounceable.bounce.toPx()
+ } else if (bounceEnd) {
+ sizeNext += bounceable.bounce.toPx()
+ }
+
+ when (orientation) {
+ Orientation.Horizontal -> {
+ val idleWidth = constraints.maxWidth
+ val animatedWidth = (idleWidth + sizePrevious + sizeNext).roundToInt()
+ val animatedConstraints =
+ constraints.copy(minWidth = animatedWidth, maxWidth = animatedWidth)
+
+ val placeable = measurable.measure(animatedConstraints)
+
+ // Important: we still place the element using the idle size coming from the
+ // constraints, otherwise the parent will automatically center this node given the
+ // size that it expects us to be. This allows us to then place the element where we
+ // want it to be.
+ layout(idleWidth, placeable.height) {
+ placeable.placeRelative(-sizePrevious.roundToInt(), 0)
+ }
+ }
+ Orientation.Vertical -> {
+ val idleHeight = constraints.maxHeight
+ val animatedHeight = (idleHeight + sizePrevious + sizeNext).roundToInt()
+ val animatedConstraints =
+ constraints.copy(minHeight = animatedHeight, maxHeight = animatedHeight)
+
+ val placeable = measurable.measure(animatedConstraints)
+ layout(placeable.width, idleHeight) {
+ placeable.placeRelative(0, -sizePrevious.roundToInt())
+ }
+ }
+ }
+ }
+}
+
+private fun checkFixedSize(constraints: Constraints, orientation: Orientation) {
+ when (orientation) {
+ Orientation.Horizontal -> {
+ check(constraints.hasFixedWidth) {
+ "Modifier.bounceable() should receive a fixed width from its parent. Make sure " +
+ "that it is used *after* a fixed-width Modifier in the horizontal axis (like" +
+ " Modifier.fillMaxWidth() or Modifier.width())."
+ }
+ }
+ Orientation.Vertical -> {
+ check(constraints.hasFixedHeight) {
+ "Modifier.bounceable() should receive a fixed height from its parent. Make sure " +
+ "that it is used *after* a fixed-height Modifier in the vertical axis (like" +
+ " Modifier.fillMaxHeight() or Modifier.height())."
+ }
+ }
+ }
+}
diff --git a/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/BounceableTest.kt b/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/BounceableTest.kt
new file mode 100644
index 000000000000..335e9f8872a6
--- /dev/null
+++ b/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/BounceableTest.kt
@@ -0,0 +1,212 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.compose.animation
+
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertHeightIsEqualTo
+import androidx.compose.ui.test.assertPositionInRootIsEqualTo
+import androidx.compose.ui.test.assertWidthIsEqualTo
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.times
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class BounceableTest {
+ @get:Rule val rule = createComposeRule()
+
+ @Test
+ fun bounceable_horizontal() {
+ var bounceables by mutableStateOf(List(4) { bounceable(0.dp) })
+
+ rule.setContent {
+ Row(Modifier.size(100.dp, 50.dp)) {
+ repeat(bounceables.size) { i ->
+ Box(
+ Modifier.weight(1f)
+ .fillMaxHeight()
+ .bounceable(bounceables, i, orientation = Orientation.Horizontal)
+ )
+ }
+ }
+ }
+
+ // All bounceables have a width of (100dp / bounceables.size) = 25dp and height of 50dp.
+ repeat(bounceables.size) { i ->
+ rule
+ .onNodeWithTag(bounceableTag(i))
+ .assertWidthIsEqualTo(25.dp)
+ .assertHeightIsEqualTo(50.dp)
+ .assertPositionInRootIsEqualTo(i * 25.dp, 0.dp)
+ }
+
+ // If all bounceables have the same bounce, it's the same as if they didn't have any.
+ bounceables = List(4) { bounceable(10.dp) }
+ repeat(bounceables.size) { i ->
+ rule
+ .onNodeWithTag(bounceableTag(i))
+ .assertWidthIsEqualTo(25.dp)
+ .assertHeightIsEqualTo(50.dp)
+ .assertPositionInRootIsEqualTo(i * 25.dp, 0.dp)
+ }
+
+ // Bounce the first and third one.
+ bounceables =
+ listOf(
+ bounceable(bounce = 5.dp),
+ bounceable(bounce = 0.dp),
+ bounceable(bounce = 10.dp),
+ bounceable(bounce = 0.dp),
+ )
+
+ // First one has a width of 25dp + 5dp, located in (0, 0).
+ rule
+ .onNodeWithTag(bounceableTag(0))
+ .assertWidthIsEqualTo(30.dp)
+ .assertHeightIsEqualTo(50.dp)
+ .assertPositionInRootIsEqualTo(0.dp, 0.dp)
+
+ // Second one has a width of 25dp - 5dp - 10dp, located in (30, 0).
+ rule
+ .onNodeWithTag(bounceableTag(1))
+ .assertWidthIsEqualTo(10.dp)
+ .assertHeightIsEqualTo(50.dp)
+ .assertPositionInRootIsEqualTo(30.dp, 0.dp)
+
+ // Third one has a width of 25 + 2 * 10dp, located in (40, 0).
+ rule
+ .onNodeWithTag(bounceableTag(2))
+ .assertWidthIsEqualTo(45.dp)
+ .assertHeightIsEqualTo(50.dp)
+ .assertPositionInRootIsEqualTo(40.dp, 0.dp)
+
+ // First one has a width of 25dp - 10dp, located in (85, 0).
+ rule
+ .onNodeWithTag(bounceableTag(3))
+ .assertWidthIsEqualTo(15.dp)
+ .assertHeightIsEqualTo(50.dp)
+ .assertPositionInRootIsEqualTo(85.dp, 0.dp)
+ }
+
+ @Test
+ fun bounceable_vertical() {
+ var bounceables by mutableStateOf(List(4) { bounceable(0.dp) })
+
+ rule.setContent {
+ Column(Modifier.size(50.dp, 100.dp)) {
+ repeat(bounceables.size) { i ->
+ Box(
+ Modifier.weight(1f)
+ .fillMaxWidth()
+ .bounceable(bounceables, i, Orientation.Vertical)
+ )
+ }
+ }
+ }
+
+ // All bounceables have a height of (100dp / bounceables.size) = 25dp and width of 50dp.
+ repeat(bounceables.size) { i ->
+ rule
+ .onNodeWithTag(bounceableTag(i))
+ .assertWidthIsEqualTo(50.dp)
+ .assertHeightIsEqualTo(25.dp)
+ .assertPositionInRootIsEqualTo(0.dp, i * 25.dp)
+ }
+
+ // If all bounceables have the same bounce, it's the same as if they didn't have any.
+ bounceables = List(4) { bounceable(10.dp) }
+ repeat(bounceables.size) { i ->
+ rule
+ .onNodeWithTag(bounceableTag(i))
+ .assertWidthIsEqualTo(50.dp)
+ .assertHeightIsEqualTo(25.dp)
+ .assertPositionInRootIsEqualTo(0.dp, i * 25.dp)
+ }
+
+ // Bounce the first and third one.
+ bounceables =
+ listOf(
+ bounceable(bounce = 5.dp),
+ bounceable(bounce = 0.dp),
+ bounceable(bounce = 10.dp),
+ bounceable(bounce = 0.dp),
+ )
+
+ // First one has a height of 25dp + 5dp, located in (0, 0).
+ rule
+ .onNodeWithTag(bounceableTag(0))
+ .assertWidthIsEqualTo(50.dp)
+ .assertHeightIsEqualTo(30.dp)
+ .assertPositionInRootIsEqualTo(0.dp, 0.dp)
+
+ // Second one has a height of 25dp - 5dp - 10dp, located in (0, 30).
+ rule
+ .onNodeWithTag(bounceableTag(1))
+ .assertWidthIsEqualTo(50.dp)
+ .assertHeightIsEqualTo(10.dp)
+ .assertPositionInRootIsEqualTo(0.dp, 30.dp)
+
+ // Third one has a height of 25 + 2 * 10dp, located in (0, 40).
+ rule
+ .onNodeWithTag(bounceableTag(2))
+ .assertWidthIsEqualTo(50.dp)
+ .assertHeightIsEqualTo(45.dp)
+ .assertPositionInRootIsEqualTo(0.dp, 40.dp)
+
+ // First one has a height of 25dp - 10dp, located in (0, 85).
+ rule
+ .onNodeWithTag(bounceableTag(3))
+ .assertWidthIsEqualTo(50.dp)
+ .assertHeightIsEqualTo(15.dp)
+ .assertPositionInRootIsEqualTo(0.dp, 85.dp)
+ }
+
+ private fun bounceable(bounce: Dp): Bounceable {
+ return object : Bounceable {
+ override val bounce: Dp = bounce
+ }
+ }
+
+ private fun Modifier.bounceable(
+ bounceables: List<Bounceable>,
+ i: Int,
+ orientation: Orientation,
+ ): Modifier {
+ val previous = if (i > 0) bounceables[i - 1] else null
+ val next = if (i < bounceables.lastIndex) bounceables[i + 1] else null
+ return this.bounceable(bounceables[i], previous, next, orientation)
+ .testTag(bounceableTag(i))
+ }
+
+ private fun bounceableTag(i: Int) = "bounceable$i"
+}