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.
+ *
+ *
+ *
+ * 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.
+ *
+ *
+ *
+ * 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.
+ *
+ *
+ *
+ * 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)