Skip to content
This repository was archived by the owner on Dec 27, 2024. It is now read-only.

Commit 510fab4

Browse files
committed
[Compose-ML] Add support for stateful animations
It should now be possible to use MotionLayout passing a MotionScene object, and animate by just providing the desired ConstraintSet to transition to. Similar to animating ConstraintLayout but without having to parse new ConstraintSets.
1 parent 291b7fb commit 510fab4

File tree

6 files changed

+663
-9
lines changed

6 files changed

+663
-9
lines changed

constraintlayout/compose/src/main/java/androidx/constraintlayout/compose/ConstraintLayout.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1641,6 +1641,13 @@ class JSONConstraintSet(@Language("json5") content: String,
16411641
initialization()
16421642
}
16431643

1644+
override fun equals(other: Any?): Boolean {
1645+
if (other is JSONConstraintSet) {
1646+
return this.getCurrentContent() == other.getCurrentContent()
1647+
}
1648+
return false
1649+
}
1650+
16441651
// Only called by MotionLayout in MotionMeasurer
16451652
override fun applyTo(transition: Transition, type: Int) {
16461653
val layoutVariables = LayoutVariables()

constraintlayout/compose/src/main/java/androidx/constraintlayout/compose/ConstraintSetParser.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,8 +547,13 @@ fun override(baseJson: CLObject, name: String, overrideValue: CLObject) {
547547
base.remove("top")
548548
base.remove("bottom")
549549
base.remove("baseline")
550+
base.remove("center")
551+
base.remove("centerHorizontally")
552+
base.remove("centerVertically")
550553
}
551554
"transforms" -> {
555+
base.remove("visibility")
556+
base.remove("alpha")
552557
base.remove("pivotX")
553558
base.remove("pivotY")
554559
base.remove("rotationX")

constraintlayout/compose/src/main/java/androidx/constraintlayout/compose/MotionLayout.kt

Lines changed: 150 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import android.annotation.SuppressLint
2020
import android.graphics.Matrix
2121
import androidx.compose.animation.core.Animatable
2222
import androidx.compose.animation.core.AnimationSpec
23-
import androidx.compose.animation.core.spring
23+
import androidx.compose.animation.core.tween
2424
import androidx.compose.foundation.Canvas
2525
import androidx.compose.foundation.layout.Box
2626
import androidx.compose.foundation.layout.BoxScope
@@ -41,12 +41,11 @@ import androidx.compose.ui.layout.*
4141
import androidx.compose.ui.semantics.semantics
4242
import androidx.compose.ui.unit.*
4343
import androidx.constraintlayout.core.motion.Motion
44-
import androidx.constraintlayout.core.parser.CLObject
4544
import androidx.constraintlayout.core.parser.CLParser
4645
import androidx.constraintlayout.core.parser.CLParsingException
47-
import androidx.constraintlayout.core.state.*
4846
import androidx.constraintlayout.core.state.Dimension
4947
import androidx.constraintlayout.core.state.Transition
48+
import androidx.constraintlayout.core.state.WidgetFrame
5049
import androidx.constraintlayout.core.widgets.Optimizer
5150
import kotlinx.coroutines.channels.Channel
5251
import org.intellij.lang.annotations.Language
@@ -67,6 +66,149 @@ inline fun MotionLayout(
6766
modifier: Modifier = Modifier,
6867
optimizationLevel: Int = Optimizer.OPTIMIZATION_STANDARD,
6968
crossinline content: @Composable MotionLayoutScope.() -> Unit
69+
) {
70+
MotionLayout(
71+
start = start,
72+
end = end,
73+
transition = transition,
74+
progress = progress,
75+
debug = debug,
76+
informationReceiver = null,
77+
modifier = modifier,
78+
optimizationLevel = optimizationLevel,
79+
content = content
80+
)
81+
}
82+
83+
/**
84+
* Layout that takes a MotionScene and animates by providing a [constraintSetName] to animate to.
85+
*
86+
* During recomposition, MotionLayout will interpolate from whichever ConstraintSet it is currently
87+
* in, to [constraintSetName].
88+
*
89+
* Typically the first value of [constraintSetName] should match the start ConstraintSet in the
90+
* default transition, or be null.
91+
*
92+
* Animation is run by [animationSpec], and will only start another animation once any other ones
93+
* are finished. Use [finishedAnimationListener] to know when a transition has stopped.
94+
*/
95+
@Composable
96+
inline fun MotionLayout(
97+
motionScene: MotionScene,
98+
constraintSetName: String? = null,
99+
animationSpec: AnimationSpec<Float> = tween<Float>(),
100+
debug: EnumSet<MotionLayoutDebugFlags> = EnumSet.of(MotionLayoutDebugFlags.NONE),
101+
modifier: Modifier = Modifier,
102+
optimizationLevel: Int = Optimizer.OPTIMIZATION_STANDARD,
103+
noinline finishedAnimationListener: (() -> Unit)? = null,
104+
crossinline content: @Composable (MotionLayoutScope.() -> Unit)
105+
) {
106+
val needsUpdate = remember {
107+
mutableStateOf(0L)
108+
}
109+
motionScene.setUpdateFlag(needsUpdate)
110+
111+
var usedDebugMode = debug
112+
if (motionScene.getForcedDrawDebug() != MotionLayoutDebugFlags.UNKNOWN) {
113+
usedDebugMode = EnumSet.of(motionScene.getForcedDrawDebug())
114+
}
115+
116+
val transitionContent = remember(motionScene, needsUpdate.value) {
117+
motionScene.getTransition("default")
118+
}
119+
120+
val transition: androidx.constraintlayout.compose.Transition? =
121+
transitionContent?.let { Transition(it) }
122+
123+
val startId = transition?.getStartConstraintSetId() ?: "start"
124+
val endId = transition?.getEndConstraintSetId() ?: "end"
125+
126+
val startContent = remember(motionScene, needsUpdate.value) {
127+
motionScene.getConstraintSet(startId) ?: motionScene.getConstraintSet(0)
128+
}
129+
val endContent = remember(motionScene, needsUpdate.value) {
130+
motionScene.getConstraintSet(endId) ?: motionScene.getConstraintSet(1)
131+
}
132+
133+
val targetEndContent = remember(motionScene, constraintSetName) {
134+
constraintSetName?.let { motionScene.getConstraintSet(constraintSetName) }
135+
}
136+
137+
if (startContent == null || endContent == null) {
138+
return
139+
}
140+
141+
var start: ConstraintSet by remember(motionScene) { mutableStateOf(JSONConstraintSet(content = startContent)) }
142+
var end: ConstraintSet by remember(motionScene) { mutableStateOf(JSONConstraintSet(content = endContent)) }
143+
val targetConstraintSet = targetEndContent?.let { JSONConstraintSet(targetEndContent) }
144+
145+
val progress = remember { Animatable(0f) }
146+
147+
var animateToEnd by remember(motionScene) { mutableStateOf(true) }
148+
149+
val channel = remember { Channel<ConstraintSet>(Channel.CONFLATED) }
150+
151+
if (targetConstraintSet != null) {
152+
SideEffect {
153+
channel.trySend(targetConstraintSet)
154+
}
155+
156+
LaunchedEffect(motionScene, channel) {
157+
for (constraints in channel) {
158+
val newConstraintSet = channel.tryReceive().getOrNull() ?: constraints
159+
val animTargetValue = if (animateToEnd) 1f else 0f
160+
val currentSet = if (animateToEnd) start else end
161+
if (newConstraintSet != currentSet) {
162+
if (animateToEnd) {
163+
end = newConstraintSet
164+
} else {
165+
start = newConstraintSet
166+
}
167+
progress.animateTo(animTargetValue, animationSpec)
168+
animateToEnd = !animateToEnd
169+
finishedAnimationListener?.invoke()
170+
}
171+
}
172+
}
173+
}
174+
175+
val lastOutsideProgress = remember { mutableStateOf(0f) }
176+
val forcedProgress = motionScene.getForcedProgress()
177+
178+
val currentProgress =
179+
if (!forcedProgress.isNaN() && lastOutsideProgress.value == progress.value) {
180+
forcedProgress
181+
} else {
182+
motionScene.resetForcedProgress()
183+
progress.value
184+
}
185+
186+
lastOutsideProgress.value = progress.value
187+
188+
MotionLayout(
189+
start = start,
190+
end = end,
191+
transition = transition,
192+
progress = currentProgress,
193+
debug = usedDebugMode,
194+
informationReceiver = motionScene as? JSONMotionScene,
195+
modifier = modifier,
196+
optimizationLevel = optimizationLevel,
197+
content = content
198+
)
199+
}
200+
201+
@Composable
202+
inline fun MotionLayout(
203+
start: ConstraintSet,
204+
end: ConstraintSet,
205+
transition: androidx.constraintlayout.compose.Transition? = null,
206+
progress: Float,
207+
debug: EnumSet<MotionLayoutDebugFlags> = EnumSet.of(MotionLayoutDebugFlags.NONE),
208+
informationReceiver: LayoutInformationReceiver? = null,
209+
modifier: Modifier = Modifier,
210+
optimizationLevel: Int = Optimizer.OPTIMIZATION_STANDARD,
211+
crossinline content: @Composable MotionLayoutScope.() -> Unit
70212
) {
71213
val measurer = remember { MotionMeasurer() }
72214
val scope = remember { MotionLayoutScope(measurer) }
@@ -83,6 +225,7 @@ inline fun MotionLayout(
83225
progressState,
84226
measurer
85227
)
228+
measurer.addLayoutInformationReceiver(informationReceiver)
86229

87230
val forcedScaleFactor = measurer.forcedScaleFactor
88231
if (!debug.contains(MotionLayoutDebugFlags.NONE) || !forcedScaleFactor.isNaN()) {
@@ -220,7 +363,6 @@ inline fun MotionLayout(
220363
content = { scope.content() }
221364
))
222365
}
223-
224366
}
225367

226368
@Immutable
@@ -525,11 +667,13 @@ internal class MotionMeasurer : Measurer() {
525667
this.measureScope = measureScope
526668
var layoutSizeChanged = false
527669
if (constraints.hasFixedWidth
528-
&& !state.sameFixedWidth(constraints.maxWidth)) {
670+
&& !state.sameFixedWidth(constraints.maxWidth)
671+
) {
529672
layoutSizeChanged = true
530673
}
531674
if (constraints.hasFixedHeight
532-
&& !state.sameFixedHeight(constraints.maxHeight)) {
675+
&& !state.sameFixedHeight(constraints.maxHeight)
676+
) {
533677
layoutSizeChanged = true
534678
}
535679
if (motionProgress != progress

constraintlayout/core/src/main/java/androidx/constraintlayout/core/state/WidgetFrame.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,14 @@ public static void interpolate(int parentWidth, int parentHeight, WidgetFrame fr
167167
endAlpha = 1f;
168168
}
169169

170+
if (start.visibility == ConstraintWidget.INVISIBLE) {
171+
startAlpha = 0f;
172+
}
173+
174+
if (end.visibility == ConstraintWidget.INVISIBLE) {
175+
endAlpha = 0f;
176+
}
177+
170178
if (frame.widget != null && transition.hasPositionKeyframes()) {
171179
Transition.KeyPosition firstPosition = transition.findPreviousPosition(frame.widget.stringId, frameNumber);
172180
Transition.KeyPosition lastPosition = transition.findNextPosition(frame.widget.stringId, frameNumber);

projects/ComposeConstraintLayout/app/src/main/java/com/example/constraintlayout/MotionComposeExamples.kt

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ public fun MotionExample2() {
166166

167167
Column(Modifier.background(Color.White)) {
168168
MotionLayout(
169-
ConstraintSet(
169+
start = ConstraintSet(
170170
""" {
171171
background: {
172172
width: "spread",
@@ -216,7 +216,7 @@ public fun MotionExample2() {
216216
}
217217
} """
218218
),
219-
ConstraintSet(
219+
end = ConstraintSet(
220220
""" {
221221
background: {
222222
width: "spread",
@@ -256,7 +256,7 @@ public fun MotionExample2() {
256256
height: 400,
257257
start: ['parent', 'start', 16],
258258
end: ['parent', 'end', 16],
259-
top: ['description', 'bottom', 16]
259+
top: ['description', 'bottom', 16],
260260
},
261261
play: {
262262
start: ['parent', 'end', 8],
@@ -1382,4 +1382,101 @@ fun MotionExample9() {
13821382
)
13831383
}
13841384
}
1385+
}
1386+
1387+
@Preview(group = "motion10")
1388+
@Composable
1389+
fun MotionExample10() {
1390+
val states = remember {
1391+
listOf(
1392+
"csTopRight",
1393+
"csBottomLeft",
1394+
"csBottomRight",
1395+
"csTopLeft"
1396+
)
1397+
}
1398+
1399+
var currentState by remember {
1400+
mutableStateOf(-1)
1401+
}
1402+
1403+
Column {
1404+
Button(
1405+
onClick = {
1406+
currentState = if (currentState >= (states.size - 1)) {
1407+
0
1408+
} else {
1409+
currentState + 1
1410+
}
1411+
}) {
1412+
Text("Run")
1413+
}
1414+
MotionLayout(
1415+
modifier = Modifier
1416+
.fillMaxWidth()
1417+
.fillMaxHeight()
1418+
.background(Color.Black),
1419+
debug = EnumSet.of(MotionLayoutDebugFlags.SHOW_ALL),
1420+
motionScene = MotionScene(
1421+
"""
1422+
{
1423+
ConstraintSets: {
1424+
csTopLeft: {
1425+
box1: {
1426+
start: ['parent', 'start', 16],
1427+
top: ['parent', 'top', 16]
1428+
},
1429+
},
1430+
csTopRight : {
1431+
box1: {
1432+
end: ['parent', 'end', 16],
1433+
top: ['parent', 'top', 16]
1434+
},
1435+
},
1436+
csBottomLeft : {
1437+
box1: {
1438+
start: ['parent', 'start', 16],
1439+
bottom: ['parent', 'bottom', 16]
1440+
},
1441+
},
1442+
csBottomRight : {
1443+
box1: {
1444+
end: ['parent', 'end', 16],
1445+
bottom: ['parent', 'bottom', 16]
1446+
},
1447+
}
1448+
},
1449+
Transitions: {
1450+
default: {
1451+
from: 'csBottomRight',
1452+
to: 'csTopLeft',
1453+
pathMotionArc: 'startHorizontal',
1454+
KeyFrames: {
1455+
KeyPositions: [
1456+
{
1457+
type: 'parentRelative',
1458+
target: ['box1'],
1459+
frames: [50],
1460+
percentX: [0.5],
1461+
percentY: [0.4]
1462+
}
1463+
]
1464+
}
1465+
}
1466+
}
1467+
}
1468+
"""
1469+
),
1470+
constraintSetName = states.getOrNull(currentState),
1471+
) {
1472+
Box(
1473+
modifier = Modifier
1474+
.layoutId("box1")
1475+
.height(125.dp)
1476+
.width(188.dp)
1477+
.clip(shape = RoundedCornerShape(12.dp))
1478+
.background(Color.Green)
1479+
)
1480+
}
1481+
}
13851482
}

0 commit comments

Comments
 (0)