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

Commit a84bd3ea authored by Jordan Demeulenaere's avatar Jordan Demeulenaere Committed by Android (Google) Code Review
Browse files

Merge "Support bounceables wrapping their content" into main

parents 2b303a8e 7ba693e9
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))
    }