Loading packages/SystemUI/compose/core/src/com/android/compose/animation/Bounceable.kt +149 −43 Original line number Diff line number Diff line Loading @@ -19,14 +19,17 @@ package com.android.compose.animation import androidx.compose.foundation.gestures.Orientation import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ApproachLayoutModifierNode import androidx.compose.ui.layout.ApproachMeasureScope import androidx.compose.ui.layout.Measurable import androidx.compose.ui.layout.MeasureResult import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.layout.layout import androidx.compose.ui.node.LayoutModifierNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import kotlin.math.roundToInt /** A component that can bounce in one dimension, for instance when it is tapped. */ Loading @@ -52,6 +55,10 @@ interface Bounceable { * @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. * @param isWrappingContent whether this bounceable size is determined by the size of its children. * This should preferably be `false` whenever possible, so that this modifier plays nicely with * lookahead animations (e.g. SceneTransitionLayout animations), but is sometimes necessary when * the size of a bounceable strictly depends on the size of its content. */ @Stable fun Modifier.bounceable( Loading @@ -60,9 +67,27 @@ fun Modifier.bounceable( nextBounceable: Bounceable?, orientation: Orientation, bounceEnd: Boolean = nextBounceable != null, isWrappingContent: Boolean = false, ): Modifier { return this then BounceableElement(bounceable, previousBounceable, nextBounceable, orientation, bounceEnd) return if (isWrappingContent) { this then WrappingBounceableElement( bounceable, previousBounceable, nextBounceable, orientation, bounceEnd, ) } else { this then BounceableElement( bounceable, previousBounceable, nextBounceable, orientation, bounceEnd, ) } } private data class BounceableElement( Loading Loading @@ -105,16 +130,36 @@ private class BounceableNode( // 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) return measure( constraints = constraints, measurable = measurable, bounceable = bounceable, previousBounceable = previousBounceable, nextBounceable = nextBounceable, orientation = orientation, bounceEnd = bounceEnd, idleSize = IntSize(constraints.maxWidth, constraints.maxHeight), ) } } private fun MeasureScope.measure( constraints: Constraints, measurable: Measurable, bounceable: Bounceable, previousBounceable: Bounceable?, nextBounceable: Bounceable?, orientation: Orientation, bounceEnd: Boolean, idleSize: IntSize, ): MeasureResult { var sizePrevious = 0f var sizeNext = 0f val previousBounceable = previousBounceable if (previousBounceable != null) { sizePrevious += bounceable.bounce.toPx() - previousBounceable.bounce.toPx() } val nextBounceable = nextBounceable if (nextBounceable != null) { sizeNext += bounceable.bounce.toPx() - nextBounceable.bounce.toPx() } else if (bounceEnd) { Loading @@ -123,7 +168,7 @@ private class BounceableNode( when (orientation) { Orientation.Horizontal -> { val idleWidth = constraints.maxWidth val idleWidth = idleSize.width val animatedWidth = (idleWidth + sizePrevious + sizeNext).roundToInt() val animatedConstraints = constraints.copy(minWidth = animatedWidth, maxWidth = animatedWidth) Loading @@ -131,15 +176,16 @@ private class BounceableNode( 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. // 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. return layout(idleWidth, placeable.height) { placeable.placeRelative(-sizePrevious.roundToInt(), 0) } } Orientation.Vertical -> { val idleHeight = constraints.maxHeight val idleHeight = idleSize.height val animatedHeight = (idleHeight + sizePrevious + sizeNext).roundToInt() val animatedConstraints = constraints.copy(minHeight = animatedHeight, maxHeight = animatedHeight) Loading @@ -151,7 +197,6 @@ private class BounceableNode( } } } } private fun checkFixedSize(constraints: Constraints, orientation: Orientation) { when (orientation) { Loading @@ -159,15 +204,76 @@ private fun checkFixedSize(constraints: Constraints, orientation: Orientation) { 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())." " Modifier.fillMaxWidth() or Modifier.width()). If doing so is impossible" + " and the bounceable has to wrap its content, set isWrappingContent to `true`." } } 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())." " Modifier.fillMaxHeight() or Modifier.height()). If doing so is impossible " + "and the bounceable has to wrap its content, set isWrappingContent to `true`." } } } } private data class WrappingBounceableElement( private val bounceable: Bounceable, private val previousBounceable: Bounceable?, private val nextBounceable: Bounceable?, private val orientation: Orientation, private val bounceEnd: Boolean, ) : ModifierNodeElement<WrappingBounceableNode>() { override fun create(): WrappingBounceableNode { return WrappingBounceableNode( bounceable, previousBounceable, nextBounceable, orientation, bounceEnd, ) } override fun update(node: WrappingBounceableNode) { node.bounceable = bounceable node.previousBounceable = previousBounceable node.nextBounceable = nextBounceable node.orientation = orientation node.bounceEnd = bounceEnd } } private class WrappingBounceableNode( var bounceable: Bounceable, var previousBounceable: Bounceable?, var nextBounceable: Bounceable?, var orientation: Orientation, var bounceEnd: Boolean = nextBounceable != null, ) : Modifier.Node(), ApproachLayoutModifierNode { override fun isMeasurementApproachInProgress(lookaheadSize: IntSize): Boolean { fun Bounceable?.isBouncing() = this != null && this.bounce != 0.dp return bounceable.isBouncing() || previousBounceable.isBouncing() || nextBounceable.isBouncing() } override fun ApproachMeasureScope.approachMeasure( measurable: Measurable, constraints: Constraints, ): MeasureResult { return measure( constraints = constraints, measurable = measurable, bounceable = bounceable, previousBounceable = previousBounceable, nextBounceable = nextBounceable, orientation = orientation, bounceEnd = bounceEnd, idleSize = lookaheadSize, ) } } packages/SystemUI/compose/core/tests/src/com/android/compose/animation/BounceableTest.kt +65 −1 Original line number Diff line number Diff line Loading @@ -191,6 +191,63 @@ class BounceableTest { .assertPositionInRootIsEqualTo(0.dp, 85.dp) } @Test fun wrapContent() { // Bounce the first and third bounceables. val bounceables = listOf( bounceable(bounce = 5.dp), bounceable(bounce = 0.dp), bounceable(bounce = 10.dp), bounceable(bounce = 0.dp), ) rule.setContent { Column(Modifier) { repeat(bounceables.size) { i -> Box( // All bounceables are 50dp x 25dp when idle. Modifier.bounceable( bounceables, i, Orientation.Vertical, isWrappingContent = true, ) .size(50.dp, 25.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 Loading @@ -201,10 +258,17 @@ class BounceableTest { bounceables: List<Bounceable>, i: Int, orientation: Orientation, isWrappingContent: Boolean = false, ): 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) return this.bounceable( bounceables[i], previous, next, orientation, isWrappingContent = isWrappingContent, ) .testTag(bounceableTag(i)) } Loading Loading
packages/SystemUI/compose/core/src/com/android/compose/animation/Bounceable.kt +149 −43 Original line number Diff line number Diff line Loading @@ -19,14 +19,17 @@ package com.android.compose.animation import androidx.compose.foundation.gestures.Orientation import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ApproachLayoutModifierNode import androidx.compose.ui.layout.ApproachMeasureScope import androidx.compose.ui.layout.Measurable import androidx.compose.ui.layout.MeasureResult import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.layout.layout import androidx.compose.ui.node.LayoutModifierNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import kotlin.math.roundToInt /** A component that can bounce in one dimension, for instance when it is tapped. */ Loading @@ -52,6 +55,10 @@ interface Bounceable { * @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. * @param isWrappingContent whether this bounceable size is determined by the size of its children. * This should preferably be `false` whenever possible, so that this modifier plays nicely with * lookahead animations (e.g. SceneTransitionLayout animations), but is sometimes necessary when * the size of a bounceable strictly depends on the size of its content. */ @Stable fun Modifier.bounceable( Loading @@ -60,9 +67,27 @@ fun Modifier.bounceable( nextBounceable: Bounceable?, orientation: Orientation, bounceEnd: Boolean = nextBounceable != null, isWrappingContent: Boolean = false, ): Modifier { return this then BounceableElement(bounceable, previousBounceable, nextBounceable, orientation, bounceEnd) return if (isWrappingContent) { this then WrappingBounceableElement( bounceable, previousBounceable, nextBounceable, orientation, bounceEnd, ) } else { this then BounceableElement( bounceable, previousBounceable, nextBounceable, orientation, bounceEnd, ) } } private data class BounceableElement( Loading Loading @@ -105,16 +130,36 @@ private class BounceableNode( // 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) return measure( constraints = constraints, measurable = measurable, bounceable = bounceable, previousBounceable = previousBounceable, nextBounceable = nextBounceable, orientation = orientation, bounceEnd = bounceEnd, idleSize = IntSize(constraints.maxWidth, constraints.maxHeight), ) } } private fun MeasureScope.measure( constraints: Constraints, measurable: Measurable, bounceable: Bounceable, previousBounceable: Bounceable?, nextBounceable: Bounceable?, orientation: Orientation, bounceEnd: Boolean, idleSize: IntSize, ): MeasureResult { var sizePrevious = 0f var sizeNext = 0f val previousBounceable = previousBounceable if (previousBounceable != null) { sizePrevious += bounceable.bounce.toPx() - previousBounceable.bounce.toPx() } val nextBounceable = nextBounceable if (nextBounceable != null) { sizeNext += bounceable.bounce.toPx() - nextBounceable.bounce.toPx() } else if (bounceEnd) { Loading @@ -123,7 +168,7 @@ private class BounceableNode( when (orientation) { Orientation.Horizontal -> { val idleWidth = constraints.maxWidth val idleWidth = idleSize.width val animatedWidth = (idleWidth + sizePrevious + sizeNext).roundToInt() val animatedConstraints = constraints.copy(minWidth = animatedWidth, maxWidth = animatedWidth) Loading @@ -131,15 +176,16 @@ private class BounceableNode( 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. // 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. return layout(idleWidth, placeable.height) { placeable.placeRelative(-sizePrevious.roundToInt(), 0) } } Orientation.Vertical -> { val idleHeight = constraints.maxHeight val idleHeight = idleSize.height val animatedHeight = (idleHeight + sizePrevious + sizeNext).roundToInt() val animatedConstraints = constraints.copy(minHeight = animatedHeight, maxHeight = animatedHeight) Loading @@ -151,7 +197,6 @@ private class BounceableNode( } } } } private fun checkFixedSize(constraints: Constraints, orientation: Orientation) { when (orientation) { Loading @@ -159,15 +204,76 @@ private fun checkFixedSize(constraints: Constraints, orientation: Orientation) { 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())." " Modifier.fillMaxWidth() or Modifier.width()). If doing so is impossible" + " and the bounceable has to wrap its content, set isWrappingContent to `true`." } } 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())." " Modifier.fillMaxHeight() or Modifier.height()). If doing so is impossible " + "and the bounceable has to wrap its content, set isWrappingContent to `true`." } } } } private data class WrappingBounceableElement( private val bounceable: Bounceable, private val previousBounceable: Bounceable?, private val nextBounceable: Bounceable?, private val orientation: Orientation, private val bounceEnd: Boolean, ) : ModifierNodeElement<WrappingBounceableNode>() { override fun create(): WrappingBounceableNode { return WrappingBounceableNode( bounceable, previousBounceable, nextBounceable, orientation, bounceEnd, ) } override fun update(node: WrappingBounceableNode) { node.bounceable = bounceable node.previousBounceable = previousBounceable node.nextBounceable = nextBounceable node.orientation = orientation node.bounceEnd = bounceEnd } } private class WrappingBounceableNode( var bounceable: Bounceable, var previousBounceable: Bounceable?, var nextBounceable: Bounceable?, var orientation: Orientation, var bounceEnd: Boolean = nextBounceable != null, ) : Modifier.Node(), ApproachLayoutModifierNode { override fun isMeasurementApproachInProgress(lookaheadSize: IntSize): Boolean { fun Bounceable?.isBouncing() = this != null && this.bounce != 0.dp return bounceable.isBouncing() || previousBounceable.isBouncing() || nextBounceable.isBouncing() } override fun ApproachMeasureScope.approachMeasure( measurable: Measurable, constraints: Constraints, ): MeasureResult { return measure( constraints = constraints, measurable = measurable, bounceable = bounceable, previousBounceable = previousBounceable, nextBounceable = nextBounceable, orientation = orientation, bounceEnd = bounceEnd, idleSize = lookaheadSize, ) } }
packages/SystemUI/compose/core/tests/src/com/android/compose/animation/BounceableTest.kt +65 −1 Original line number Diff line number Diff line Loading @@ -191,6 +191,63 @@ class BounceableTest { .assertPositionInRootIsEqualTo(0.dp, 85.dp) } @Test fun wrapContent() { // Bounce the first and third bounceables. val bounceables = listOf( bounceable(bounce = 5.dp), bounceable(bounce = 0.dp), bounceable(bounce = 10.dp), bounceable(bounce = 0.dp), ) rule.setContent { Column(Modifier) { repeat(bounceables.size) { i -> Box( // All bounceables are 50dp x 25dp when idle. Modifier.bounceable( bounceables, i, Orientation.Vertical, isWrappingContent = true, ) .size(50.dp, 25.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 Loading @@ -201,10 +258,17 @@ class BounceableTest { bounceables: List<Bounceable>, i: Int, orientation: Orientation, isWrappingContent: Boolean = false, ): 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) return this.bounceable( bounceables[i], previous, next, orientation, isWrappingContent = isWrappingContent, ) .testTag(bounceableTag(i)) } Loading