Skip to content

Commit

Permalink
[Compose] Remeasure MotionLayout on child size change
Browse files Browse the repository at this point in the history
- 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.
  • Loading branch information
oscar-ad committed Apr 22, 2022
1 parent 33ea839 commit 99a48f3
Show file tree
Hide file tree
Showing 8 changed files with 518 additions and 144 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.layout.*
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.*
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import androidx.constraintlayout.core.motion.Motion
import androidx.constraintlayout.core.parser.CLParser
import androidx.constraintlayout.core.parser.CLParsingException
Expand Down Expand Up @@ -362,7 +364,7 @@ internal inline fun MotionLayoutCore(
val measurer = remember { MotionMeasurer() }
val scope = remember { MotionLayoutScope(measurer) }
val progressState = remember { mutableStateOf(0f) }
SideEffect { progressState.value = progress }
progressState.value = progress
val measurePolicy =
rememberMotionLayoutMeasurePolicy(
optimizationLevel,
Expand All @@ -382,6 +384,7 @@ internal inline fun MotionLayoutCore(
if (!forcedScaleFactor.isNaN()) {
mod = modifier.scale(measurer.forcedScaleFactor)
}
// FIXME: Consider removing this Box wrapper, it breaks the expected behavior of the layout
Box {
@Suppress("DEPRECATION")
(MultiMeasureLayout(
Expand Down Expand Up @@ -690,8 +693,10 @@ internal class MotionMeasurer : Measurer() {
// make sure that the constraints/dimensions returned are for the start/current ConstraintSet

private fun measureConstraintSet(
optimizationLevel: Int, constraintSet: ConstraintSet,
measurables: List<Measurable>, constraints: Constraints
optimizationLevel: Int,
constraintSet: ConstraintSet,
measurables: List<Measurable>,
constraints: Constraints
) {
state.reset()
constraintSet.applyTo(state, measurables)
Expand Down Expand Up @@ -731,93 +736,140 @@ internal class MotionMeasurer : Measurer() {
): IntSize {
this.density = measureScope
this.measureScope = measureScope
// TODO: Add another check for whenever a measurable/child has changed size, that triggers a
// measure of the constraintsets and interpolation
var layoutSizeChanged = false
if (constraints.hasFixedWidth
&& !state.sameFixedWidth(constraints.maxWidth)
) {
layoutSizeChanged = true
}
if (constraints.hasFixedHeight
&& !state.sameFixedHeight(constraints.maxHeight)
) {
layoutSizeChanged = true
}

val needsRemeasure = needsRemeasure(constraints)

if (motionProgress != progress
|| (layoutInformationReceiver?.getForcedWidth() != Int.MIN_VALUE
&& layoutInformationReceiver?.getForcedHeight() != Int.MIN_VALUE)
|| this.transition.isEmpty()
|| frameCache.isEmpty()
|| layoutSizeChanged
|| needsRemeasure
) {
motionProgress = progress
if (layoutSizeChanged || this.transition.isEmpty() || frameCache.isEmpty()) {
this.transition.clear()
resetMeasureState()
state.reset()
// Define the size of the ConstraintLayout.
state.width(
if (constraints.hasFixedWidth) {
Dimension.Fixed(constraints.maxWidth)
} else {
Dimension.Wrap().min(constraints.minWidth)
}
)
state.height(
if (constraints.hasFixedHeight) {
Dimension.Fixed(constraints.maxHeight)
} else {
Dimension.Wrap().min(constraints.minHeight)
}
)
// Build constraint set and apply it to the state.
state.rootIncomingConstraints = constraints
state.layoutDirection = layoutDirection
recalculateInterpolation(
constraints = constraints,
layoutDirection = layoutDirection,
constraintSetStart = constraintSetStart,
constraintSetEnd = constraintSetEnd,
transition = transition,
measurables = measurables,
optimizationLevel = optimizationLevel,
progress = progress,
remeasure = needsRemeasure
)
}
return IntSize(root.width, root.height)
}

measureConstraintSet(
optimizationLevel, constraintSetStart, measurables, constraints
)
this.transition.updateFrom(root, Transition.START)
measureConstraintSet(
optimizationLevel, constraintSetEnd, measurables, constraints
)
this.transition.updateFrom(root, Transition.END)
if (transition != null) {
transition.applyTo(this.transition, 0)
/**
* Indicates if the layout requires measuring before computing the interpolation.
*
* This might happen if the size of MotionLayout or any of its children changed.
*
* MotionLayout size might change from its parent Layout, and in some cases the children size
* might change (eg: A Text layout has a longer string appended).
*/
private fun needsRemeasure(constraints: Constraints): Boolean {
if (this.transition.isEmpty || frameCache.isEmpty()) {
// Nothing measured (by MotionMeasurer)
return true
}

if ((constraints.hasFixedHeight && !state.sameFixedHeight(constraints.maxHeight))
|| (constraints.hasFixedWidth && !state.sameFixedWidth(constraints.maxWidth))) {
// Layout size changed
return true
}

return root.children.fastAny { child ->
// Check if measurables have changed their size
val measurable = (child.companionWidget as? Measurable) ?: return@fastAny false
val interpolatedFrame = this.transition.getInterpolated(child) ?: return@fastAny false
val placeable = placeables[measurable] ?: return@fastAny false
val currentWidth = placeable.width
val currentHeight = placeable.height

// Need to recalculate interpolation if the size of any element changed
return@fastAny currentWidth != interpolatedFrame.width()
|| currentHeight != interpolatedFrame.height()
}
}

/**
* Remeasures based on [constraintSetStart] and [constraintSetEnd] if needed.
*
* Runs the interpolation for the given [progress].
*
* Finally, updates the [Measurable]s dimension if they changed during interpolation.
*/
private fun recalculateInterpolation(
constraints: Constraints,
layoutDirection: LayoutDirection,
constraintSetStart: ConstraintSet,
constraintSetEnd: ConstraintSet,
transition: androidx.constraintlayout.compose.Transition?,
measurables: List<Measurable>,
optimizationLevel: Int,
progress: Float,
remeasure: Boolean
) {
motionProgress = progress
if (remeasure) {
this.transition.clear()
resetMeasureState()
state.reset()
// Define the size of the ConstraintLayout.
state.width(
if (constraints.hasFixedWidth) {
Dimension.Fixed(constraints.maxWidth)
} else {
Dimension.Wrap().min(constraints.minWidth)
}
}
this.transition.interpolate(root.width, root.height, progress)
var index = 0
for (child in root.children) {
val measurable = child.companionWidget
if (measurable !is Measurable) continue
var interpolatedFrame = this.transition.getInterpolated(child)
if (interpolatedFrame == null) {
continue
)
state.height(
if (constraints.hasFixedHeight) {
Dimension.Fixed(constraints.maxHeight)
} else {
Dimension.Wrap().min(constraints.minHeight)
}
val placeable = placeables[measurable]
val currentWidth = placeable?.width
val currentHeight = placeable?.height
if (placeable == null
|| currentWidth != interpolatedFrame.width()
|| currentHeight != interpolatedFrame.height()
) {
measurable.measure(
Constraints.fixed(interpolatedFrame.width(), interpolatedFrame.height())
)
.also {
placeables[measurable] = it
}
)
// Build constraint set and apply it to the state.
state.rootIncomingConstraints = constraints
state.layoutDirection = layoutDirection

measureConstraintSet(
optimizationLevel, constraintSetStart, measurables, constraints
)
this.transition.updateFrom(root, Transition.START)
measureConstraintSet(
optimizationLevel, constraintSetEnd, measurables, constraints
)
this.transition.updateFrom(root, Transition.END)
transition?.applyTo(this.transition, 0)
}

this.transition.interpolate(root.width, root.height, progress)

root.children.fastForEach { child ->
// Update measurables to the interpolated dimensions
val measurable = (child.companionWidget as? Measurable) ?: return@fastForEach
val interpolatedFrame = this.transition.getInterpolated(child) ?: return@fastForEach
val placeable = placeables[measurable]
val currentWidth = placeable?.width
val currentHeight = placeable?.height
if (placeable == null
|| currentWidth != interpolatedFrame.width()
|| currentHeight != interpolatedFrame.height()) {
measurable.measure(
Constraints.fixed(interpolatedFrame.width(), interpolatedFrame.height())
).also { newPlaceable ->
placeables[measurable] = newPlaceable
}
frameCache[measurable] = interpolatedFrame
index++
}
if (layoutInformationReceiver?.getLayoutInformationMode() == LayoutInfoFlags.BOUNDS) {
computeLayoutResult()
}
frameCache[measurable] = interpolatedFrame
}

if (layoutInformationReceiver?.getLayoutInformationMode() == LayoutInfoFlags.BOUNDS) {
computeLayoutResult()
}
return IntSize(root.width, root.height)
}

private fun encodeKeyFrames(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* Copyright (C) 2022 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.example.constraintlayout

import androidx.compose.runtime.currentComposer
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.hasParent
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.example.constraintlayout.verification.ComposableInvocator
import com.example.constraintlayout.verification.motiondsl.MotionTestInfoProviderKey
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@MediumTest
@RunWith(AndroidJUnit4::class)
class MotionVerificationTest {
@get:Rule
val rule = createComposeRule()

val invocator = ComposableInvocator(
packageString = "com.example.constraintlayout.verification.motiondsl",
fileName = "MotionDslVerification"
)

@Test
fun verifyComposables() {
// TODO: Test doesn't work very well with Json, some part in the helper parser is not
// stable, either from user-string to binary or binary to result-string
val results = HashMap<String, String>()

var composableIndex by mutableStateOf(0) // Observable state that we'll use to change the content in a recomposition
var fqComposable = ""
var baselineRaw = ""
rule.setContent {
// Set the content to the Composable at the given index
fqComposable = invocator.invokeComposable(composableIndex, currentComposer)
// We can only get the Resources in this context
baselineRaw =
LocalContext.current.resources.openRawResource(R.raw.motion_results)
.bufferedReader()
.readText()
}
for (i in 0..invocator.max) {
rule.runOnUiThread {
// Force a recomposition with the next Composable index
composableIndex = i
}
// Wait for the content to settle
rule.waitForIdle()

val motionInfoNode =
rule.onNode(SemanticsMatcher.keyIsDefined(MotionTestInfoProviderKey))
motionInfoNode.assertExists()
val motionInfo = motionInfoNode.fetchSemanticsNode().config[MotionTestInfoProviderKey]
val boundsStart = fetchBounds()

motionInfo.setProgress(0.5f)
rule.waitForIdle()
motionInfo.recompose()
rule.waitForIdle()
val boundsMidPoint = fetchBounds()

motionInfo.setProgress(1.0f)
rule.waitForIdle()
val boundsEnd = fetchBounds()

assert(boundsStart.size == boundsMidPoint.size && boundsMidPoint.size == boundsEnd.size)
// Save the result in a composable->result map
results[fqComposable] =
MotionTestResult(boundsStart, boundsMidPoint, boundsEnd).printString()
}
val baselineResults = parseBaselineResults(baselineRaw)
checkTest(baselineResults, results)
}

private fun fetchBounds(): List<Rect> {
return rule.onAllNodes(hasParent(SemanticsMatcher.keyIsDefined(MotionTestInfoProviderKey)))
.fetchSemanticsNodes().map { it.boundsInRoot }
}
}

private data class MotionTestResult(
val start: List<Rect>,
val midPoint: List<Rect>,
val end: List<Rect>
) {
fun printString(): String {
val buffer = StringBuffer()
start.joinTo(buffer, ",") { it.toString() }
buffer.append(":")
midPoint.joinTo(buffer, ",") { it.toString() }
buffer.append(":")
end.joinTo(buffer, ",") { it.toString() }
return buffer.toString()
}
}
Loading

0 comments on commit 99a48f3

Please sign in to comment.