[go: nahoru, domu]

Add support for staggered

Added `staggered` property in TransitionScope.

Additionally, from MotionSceneScope, users may define custom
`staggeredWeight` on a per-widget basis.

Relnote: "Staggered now supported in the MotionLayout DSL, define the
maximum delay with `TransitionScope.staggered`, you may also use
`ConstrainScope.staggeredWeight` (within a MotionSceneScope) to get a
custom staggered order."

Bug: n/a
Test: testStaggeredAndCustomWeights
Change-Id: I70275855b29cbb7bdcfe85f27d6c4f4f8178bbd1
diff --git a/constraintlayout/constraintlayout-compose/api/public_plus_experimental_current.txt b/constraintlayout/constraintlayout-compose/api/public_plus_experimental_current.txt
index f2b3795..e62d1bf 100644
--- a/constraintlayout/constraintlayout-compose/api/public_plus_experimental_current.txt
+++ b/constraintlayout/constraintlayout-compose/api/public_plus_experimental_current.txt
@@ -715,6 +715,8 @@
     method public void customInt(androidx.constraintlayout.compose.ConstrainScope, String name, int value);
     method public void customInt(androidx.constraintlayout.compose.KeyAttributeScope, String name, int value);
     method public void defaultTransition(androidx.constraintlayout.compose.ConstraintSetRef from, androidx.constraintlayout.compose.ConstraintSetRef to, optional kotlin.jvm.functions.Function1<? super androidx.constraintlayout.compose.TransitionScope,kotlin.Unit> transitionContent);
+    method public float getStaggeredWeight(androidx.constraintlayout.compose.ConstrainScope);
+    method public void setStaggeredWeight(androidx.constraintlayout.compose.ConstrainScope, float);
     method public void transition(androidx.constraintlayout.compose.ConstraintSetRef from, androidx.constraintlayout.compose.ConstraintSetRef to, optional String? name, kotlin.jvm.functions.Function1<? super androidx.constraintlayout.compose.TransitionScope,kotlin.Unit> transitionContent);
   }
 
@@ -921,13 +923,16 @@
     method public androidx.constraintlayout.compose.ConstrainedLayoutReference createRefFor(Object id);
     method public androidx.constraintlayout.compose.Arc getMotionArc();
     method public androidx.constraintlayout.compose.OnSwipe? getOnSwipe();
+    method public float getStaggered();
     method public void keyAttributes(androidx.constraintlayout.compose.ConstrainedLayoutReference![] targets, kotlin.jvm.functions.Function1<? super androidx.constraintlayout.compose.KeyAttributesScope,kotlin.Unit> keyAttributesContent);
     method public void keyCycles(androidx.constraintlayout.compose.ConstrainedLayoutReference![] targets, kotlin.jvm.functions.Function1<? super androidx.constraintlayout.compose.KeyCyclesScope,kotlin.Unit> keyCyclesContent);
     method public void keyPositions(androidx.constraintlayout.compose.ConstrainedLayoutReference![] targets, kotlin.jvm.functions.Function1<? super androidx.constraintlayout.compose.KeyPositionsScope,kotlin.Unit> keyPositionsContent);
     method public void setMotionArc(androidx.constraintlayout.compose.Arc);
     method public void setOnSwipe(androidx.constraintlayout.compose.OnSwipe?);
+    method public void setStaggered(float);
     property public final androidx.constraintlayout.compose.Arc motionArc;
     property public final androidx.constraintlayout.compose.OnSwipe? onSwipe;
+    property public final float staggered;
   }
 
   public final class TransitionScopeKt {
diff --git a/constraintlayout/constraintlayout-compose/integration-tests/demos/src/main/java/androidx/constraintlayout/compose/demos/AllDemos.kt b/constraintlayout/constraintlayout-compose/integration-tests/demos/src/main/java/androidx/constraintlayout/compose/demos/AllDemos.kt
index f03ec69..4121bed 100644
--- a/constraintlayout/constraintlayout-compose/integration-tests/demos/src/main/java/androidx/constraintlayout/compose/demos/AllDemos.kt
+++ b/constraintlayout/constraintlayout-compose/integration-tests/demos/src/main/java/androidx/constraintlayout/compose/demos/AllDemos.kt
@@ -61,7 +61,8 @@
         ComposeDemo("MotionLayout in LazyList") { MotionInLazyColumnDslDemo() },
         ComposeDemo("Animated Graphs") { AnimateGraphsOnRevealDemo() },
         ComposeDemo("Animated Reactions Selector") { ReactionSelectorDemo() },
-        ComposeDemo("Animated Puzzle Pieces") { AnimatedPuzzlePiecesDemo() }
+        ComposeDemo("Animated Puzzle Pieces") { AnimatedPuzzlePiecesDemo() },
+        ComposeDemo("Simple Staggered") { SimpleStaggeredDemo() }
     )
 
 /**
diff --git a/constraintlayout/constraintlayout-compose/integration-tests/demos/src/main/java/androidx/constraintlayout/compose/demos/StaggeredDemo.kt b/constraintlayout/constraintlayout-compose/integration-tests/demos/src/main/java/androidx/constraintlayout/compose/demos/StaggeredDemo.kt
new file mode 100644
index 0000000..39169ab
--- /dev/null
+++ b/constraintlayout/constraintlayout-compose/integration-tests/demos/src/main/java/androidx/constraintlayout/compose/demos/StaggeredDemo.kt
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2023 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.
+ */
+
+@file:OptIn(ExperimentalMotionApi::class)
+
+package androidx.constraintlayout.compose.demos
+
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.Button
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.layoutId
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.constraintlayout.compose.ChainStyle
+import androidx.constraintlayout.compose.ExperimentalMotionApi
+import androidx.constraintlayout.compose.MotionLayout
+import androidx.constraintlayout.compose.MotionScene
+
+private const val STAGGERED_VALUE = 0.4f
+
+@Preview
+@Composable
+fun SimpleStaggeredDemo() {
+    var mode by remember { mutableStateOf(StaggeredMode.Normal) }
+    var animateToEnd by remember { mutableStateOf(false) }
+    val progress by animateFloatAsState(
+        targetValue = if (animateToEnd) 1f else 0f,
+        animationSpec = tween(3000),
+    )
+    val boxesId: IntArray = remember { IntArray(10) { it } }
+    val staggeredValue by remember {
+        derivedStateOf {
+            when (mode) {
+                StaggeredMode.Inverted -> -STAGGERED_VALUE
+                else -> STAGGERED_VALUE
+            }
+        }
+    }
+
+    Column(Modifier.fillMaxSize()) {
+        MotionLayout(
+            remember(mode) {
+                MotionScene {
+                    val refs = boxesId.map { createRefFor(it) }.toTypedArray()
+                    val weights = when (mode) {
+                        StaggeredMode.Custom -> boxesId.map { it.toFloat() }.shuffled()
+                        else -> boxesId.map { Float.NaN }
+                    }
+
+                    defaultTransition(
+                        constraintSet {
+                            createHorizontalChain(*refs, chainStyle = ChainStyle.Packed(0f))
+                            refs.forEachIndexed { index, ref ->
+                                constrain(ref) {
+                                    staggeredWeight = weights[index]
+                                }
+                            }
+                        },
+                        constraintSet {
+                            createVerticalChain(*refs, chainStyle = ChainStyle.Packed(1f))
+                            constrain(*refs) {
+                                end.linkTo(parent.end)
+                            }
+                        }
+                    ) {
+                        staggered = staggeredValue
+                    }
+                }
+            },
+            progress = progress,
+            Modifier
+                .fillMaxWidth()
+                .weight(1f, true)
+        ) {
+            for (id in boxesId) {
+                Box(
+                    modifier = Modifier
+                        .size(25.dp)
+                        .background(Color.Red)
+                        .layoutId(id)
+                )
+            }
+        }
+        Row(
+            modifier = Modifier.fillMaxWidth(),
+            horizontalArrangement = Arrangement.SpaceBetween
+        ) {
+            Button( animateToEnd = !animateToEnd }) {
+                Text(text = "Run")
+            }
+            Button(
+                >
+                    mode = when (mode) {
+                        StaggeredMode.Normal -> StaggeredMode.Inverted
+                        StaggeredMode.Inverted -> StaggeredMode.Custom
+                        else -> StaggeredMode.Normal
+                    }
+                }
+            ) {
+                Text(text = "Mode: ${mode.name}, Value: $staggeredValue")
+            }
+        }
+    }
+}
+
+private enum class StaggeredMode {
+    Normal,
+    Inverted,
+    Custom
+}
\ No newline at end of file
diff --git a/constraintlayout/constraintlayout-compose/src/androidAndroidTest/kotlin/androidx/constraintlayout/compose/MotionLayoutTest.kt b/constraintlayout/constraintlayout-compose/src/androidAndroidTest/kotlin/androidx/constraintlayout/compose/MotionLayoutTest.kt
index 4ae97ba..7e84a3d 100644
--- a/constraintlayout/constraintlayout-compose/src/androidAndroidTest/kotlin/androidx/constraintlayout/compose/MotionLayoutTest.kt
+++ b/constraintlayout/constraintlayout-compose/src/androidAndroidTest/kotlin/androidx/constraintlayout/compose/MotionLayoutTest.kt
@@ -36,8 +36,11 @@
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
 import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
@@ -47,6 +50,7 @@
 import androidx.compose.ui.layout.boundsInParent
 import androidx.compose.ui.layout.layoutId
 import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.positionInParent
 import androidx.compose.ui.layout.positionInRoot
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalDensity
@@ -72,6 +76,8 @@
 import androidx.test.filters.MediumTest
 import kotlin.math.roundToInt
 import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+import kotlin.test.assertTrue
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -496,6 +502,103 @@
         }
     }
 
+    @Test
+    fun testStaggeredAndCustomWeights() = with(rule.density) {
+        val rootSizePx = 100
+        val boxSizePx = 10
+        val progress = mutableStateOf(0f)
+        val staggeredValue = mutableStateOf(0.31f)
+        val weights = mutableStateListOf(Float.NaN, Float.NaN, Float.NaN)
+
+        val ids = IntArray(3) { it }
+        val positions = mutableMapOf<Int, IntOffset>()
+
+        rule.setContent {
+            MotionLayout(
+                motionScene = remember {
+                    derivedStateOf {
+                        MotionScene {
+                            val refs = ids.map { createRefFor(it) }.toTypedArray()
+                            defaultTransition(
+                                from = constraintSet {
+                                    createVerticalChain(*refs, chainStyle = ChainStyle.Packed(0.0f))
+                                    refs.forEachIndexed { index, ref ->
+                                        constrain(ref) {
+                                            staggeredWeight = weights[index]
+                                        }
+                                    }
+                                },
+                                to = constraintSet {
+                                    createVerticalChain(*refs, chainStyle = ChainStyle.Packed(0.0f))
+                                    constrain(*refs) {
+                                        end.linkTo(parent.end)
+                                    }
+                                }
+                            ) {
+                                staggered = staggeredValue.value
+                            }
+                        }
+                    }
+                }.value,
+                progress = progress.value,
+                modifier = Modifier.size(rootSizePx.toDp())
+            ) {
+                for (id in ids) {
+                    Box(
+                        Modifier
+                            .size(boxSizePx.toDp())
+                            .layoutId(id)
+                            .onGloballyPositioned {
+                                positions[id] = it
+                                    .positionInParent()
+                                    .round()
+                            })
+                }
+            }
+        }
+
+        // Set the progress to just before the stagger value (0.31f)
+        progress.value = 0.3f
+
+        rule.runOnIdle {
+            assertEquals(0, positions[0]!!.x)
+            assertNotEquals(0, positions[1]!!.x)
+            assertNotEquals(0, positions[2]!!.x)
+
+            // Widget 2 has higher weight since it's laid out further towards the bottom
+            assertTrue(positions[2]!!.x > positions[1]!!.x)
+        }
+
+        // Invert the staggering order
+        staggeredValue.value = -(staggeredValue.value)
+
+        rule.runOnIdle {
+            assertNotEquals(0, positions[0]!!.x)
+            assertNotEquals(0, positions[1]!!.x)
+            assertEquals(0, positions[2]!!.x)
+
+            // While inverted, widget 0 has the higher weight
+            assertTrue(positions[0]!!.x > positions[1]!!.x)
+        }
+
+        // Set the widget in the middle to have the lowest weight
+        weights[0] = 3f
+        weights[1] = 1f
+        weights[2] = 2f
+
+        // Set the staggering order back to normal
+        staggeredValue.value = -(staggeredValue.value)
+
+        rule.runOnIdle {
+            assertNotEquals(0, positions[0]!!.x)
+            assertEquals(0, positions[1]!!.x)
+            assertNotEquals(0, positions[2]!!.x)
+
+            // Widget 0 has higher weight, starts earlier
+            assertTrue(positions[0]!!.x > positions[2]!!.x)
+        }
+    }
+
     private fun Color.toHexString(): String = toArgb().toUInt().toString(16)
 }
 
diff --git a/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/MotionSceneScope.kt b/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/MotionSceneScope.kt
index 0b17e86..8bc0033 100644
--- a/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/MotionSceneScope.kt
+++ b/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/MotionSceneScope.kt
@@ -362,6 +362,39 @@
     }
 
     /**
+     * Custom staggered weight. When set, MotionLayout will use these values instead of the default
+     * way of calculating the weight, ignoring those with a `Float.NaN` value.
+     *
+     * &nbsp;
+     *
+     * The value is `Float.NaN` by default. Note that when all widgets are set to `Float.NaN`,
+     * MotionLayout will use the default way of calculating the weight.
+     *
+     * @see TransitionScope.staggered
+     */
+    var ConstrainScope.staggeredWeight: Float
+        get() {
+            if (!this.containerObject.has("motion")) {
+                return Float.NaN
+            }
+            val motionObject = this.containerObject.getObject("motion")
+            return motionObject.getFloatOrNaN("stagger")
+        }
+        set(value) {
+            with(this) {
+                setMotionProperty("stagger", value)
+            }
+        }
+
+    private fun ConstrainScope.setMotionProperty(name: String, value: Float) {
+        if (!this.containerObject.has("motion")) {
+            containerObject.put("motion", CLObject(charArrayOf()))
+        }
+        val motionPropsObject = containerObject.getObjectOrNull("motion") ?: return
+        motionPropsObject.putNumber(name, value)
+    }
+
+    /**
      * Sets the custom Float [value] at the frame of the current [KeyAttributeScope].
      */
     fun KeyAttributeScope.customFloat(name: String, value: Float) {
diff --git a/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/TransitionScope.kt b/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/TransitionScope.kt
index 788173c..965a2f2 100644
--- a/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/TransitionScope.kt
+++ b/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/TransitionScope.kt
@@ -16,6 +16,7 @@
 
 package androidx.constraintlayout.compose
 
+import androidx.annotation.FloatRange
 import androidx.annotation.IntRange
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
@@ -78,6 +79,29 @@
 
     var onSwipe: OnSwipe? = null
 
+    /**
+     * Defines the maximum delay (in progress percent) between a group of staggered widgets.
+     *
+     * &nbsp;
+     *
+     * The amount of delay for each widget is on proportion to their final position on the layout,
+     * weighted against each other.
+     *
+     * Where the weight is calculated as the Manhattan Distance from the top-left corner of the
+     * layout.
+     *
+     * So the widget with the lowest weight will receive the most delay. A negative [staggered]
+     * value inverts this logic, in which case, the widget with the lowest weight will receive no
+     * delay.
+     *
+     * &nbsp;
+     *
+     * You may set [MotionSceneScope.staggeredWeight] on a per-widget basis to get a custom
+     * staggered order.
+     */
+    @FloatRange(-1.0, 1.0, fromInclusive = false, toInclusive = false)
+    var staggered: Float = 0.0f
+
     fun keyAttributes(
         vararg targets: ConstrainedLayoutReference,
         keyAttributesContent: KeyAttributesScope.() -> Unit
@@ -122,6 +146,7 @@
         //  `progress` value. Eg: `animateFloat(tween(duration, LinearEasing))`
 //        containerObject.putString("interpolator", easing.name)
 //        containerObject.putNumber("duration", durationMs.toFloat())
+        containerObject.putNumber("staggered", staggered)
         onSwipe?.let {
             containerObject.put("onSwipe", onSwipeObject)
             onSwipeObject.putString("direction", it.direction.name)