Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 7ba693e9 authored by Jordan Demeulenaere's avatar Jordan Demeulenaere
Browse files

Support bounceables wrapping their content

Bug: 371173476
Test: atest BounceableTest
Flag: EXEMPT new parameter not used in feature code yet
Change-Id: I3d6bbcbe037f2aa92f124fcbe619d5e0c453823b
parent c582e387
Loading
Loading
Loading
Loading
+149 −43
Original line number Diff line number Diff line
@@ -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. */
@@ -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(
@@ -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(
@@ -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) {
@@ -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)
@@ -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)
@@ -151,7 +197,6 @@ private class BounceableNode(
        }
    }
}
}

private fun checkFixedSize(constraints: Constraints, orientation: Orientation) {
    when (orientation) {
@@ -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,
        )
    }
}
+65 −1
Original line number Diff line number Diff line
@@ -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
@@ -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))
    }