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

Commit 99a48f3

Browse files
committed
[Compose] Remeasure MotionLayout on child size change
- Code cleanup/refactor for clarity and early return conditions - Need to remeasure the layout if the size of any child Composable changed externally - Added MotionVerificationTest that tests the bounds of a MotionLayout composable child at three points during animation (start, half-way, end). It also offers a chance to recompose at the midpoint.
1 parent 33ea839 commit 99a48f3

File tree

8 files changed

+518
-144
lines changed

8 files changed

+518
-144
lines changed

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

Lines changed: 132 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ import androidx.compose.ui.graphics.nativeCanvas
4040
import androidx.compose.ui.layout.*
4141
import androidx.compose.ui.semantics.semantics
4242
import androidx.compose.ui.unit.*
43+
import androidx.compose.ui.util.fastAny
44+
import androidx.compose.ui.util.fastForEach
4345
import androidx.constraintlayout.core.motion.Motion
4446
import androidx.constraintlayout.core.parser.CLParser
4547
import androidx.constraintlayout.core.parser.CLParsingException
@@ -362,7 +364,7 @@ internal inline fun MotionLayoutCore(
362364
val measurer = remember { MotionMeasurer() }
363365
val scope = remember { MotionLayoutScope(measurer) }
364366
val progressState = remember { mutableStateOf(0f) }
365-
SideEffect { progressState.value = progress }
367+
progressState.value = progress
366368
val measurePolicy =
367369
rememberMotionLayoutMeasurePolicy(
368370
optimizationLevel,
@@ -382,6 +384,7 @@ internal inline fun MotionLayoutCore(
382384
if (!forcedScaleFactor.isNaN()) {
383385
mod = modifier.scale(measurer.forcedScaleFactor)
384386
}
387+
// FIXME: Consider removing this Box wrapper, it breaks the expected behavior of the layout
385388
Box {
386389
@Suppress("DEPRECATION")
387390
(MultiMeasureLayout(
@@ -690,8 +693,10 @@ internal class MotionMeasurer : Measurer() {
690693
// make sure that the constraints/dimensions returned are for the start/current ConstraintSet
691694

692695
private fun measureConstraintSet(
693-
optimizationLevel: Int, constraintSet: ConstraintSet,
694-
measurables: List<Measurable>, constraints: Constraints
696+
optimizationLevel: Int,
697+
constraintSet: ConstraintSet,
698+
measurables: List<Measurable>,
699+
constraints: Constraints
695700
) {
696701
state.reset()
697702
constraintSet.applyTo(state, measurables)
@@ -731,93 +736,140 @@ internal class MotionMeasurer : Measurer() {
731736
): IntSize {
732737
this.density = measureScope
733738
this.measureScope = measureScope
734-
// TODO: Add another check for whenever a measurable/child has changed size, that triggers a
735-
// measure of the constraintsets and interpolation
736-
var layoutSizeChanged = false
737-
if (constraints.hasFixedWidth
738-
&& !state.sameFixedWidth(constraints.maxWidth)
739-
) {
740-
layoutSizeChanged = true
741-
}
742-
if (constraints.hasFixedHeight
743-
&& !state.sameFixedHeight(constraints.maxHeight)
744-
) {
745-
layoutSizeChanged = true
746-
}
739+
740+
val needsRemeasure = needsRemeasure(constraints)
741+
747742
if (motionProgress != progress
748743
|| (layoutInformationReceiver?.getForcedWidth() != Int.MIN_VALUE
749744
&& layoutInformationReceiver?.getForcedHeight() != Int.MIN_VALUE)
750-
|| this.transition.isEmpty()
751-
|| frameCache.isEmpty()
752-
|| layoutSizeChanged
745+
|| needsRemeasure
753746
) {
754-
motionProgress = progress
755-
if (layoutSizeChanged || this.transition.isEmpty() || frameCache.isEmpty()) {
756-
this.transition.clear()
757-
resetMeasureState()
758-
state.reset()
759-
// Define the size of the ConstraintLayout.
760-
state.width(
761-
if (constraints.hasFixedWidth) {
762-
Dimension.Fixed(constraints.maxWidth)
763-
} else {
764-
Dimension.Wrap().min(constraints.minWidth)
765-
}
766-
)
767-
state.height(
768-
if (constraints.hasFixedHeight) {
769-
Dimension.Fixed(constraints.maxHeight)
770-
} else {
771-
Dimension.Wrap().min(constraints.minHeight)
772-
}
773-
)
774-
// Build constraint set and apply it to the state.
775-
state.rootIncomingConstraints = constraints
776-
state.layoutDirection = layoutDirection
747+
recalculateInterpolation(
748+
constraints = constraints,
749+
layoutDirection = layoutDirection,
750+
constraintSetStart = constraintSetStart,
751+
constraintSetEnd = constraintSetEnd,
752+
transition = transition,
753+
measurables = measurables,
754+
optimizationLevel = optimizationLevel,
755+
progress = progress,
756+
remeasure = needsRemeasure
757+
)
758+
}
759+
return IntSize(root.width, root.height)
760+
}
777761

778-
measureConstraintSet(
779-
optimizationLevel, constraintSetStart, measurables, constraints
780-
)
781-
this.transition.updateFrom(root, Transition.START)
782-
measureConstraintSet(
783-
optimizationLevel, constraintSetEnd, measurables, constraints
784-
)
785-
this.transition.updateFrom(root, Transition.END)
786-
if (transition != null) {
787-
transition.applyTo(this.transition, 0)
762+
/**
763+
* Indicates if the layout requires measuring before computing the interpolation.
764+
*
765+
* This might happen if the size of MotionLayout or any of its children changed.
766+
*
767+
* MotionLayout size might change from its parent Layout, and in some cases the children size
768+
* might change (eg: A Text layout has a longer string appended).
769+
*/
770+
private fun needsRemeasure(constraints: Constraints): Boolean {
771+
if (this.transition.isEmpty || frameCache.isEmpty()) {
772+
// Nothing measured (by MotionMeasurer)
773+
return true
774+
}
775+
776+
if ((constraints.hasFixedHeight && !state.sameFixedHeight(constraints.maxHeight))
777+
|| (constraints.hasFixedWidth && !state.sameFixedWidth(constraints.maxWidth))) {
778+
// Layout size changed
779+
return true
780+
}
781+
782+
return root.children.fastAny { child ->
783+
// Check if measurables have changed their size
784+
val measurable = (child.companionWidget as? Measurable) ?: return@fastAny false
785+
val interpolatedFrame = this.transition.getInterpolated(child) ?: return@fastAny false
786+
val placeable = placeables[measurable] ?: return@fastAny false
787+
val currentWidth = placeable.width
788+
val currentHeight = placeable.height
789+
790+
// Need to recalculate interpolation if the size of any element changed
791+
return@fastAny currentWidth != interpolatedFrame.width()
792+
|| currentHeight != interpolatedFrame.height()
793+
}
794+
}
795+
796+
/**
797+
* Remeasures based on [constraintSetStart] and [constraintSetEnd] if needed.
798+
*
799+
* Runs the interpolation for the given [progress].
800+
*
801+
* Finally, updates the [Measurable]s dimension if they changed during interpolation.
802+
*/
803+
private fun recalculateInterpolation(
804+
constraints: Constraints,
805+
layoutDirection: LayoutDirection,
806+
constraintSetStart: ConstraintSet,
807+
constraintSetEnd: ConstraintSet,
808+
transition: androidx.constraintlayout.compose.Transition?,
809+
measurables: List<Measurable>,
810+
optimizationLevel: Int,
811+
progress: Float,
812+
remeasure: Boolean
813+
) {
814+
motionProgress = progress
815+
if (remeasure) {
816+
this.transition.clear()
817+
resetMeasureState()
818+
state.reset()
819+
// Define the size of the ConstraintLayout.
820+
state.width(
821+
if (constraints.hasFixedWidth) {
822+
Dimension.Fixed(constraints.maxWidth)
823+
} else {
824+
Dimension.Wrap().min(constraints.minWidth)
788825
}
789-
}
790-
this.transition.interpolate(root.width, root.height, progress)
791-
var index = 0
792-
for (child in root.children) {
793-
val measurable = child.companionWidget
794-
if (measurable !is Measurable) continue
795-
var interpolatedFrame = this.transition.getInterpolated(child)
796-
if (interpolatedFrame == null) {
797-
continue
826+
)
827+
state.height(
828+
if (constraints.hasFixedHeight) {
829+
Dimension.Fixed(constraints.maxHeight)
830+
} else {
831+
Dimension.Wrap().min(constraints.minHeight)
798832
}
799-
val placeable = placeables[measurable]
800-
val currentWidth = placeable?.width
801-
val currentHeight = placeable?.height
802-
if (placeable == null
803-
|| currentWidth != interpolatedFrame.width()
804-
|| currentHeight != interpolatedFrame.height()
805-
) {
806-
measurable.measure(
807-
Constraints.fixed(interpolatedFrame.width(), interpolatedFrame.height())
808-
)
809-
.also {
810-
placeables[measurable] = it
811-
}
833+
)
834+
// Build constraint set and apply it to the state.
835+
state.rootIncomingConstraints = constraints
836+
state.layoutDirection = layoutDirection
837+
838+
measureConstraintSet(
839+
optimizationLevel, constraintSetStart, measurables, constraints
840+
)
841+
this.transition.updateFrom(root, Transition.START)
842+
measureConstraintSet(
843+
optimizationLevel, constraintSetEnd, measurables, constraints
844+
)
845+
this.transition.updateFrom(root, Transition.END)
846+
transition?.applyTo(this.transition, 0)
847+
}
848+
849+
this.transition.interpolate(root.width, root.height, progress)
850+
851+
root.children.fastForEach { child ->
852+
// Update measurables to the interpolated dimensions
853+
val measurable = (child.companionWidget as? Measurable) ?: return@fastForEach
854+
val interpolatedFrame = this.transition.getInterpolated(child) ?: return@fastForEach
855+
val placeable = placeables[measurable]
856+
val currentWidth = placeable?.width
857+
val currentHeight = placeable?.height
858+
if (placeable == null
859+
|| currentWidth != interpolatedFrame.width()
860+
|| currentHeight != interpolatedFrame.height()) {
861+
measurable.measure(
862+
Constraints.fixed(interpolatedFrame.width(), interpolatedFrame.height())
863+
).also { newPlaceable ->
864+
placeables[measurable] = newPlaceable
812865
}
813-
frameCache[measurable] = interpolatedFrame
814-
index++
815-
}
816-
if (layoutInformationReceiver?.getLayoutInformationMode() == LayoutInfoFlags.BOUNDS) {
817-
computeLayoutResult()
818866
}
867+
frameCache[measurable] = interpolatedFrame
868+
}
869+
870+
if (layoutInformationReceiver?.getLayoutInformationMode() == LayoutInfoFlags.BOUNDS) {
871+
computeLayoutResult()
819872
}
820-
return IntSize(root.width, root.height)
821873
}
822874

823875
private fun encodeKeyFrames(
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/*
2+
* Copyright (C) 2022 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.example.constraintlayout
18+
19+
import androidx.compose.runtime.currentComposer
20+
import androidx.compose.runtime.getValue
21+
import androidx.compose.runtime.mutableStateOf
22+
import androidx.compose.runtime.setValue
23+
import androidx.compose.ui.geometry.Rect
24+
import androidx.compose.ui.platform.LocalContext
25+
import androidx.compose.ui.test.SemanticsMatcher
26+
import androidx.compose.ui.test.hasParent
27+
import androidx.compose.ui.test.junit4.createComposeRule
28+
import androidx.test.ext.junit.runners.AndroidJUnit4
29+
import androidx.test.filters.MediumTest
30+
import com.example.constraintlayout.verification.ComposableInvocator
31+
import com.example.constraintlayout.verification.motiondsl.MotionTestInfoProviderKey
32+
import org.junit.Rule
33+
import org.junit.Test
34+
import org.junit.runner.RunWith
35+
36+
@MediumTest
37+
@RunWith(AndroidJUnit4::class)
38+
class MotionVerificationTest {
39+
@get:Rule
40+
val rule = createComposeRule()
41+
42+
val invocator = ComposableInvocator(
43+
packageString = "com.example.constraintlayout.verification.motiondsl",
44+
fileName = "MotionDslVerification"
45+
)
46+
47+
@Test
48+
fun verifyComposables() {
49+
// TODO: Test doesn't work very well with Json, some part in the helper parser is not
50+
// stable, either from user-string to binary or binary to result-string
51+
val results = HashMap<String, String>()
52+
53+
var composableIndex by mutableStateOf(0) // Observable state that we'll use to change the content in a recomposition
54+
var fqComposable = ""
55+
var baselineRaw = ""
56+
rule.setContent {
57+
// Set the content to the Composable at the given index
58+
fqComposable = invocator.invokeComposable(composableIndex, currentComposer)
59+
// We can only get the Resources in this context
60+
baselineRaw =
61+
LocalContext.current.resources.openRawResource(R.raw.motion_results)
62+
.bufferedReader()
63+
.readText()
64+
}
65+
for (i in 0..invocator.max) {
66+
rule.runOnUiThread {
67+
// Force a recomposition with the next Composable index
68+
composableIndex = i
69+
}
70+
// Wait for the content to settle
71+
rule.waitForIdle()
72+
73+
val motionInfoNode =
74+
rule.onNode(SemanticsMatcher.keyIsDefined(MotionTestInfoProviderKey))
75+
motionInfoNode.assertExists()
76+
val motionInfo = motionInfoNode.fetchSemanticsNode().config[MotionTestInfoProviderKey]
77+
val boundsStart = fetchBounds()
78+
79+
motionInfo.setProgress(0.5f)
80+
rule.waitForIdle()
81+
motionInfo.recompose()
82+
rule.waitForIdle()
83+
val boundsMidPoint = fetchBounds()
84+
85+
motionInfo.setProgress(1.0f)
86+
rule.waitForIdle()
87+
val boundsEnd = fetchBounds()
88+
89+
assert(boundsStart.size == boundsMidPoint.size && boundsMidPoint.size == boundsEnd.size)
90+
// Save the result in a composable->result map
91+
results[fqComposable] =
92+
MotionTestResult(boundsStart, boundsMidPoint, boundsEnd).printString()
93+
}
94+
val baselineResults = parseBaselineResults(baselineRaw)
95+
checkTest(baselineResults, results)
96+
}
97+
98+
private fun fetchBounds(): List<Rect> {
99+
return rule.onAllNodes(hasParent(SemanticsMatcher.keyIsDefined(MotionTestInfoProviderKey)))
100+
.fetchSemanticsNodes().map { it.boundsInRoot }
101+
}
102+
}
103+
104+
private data class MotionTestResult(
105+
val start: List<Rect>,
106+
val midPoint: List<Rect>,
107+
val end: List<Rect>
108+
) {
109+
fun printString(): String {
110+
val buffer = StringBuffer()
111+
start.joinTo(buffer, ",") { it.toString() }
112+
buffer.append(":")
113+
midPoint.joinTo(buffer, ",") { it.toString() }
114+
buffer.append(":")
115+
end.joinTo(buffer, ",") { it.toString() }
116+
return buffer.toString()
117+
}
118+
}

0 commit comments

Comments
 (0)