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

Commit 1ed59654 authored by Jordan Demeulenaere's avatar Jordan Demeulenaere
Browse files

Introduce Bounceable and Modifier.bounceable()

This CL introduces a new Modifier to make it easy to implement buttons
that can bounce, affecting the size of its neighbours. This will be used
initially by QuickSettings tiles and Magic Portrait effects.

See b/371173476#comment2 for a video and details about the
implementation.

Bug: 371173476
Test: atest BounceableTest
Flag: EXEMPT library code not used in feature code yet
Change-Id: Id376b49f05b400c3c492c26f1014ac31c1236343
parent d42e0182
Loading
Loading
Loading
Loading
+123 −0
Original line number Diff line number Diff line
/*
 * 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())."
            }
        }
    }
}
+212 −0
Original line number Diff line number Diff line
/*
 * 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"
}