diff options
| -rw-r--r-- | packages/SystemUI/compose/core/src/com/android/compose/animation/Bounceable.kt | 123 | ||||
| -rw-r--r-- | packages/SystemUI/compose/core/tests/src/com/android/compose/animation/BounceableTest.kt | 212 |
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" +} |