[go: nahoru, domu]

blob: 378d5b729d763a3f88ab67215f6dffe7c5667952 [file] [log] [blame]
/*
* Copyright 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.
*/
@file:OptIn(ExperimentalComposeUiApi::class)
package androidx.compose.ui.layout
import androidx.activity.ComponentActivity
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector2D
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.Absolute.SpaceAround
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collection.MutableVector
import androidx.compose.runtime.collection.mutableVectorOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Matrix
import androidx.compose.ui.node.LayoutModifierNode
import androidx.compose.ui.node.LayoutNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.platform.AndroidOwnerExtraAssertionsRule
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.round
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastForEachIndexed
import androidx.compose.ui.util.fastMap
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import java.lang.Integer.max
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertTrue
import kotlin.math.roundToInt
import kotlin.random.Random
import kotlinx.coroutines.launch
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
const val Debug = false
@MediumTest
@RunWith(AndroidJUnit4::class)
class LookaheadScopeTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@get:Rule
val excessiveAssertions = AndroidOwnerExtraAssertionsRule()
@Test
fun randomLookaheadPlacementOrder() {
val nodeList = List(10) { node() }
val placementOrder = (0..9).toMutableList()
fun generateRandomPlaceOrder() {
repeat(9) {
val swapId = Random.nextInt(it, 10)
val tmp = placementOrder[it]
placementOrder[it] = placementOrder[swapId]
placementOrder[swapId] = tmp
}
}
val root = node {
add(LayoutNode(isVirtual = true).apply {
isVirtualLookaheadRoot = true
add(node {
generateRandomPlaceOrder()
add(nodeList[0])
add(LayoutNode(isVirtual = true).apply {
repeat(4) {
add(nodeList[it + 1])
}
})
add(LayoutNode(isVirtual = true).apply {
repeat(5) {
add(nodeList[5 + it])
}
})
measurePolicy = MeasurePolicy { measurables, constraints ->
assertEquals(10, measurables.size)
val placeables = measurables.fastMap { it.measure(constraints) }
assertEquals(10, placeables.size)
layout(100, 100) {
placementOrder.fastForEach { id ->
placeables[id].place(0, 0)
}
}
}
})
}
)
}
val delegate = createDelegate(root)
repeat(5) {
placementOrder.fastForEachIndexed { placeOrder, nodeId ->
assertEquals(placeOrder, nodeList[nodeId].lookaheadPassDelegate!!.placeOrder)
assertEquals(placeOrder, nodeList[nodeId].measurePassDelegate.placeOrder)
}
generateRandomPlaceOrder()
root.children[0].requestLookaheadRemeasure()
delegate.measureAndLayout()
}
}
@Test
fun defaultIntermediateMeasurePolicyInSubcomposeLayout() {
val expectedSizes = listOf(
IntSize(200, 100),
IntSize(400, 300),
IntSize(100, 500),
IntSize(20, 5),
IntSize(90, 120)
)
val targetSize = IntSize(260, 350)
var actualSize by mutableStateOf(IntSize.Zero)
var actualTargetSize by mutableStateOf(IntSize.Zero)
var iteration by mutableStateOf(0)
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
SubcomposeLayout(
Modifier
.requiredSize(targetSize.width.dp, targetSize.height.dp)
.intermediateLayout { measurable, _ ->
val intermediateConstraints = Constraints.fixed(
expectedSizes[iteration].width,
expectedSizes[iteration].height
)
measurable
.measure(intermediateConstraints)
.run {
layout(width, height) { place(0, 0) }
}
}) { constraints ->
val placeable = subcompose(0) {
Box(Modifier.fillMaxSize())
}[0].measure(constraints)
val size = placeable.run { IntSize(width, height) }
if (this is SubcomposeIntermediateMeasureScope) {
actualSize = size
} else {
actualTargetSize = size
}
layout(size.width, size.height) {
placeable.place(0, 0)
}
}
}
}
repeat(5) {
rule.runOnIdle {
assertEquals(targetSize, actualTargetSize)
assertEquals(expectedSizes[iteration], actualSize)
if (iteration < 4) {
iteration++
}
}
}
}
@Test
fun lookaheadLayoutAnimation() {
var isLarge by mutableStateOf(true)
var size1 = IntSize.Zero
var size2 = IntSize.Zero
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
LookaheadScope {
Row(if (isLarge) Modifier.size(200.dp) else Modifier.size(50.dp, 100.dp)) {
Box(
Modifier
.fillMaxHeight()
.weight(2f)
.onSizeChanged {
size1 = it
}
.animateSize())
Box(
Modifier
.fillMaxHeight()
.weight(3f)
.onSizeChanged {
size2 = it
}
.animateSize())
}
}
}
}
// Check that:
// 1) size changes happen when parent constraints change,
// 2) animations finish and actual measurements get updated by animation,
// 3) during the animation the tree is consistent.
rule.runOnIdle {
assertEquals(IntSize(80, 200), size1)
assertEquals(IntSize(120, 200), size2)
isLarge = false
}
rule.runOnIdle {
assertEquals(IntSize(20, 100), size1)
assertEquals(IntSize(30, 100), size2)
isLarge = true
}
rule.runOnIdle {
assertEquals(IntSize(80, 200), size1)
assertEquals(IntSize(120, 200), size2)
}
}
private fun Modifier.animateSize(): Modifier = composed {
var anim: Animatable<IntSize, AnimationVector2D>? by remember { mutableStateOf(null) }
this.intermediateLayout { measurable, _ ->
anim = anim?.apply {
launch {
if (lookaheadSize != targetValue) {
animateTo(lookaheadSize, tween(200))
}
}
} ?: Animatable(lookaheadSize, IntSize.VectorConverter)
val (width, height) = anim!!.value
val placeable = measurable.measure(Constraints.fixed(width, height))
layout(placeable.width, placeable.height) {
placeable.place(0, 0)
}
}
}
@Test
fun nestedLookaheadLayoutTest() {
var parentLookaheadMeasure = 0
var childLookaheadMeasure = 0
var parentLookaheadPlace = 0
var childLookaheadPlace = 0
var parentMeasure = 0
var childMeasure = 0
var parentPlace = 0
var childPlace = 0
var rootPreMeasure = 0
var rootPrePlace = 0
var rootPostMeasure = 0
var rootPostPlace = 0
var counter = 0
rule.setContent {
// The right sequence for this nested lookahead layout setup:
// parentLookaheadMeasure -> childLookaheadMeasure -> parentMeasure -> childMeasure
// -> parentLookaheadPlace -> childLookaheadPlace -> -> parentPlace -> childPlace
// Each event should happen exactly once in the end.
Box(Modifier.layout(
measureWithLambdas(
preMeasure = { rootPreMeasure = ++counter },
postMeasure = { rootPostMeasure = ++counter },
prePlacement = { rootPrePlace = ++counter },
postPlacement = { rootPostPlace = ++counter }
)
)) {
MyLookaheadLayout {
Box(
Modifier
.padding(top = 100.dp)
.fillMaxSize()
.intermediateLayout { measurable, constraints ->
measureWithLambdas(
preMeasure = { parentMeasure = ++counter },
prePlacement = { parentPlace = ++counter }
).invoke(this, measurable, constraints)
}
.layout(
measureWithLambdas(
preMeasure = {
if (parentLookaheadMeasure == 0) {
// Only the first invocation is for lookahead
parentLookaheadMeasure = ++counter
}
},
prePlacement = {
if (parentLookaheadPlace == 0) {
// Only the first invocation is for lookahead
parentLookaheadPlace = ++counter
}
}
)
)
) {
MyLookaheadLayout {
Column {
Box(
Modifier
.size(100.dp)
.background(Color.Red)
.intermediateLayout { measurable, constraints ->
measureWithLambdas(
preMeasure = { childMeasure = ++counter },
prePlacement = { childPlace = ++counter }
).invoke(this, measurable, constraints)
}
.layout(
measure = measureWithLambdas(
preMeasure = {
if (childLookaheadMeasure == 0) {
childLookaheadMeasure = ++counter
}
},
prePlacement = {
if (childLookaheadPlace == 0) {
childLookaheadPlace = ++counter
}
}
)
)
)
Box(
Modifier
.size(100.dp)
.background(Color.Green)
)
}
}
}
}
}
}
rule.runOnIdle {
assertEquals(1, rootPreMeasure)
assertEquals(2, parentLookaheadMeasure)
assertEquals(3, childLookaheadMeasure)
assertEquals(4, parentMeasure)
assertEquals(5, childMeasure)
assertEquals(6, rootPostMeasure)
// Measure finished. Then placement.
assertEquals(7, rootPrePlace)
assertEquals(8, parentLookaheadPlace)
assertEquals(9, childLookaheadPlace)
assertEquals(10, parentPlace)
assertEquals(11, childPlace)
assertEquals(12, rootPostPlace)
}
}
@Test
fun parentObserveActualMeasurementTest() {
val width = 200
val height = 120
var scaleFactor by mutableStateOf(0.1f)
var parentSize = IntSize.Zero
var grandParentSize = IntSize.Zero
var greatGrandParentSize = IntSize.Zero
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
Column(
Modifier.layout(measureWithLambdas(postMeasure = { greatGrandParentSize = it }))
) {
Row(
Modifier.layout(measureWithLambdas(postMeasure = { grandParentSize = it }))
) {
Box(
Modifier.layout(measureWithLambdas(postMeasure = { parentSize = it }))
) {
MyLookaheadLayout {
Box(modifier = Modifier
.intermediateLayout { measurable, constraints ->
assertEquals(width, lookaheadSize.width)
assertEquals(height, lookaheadSize.height)
val placeable = measurable.measure(constraints)
layout(
(scaleFactor * width).roundToInt(),
(scaleFactor * height).roundToInt()
) {
placeable.place(0, 0)
}
}
.size(width.dp, height.dp))
}
}
Spacer(modifier = Modifier.size(20.dp))
}
Spacer(modifier = Modifier.size(50.dp))
}
}
}
val size = IntSize(width, height)
repeat(20) {
rule.runOnIdle {
assertEquals(size * scaleFactor, parentSize)
assertEquals((size * scaleFactor).width + 20, grandParentSize.width)
assertEquals(max((size * scaleFactor).height, 20), grandParentSize.height)
assertEquals(max(grandParentSize.width, 50), greatGrandParentSize.width)
assertEquals(grandParentSize.height + 50, greatGrandParentSize.height)
scaleFactor += 0.1f
}
}
}
private operator fun IntSize.times(multiplier: Float): IntSize =
IntSize((width * multiplier).roundToInt(), (height * multiplier).roundToInt())
@Test
fun noExtraLookaheadTest() {
var parentMeasure = 0
var parentPlace = 0
var measurePlusLookahead = 0
var placePlusLookahead = 0
var measure = 0
var place = 0
var isSmall by mutableStateOf(true)
var controlGroupEnabled by mutableStateOf(true)
var controlGroupParentMeasure = 0
var controlGroupParentPlace = 0
var controlGroupMeasure = 0
var controlGroupPlace = 0
rule.setContent {
if (controlGroupEnabled) {
Box(
Modifier.layout(
measureWithLambdas(
postMeasure = { controlGroupParentMeasure++ },
postPlacement = { controlGroupParentPlace++ }
)
)
) {
Layout(measurePolicy = defaultMeasurePolicy, content = {
Box(
Modifier
.size(if (isSmall) 100.dp else 200.dp)
.layout(
measureWithLambdas(
postMeasure = { controlGroupMeasure++ },
postPlacement = { controlGroupPlace++ },
)
)
)
})
}
} else {
Box(
Modifier.layout(
measureWithLambdas(
postMeasure = { parentMeasure++ },
postPlacement = { parentPlace++ }
)
)
) {
MyLookaheadLayout {
Box(
Modifier
.size(if (isSmall) 100.dp else 200.dp)
.animateSize()
.layout(
measureWithLambdas(
postMeasure = { measurePlusLookahead++ },
postPlacement = { placePlusLookahead++ },
)
)
.intermediateLayout { measurable, constraints ->
measureWithLambdas(
postMeasure = { measure++ },
postPlacement = { place++ }
).invoke(this, measurable, constraints)
}
)
}
}
}
}
rule.runOnIdle {
assertEquals(1, controlGroupParentMeasure)
assertEquals(1, controlGroupParentPlace)
assertEquals(1, controlGroupMeasure)
assertEquals(1, controlGroupPlace)
isSmall = !isSmall
}
rule.runOnIdle {
// Check the starting condition before switching over from control group
assertEquals(0, parentMeasure)
assertEquals(0, parentPlace)
assertEquals(0, measurePlusLookahead)
assertEquals(0, placePlusLookahead)
assertEquals(0, measure)
assertEquals(0, place)
// Switch to LookaheadLayout
controlGroupEnabled = !controlGroupEnabled
}
rule.runOnIdle {
// Expects 1
assertEquals(1, parentMeasure)
assertEquals(1, parentPlace)
val lookaheadMeasure = measurePlusLookahead - measure
val lookaheadPlace = placePlusLookahead - place
assertEquals(1, lookaheadMeasure)
assertEquals(1, lookaheadPlace)
}
// Pump frames so that animation triggered measurements are not completely dependent on
// system timing.
rule.mainClock.autoAdvance = false
rule.runOnIdle {
isSmall = !isSmall
}
repeat(10) {
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
}
rule.mainClock.autoAdvance = true
rule.runOnIdle {
// Compare number of lookahead measurements & placements with control group.
assertEquals(controlGroupParentMeasure, parentMeasure)
assertEquals(controlGroupParentPlace, parentPlace)
val lookaheadMeasure = measurePlusLookahead - measure
val lookaheadPlace = placePlusLookahead - place
assertEquals(controlGroupMeasure, lookaheadMeasure)
assertEquals(controlGroupPlace, lookaheadPlace)
assertTrue(lookaheadMeasure < measure)
assertTrue(lookaheadPlace < place)
}
}
@Test
fun defaultMeasurePolicyInSubcomposeLayout() {
var actualLookaheadSize by mutableStateOf(IntSize.Zero)
var defaultIntermediateMeasureSize by mutableStateOf(IntSize.Zero)
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
LookaheadScope {
SubcomposeLayout(
Modifier
.fillMaxSize()
.requiredSize(200.dp),
intermediateMeasurePolicy = { constraints ->
measurablesForSlot(Unit)[0].measure(constraints)
actualLookaheadSize = this.lookaheadSize
layout(0, 0) {}
}
) { constraints ->
val placeable = subcompose(Unit) {
Box(Modifier.requiredSize(400.dp, 600.dp))
}[0].measure(constraints)
layout(500, 300) {
placeable.place(0, 0)
}
}
SubcomposeLayout(
Modifier
.size(150.dp)
.intermediateLayout { measurable, _ ->
measurable
.measure(Constraints(0, 2000, 0, 2000))
.run {
defaultIntermediateMeasureSize = IntSize(width, height)
layout(width, height) {
place(0, 0)
}
}
}
) { constraints ->
val placeable = subcompose(Unit) {
Box(Modifier.requiredSize(400.dp, 600.dp))
}[0].measure(constraints)
layout(500, 300) {
placeable.place(0, 0)
}
}
}
}
}
rule.runOnIdle {
assertEquals(IntSize(500, 300), actualLookaheadSize)
assertEquals(IntSize(500, 300), defaultIntermediateMeasureSize)
}
}
@Test
fun lookaheadStaysTheSameDuringAnimationTest() {
var isLarge by mutableStateOf(true)
var parentLookaheadSize = IntSize.Zero
var child1LookaheadSize = IntSize.Zero
var child2LookaheadSize = IntSize.Zero
rule.setContent {
LookaheadScope {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
Row(
(if (isLarge) Modifier.size(200.dp) else Modifier.size(50.dp, 100.dp))
.intermediateLayout { measurable, constraints ->
parentLookaheadSize = lookaheadSize
measureWithLambdas().invoke(this, measurable, constraints)
}
) {
Box(
Modifier
.fillMaxHeight()
.weight(2f)
.intermediateLayout { measurable, constraints ->
child1LookaheadSize = lookaheadSize
measureWithLambdas().invoke(this, measurable, constraints)
}
.animateSize()
)
Box(
Modifier
.fillMaxHeight()
.weight(3f)
.intermediateLayout { measurable, constraints ->
child2LookaheadSize = lookaheadSize
measureWithLambdas().invoke(this, measurable, constraints)
}
.animateSize()
)
}
}
}
}
rule.waitForIdle()
rule.runOnIdle {
assertEquals(IntSize(200, 200), parentLookaheadSize)
assertEquals(IntSize(80, 200), child1LookaheadSize)
assertEquals(IntSize(120, 200), child2LookaheadSize)
rule.mainClock.autoAdvance = false
isLarge = false
}
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
repeat(10) {
rule.runOnIdle {
assertEquals(IntSize(50, 100), parentLookaheadSize)
assertEquals(IntSize(20, 100), child1LookaheadSize)
assertEquals(IntSize(30, 100), child2LookaheadSize)
}
rule.mainClock.advanceTimeByFrame()
}
rule.runOnIdle {
isLarge = true
}
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
repeat(10) {
rule.runOnIdle {
assertEquals(IntSize(200, 200), parentLookaheadSize)
assertEquals(IntSize(80, 200), child1LookaheadSize)
assertEquals(IntSize(120, 200), child2LookaheadSize)
}
rule.mainClock.advanceTimeByFrame()
}
}
@Test
fun skipPlacementOnlyPostLookahead() {
var child1TotalPlacement = 0
var child1Placement = 0
var child2TotalPlacement = 0
var child2Placement = 0
rule.setContent {
MyLookaheadLayout {
Row(Modifier.widthIn(100.dp, 200.dp)) {
Box(
modifier = Modifier
.intermediateLayout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
// skip placement in the post-lookahead placement pass
}
}
.weight(1f)
.layout { measurable, constraints ->
measureWithLambdas(
prePlacement = { child1TotalPlacement++ }
).invoke(this, measurable, constraints)
}
.intermediateLayout { measurable, constraints ->
measureWithLambdas(prePlacement = { child1Placement++ })
.invoke(this, measurable, constraints)
}
)
Box(
modifier = Modifier
.layout { measurable, constraints ->
measureWithLambdas(
prePlacement = { child2TotalPlacement++ }
).invoke(this, measurable, constraints)
}
.intermediateLayout { measurable, constraints ->
measureWithLambdas(prePlacement = { child2Placement++ })
.invoke(this, measurable, constraints)
}
.weight(3f)
)
Box(modifier = Modifier.sizeIn(50.dp))
}
}
}
rule.runOnIdle {
// Child1 skips post-lookahead placement
assertEquals(0, child1Placement)
// Child2 is placed in post-lookahead placement
assertEquals(1, child2Placement)
val child1LookaheadPlacement = child1TotalPlacement - child1Placement
val child2LookaheadPlacement = child2TotalPlacement - child2Placement
// Both child1 & child2 should be placed in lookahead, since the skipping only
// applies to regular placement pass, as per API contract in `intermediateLayout`
assertEquals(1, child1LookaheadPlacement)
assertEquals(1, child2LookaheadPlacement)
}
}
@Composable
private fun MyLookaheadLayout(
modifier: Modifier = Modifier,
postMeasure: () -> Unit = {},
postPlacement: () -> Unit = {},
content: @Composable LookaheadScope.() -> Unit
) {
Box(modifier.layout { measurable, constraints ->
measurable.measure(constraints).run {
postMeasure()
// Position the children.
layout(width, height) {
place(0, 0)
}.apply {
postPlacement()
}
}
}) {
LookaheadScope(content)
}
}
@Test
fun alterPlacementTest() {
var placementCount = 0
var totalPlacementCount = 0
var shouldPlace by mutableStateOf(false)
rule.setContent {
MyLookaheadLayout {
Layout(
content = {
Box(Modifier
.intermediateLayout { measurable, constraints ->
measureWithLambdas(prePlacement = {
placementCount++
}).invoke(this, measurable, constraints)
}
.layout { measurable, constraints ->
measureWithLambdas(prePlacement = {
totalPlacementCount++
}).invoke(this, measurable, constraints)
})
}
) { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints) }
val maxWidth: Int = placeables.maxOf { it.width }
val maxHeight = placeables.maxOf { it.height }
// Position the children.
layout(maxWidth, maxHeight) {
if (shouldPlace) {
placeables.forEach {
it.place(0, 0)
}
}
}
}
}
}
rule.runOnIdle {
assertEquals(0, totalPlacementCount)
assertEquals(0, placementCount)
shouldPlace = true
}
rule.runOnIdle {
val lookaheadPlacementCount = totalPlacementCount - placementCount
assertEquals(1, lookaheadPlacementCount)
assertEquals(1, placementCount)
}
}
@Test
fun localLookaheadPositionOfFromDisjointedLookaheadLayoutsTest() {
var firstCoordinates: LayoutCoordinates? = null
var secondCoordinates: LayoutCoordinates? = null
fun LookaheadScope.assertEqualOffset() {
val offset = secondCoordinates!!.localPositionOf(firstCoordinates!!, Offset.Zero)
val lookaheadOffset = secondCoordinates!!.localLookaheadPositionOf(firstCoordinates!!)
assertEquals(offset, lookaheadOffset)
}
rule.setContent {
Row(
Modifier.fillMaxSize(),
horizontalArrangement = SpaceAround,
verticalAlignment = Alignment.CenterVertically
) {
LookaheadScope {
Box(
Modifier
.size(200.dp)
.onPlaced { coords ->
firstCoordinates = coords
})
}
Box(
Modifier
.padding(top = 30.dp, start = 70.dp)
.offset(40.dp, 60.dp)
) {
LookaheadScope {
Box(
Modifier
.size(100.dp, 50.dp)
.onPlaced { coords ->
secondCoordinates = coords
assertEqualOffset()
})
}
}
}
}
}
@Test
fun localLookaheadPositionOfFromNestedLookaheadLayoutsTest() {
var firstCoordinates: LayoutCoordinates? = null
var secondCoordinates: LayoutCoordinates? = null
fun LookaheadScope.assertEqualOffset() {
val offset = secondCoordinates!!.localPositionOf(firstCoordinates!!, Offset.Zero)
val lookaheadOffset = secondCoordinates!!.localLookaheadPositionOf(firstCoordinates!!)
assertEquals(offset, lookaheadOffset)
}
rule.setContent {
MyLookaheadLayout {
Row(
Modifier
.fillMaxSize()
.onPlaced { it -> firstCoordinates = it },
horizontalArrangement = SpaceAround,
verticalAlignment = Alignment.CenterVertically
) {
Box(Modifier.size(200.dp))
Box(
Modifier
.padding(top = 30.dp, start = 70.dp)
.offset(40.dp, 60.dp)
) {
MyLookaheadLayout {
Box(
Modifier
.size(100.dp, 50.dp)
.onPlaced { it ->
secondCoordinates = it
})
}
}
}
}
}
}
@Test
fun lookaheadMaxHeightIntrinsicsTest() {
assertSameLayoutWithAndWithoutLookahead { modifier ->
Box(modifier) {
Row(modifier.height(IntrinsicSize.Max)) {
Box(
modifier
.fillMaxHeight()
.weight(1f)
.aspectRatio(2f)
.background(Color.Gray)
)
Box(
modifier
.fillMaxHeight()
.weight(1f)
.width(1.dp)
.background(Color.Black)
)
Box(
modifier
.fillMaxHeight()
.weight(1f)
.aspectRatio(1f)
.background(Color.Blue)
)
}
}
}
}
@Test
fun lookaheadMinHeightIntrinsicsTest() {
assertSameLayoutWithAndWithoutLookahead { modifier ->
Box {
Row(modifier.height(IntrinsicSize.Min)) {
Text(
text = "This is a really short text",
modifier = modifier
.weight(1f)
.fillMaxHeight()
)
Box(
modifier
.width(1.dp)
.fillMaxHeight()
.background(Color.Black)
)
Text(
text = "This is a much much much much much much much much much much" +
" much much much much much much longer text",
modifier = modifier
.weight(1f)
.fillMaxHeight()
)
}
}
}
}
@Test
fun lookaheadMinWidthIntrinsicsTest() {
assertSameLayoutWithAndWithoutLookahead { modifier ->
Column(
modifier
.width(IntrinsicSize.Min)
.wrapContentHeight()
) {
Box(
modifier = modifier
.fillMaxWidth()
.size(20.dp, 10.dp)
.background(Color.Gray)
)
Box(
modifier = modifier
.fillMaxWidth()
.size(30.dp, 10.dp)
.background(Color.Blue)
)
Box(
modifier = modifier
.fillMaxWidth()
.size(10.dp, 10.dp)
.background(Color.Magenta)
)
}
}
}
@Test
fun lookaheadMaxWidthIntrinsicsTest() {
assertSameLayoutWithAndWithoutLookahead { modifier ->
Box {
Column(
modifier
.width(IntrinsicSize.Max)
.wrapContentHeight()
) {
Box(
modifier
.fillMaxWidth()
.background(Color.Gray)
) {
Text("Short text")
}
Box(
modifier
.fillMaxWidth()
.background(Color.Blue)
) {
Text("Extremely long text giving the width of its siblings")
}
Box(
modifier
.fillMaxWidth()
.background(Color.Magenta)
) {
Text("Medium length text")
}
}
}
}
}
@Test
fun intermediateLayoutMaxHeightIntrinsicsTest() {
var rowHeight: Int by mutableStateOf(0)
var fraction: Float by mutableStateOf(0f)
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
LookaheadScope {
Row(
Modifier
.height(IntrinsicSize.Max)
.onGloballyPositioned {
rowHeight = it.size.height
}
.fillMaxWidth()
) {
Box(
Modifier
.intermediateLayout { measurable, constraints ->
measurable
.measure(constraints)
.run {
layout(
lookaheadSize.width,
(lookaheadSize.height * fraction).roundToInt()
) {
place(0, 0)
}
}
}
.width(5.dp)
.requiredHeight(300.dp))
Box(
Modifier
.requiredHeight(20.dp)
.weight(1f)
)
}
}
}
}
repeat(11) {
fraction = 0.1f * it
rule.runOnIdle {
val expectedHeight = max((300 * fraction).toInt(), 20)
assertEquals(expectedHeight, rowHeight)
}
}
}
@Test
fun intermediateLayoutMinHeightIntrinsicsTest() {
var rowHeight: Int by mutableStateOf(0)
var fraction: Float by mutableStateOf(0f)
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
LookaheadScope {
Row(
Modifier
.height(IntrinsicSize.Min)
.onGloballyPositioned {
rowHeight = it.size.height
}
.fillMaxWidth()
) {
Box(
Modifier
.intermediateLayout { measurable, constraints ->
measurable
.measure(constraints)
.run {
layout(
lookaheadSize.width,
(lookaheadSize.height * fraction).roundToInt()
) {
place(0, 0)
}
}
}
.width(5.dp)
.requiredHeight(300.dp))
Box(
Modifier
.requiredHeight(20.dp)
.weight(1f)
)
}
}
}
}
repeat(11) {
fraction = 0.1f * it
rule.runOnIdle {
val expectedHeight = max((300 * fraction).toInt(), 20)
assertEquals(expectedHeight, rowHeight)
}
}
}
@Test
fun intermediateLayoutMinWidthIntrinsicsTest() {
var rowWidth: Int by mutableStateOf(0)
var fraction: Float by mutableStateOf(0f)
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
LookaheadScope {
Column(
Modifier
.width(IntrinsicSize.Min)
.onGloballyPositioned {
rowWidth = it.size.width
}
.height(50.dp)
) {
Box(
Modifier
.intermediateLayout { measurable, constraints ->
measurable
.measure(constraints)
.run {
layout(
(lookaheadSize.width * fraction).roundToInt(),
lookaheadSize.height
) {
place(0, 0)
}
}
}
.requiredWidth(300.dp))
Box(
Modifier
.requiredWidth(120.dp)
.height(10.dp)
)
Text(
text =
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec" +
" non felis euismod nunc commodo pharetra a nec eros. Sed varius," +
" metus sed facilisis condimentum, orci orci aliquet arcu",
Modifier.fillMaxWidth(),
)
}
}
}
}
repeat(11) {
fraction = 0.1f * it
rule.runOnIdle {
val expectedWidth = max((300 * fraction).toInt(), 120)
assertEquals(expectedWidth, rowWidth)
}
}
}
@Test
fun intermediateLayoutMaxWidthIntrinsicsTest() {
var boxSize: IntSize by mutableStateOf(IntSize.Zero)
var childBoxSize: IntSize by mutableStateOf(IntSize.Zero)
var fraction: Float by mutableStateOf(0f)
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
LookaheadScope {
Box(
Modifier
.height(50.dp)
.width(IntrinsicSize.Max)
.onGloballyPositioned {
boxSize = it.size
}) {
Box(
Modifier
.intermediateLayout { measurable, constraints ->
measurable
.measure(constraints)
.run {
layout(
(lookaheadSize.width * fraction).roundToInt(),
lookaheadSize.height
) {
place(0, 0)
}
}
}
.requiredWidth(100.dp))
Box(
Modifier
.fillMaxWidth()
.height(10.dp)
.onGloballyPositioned {
childBoxSize = it.size
})
}
}
}
}
repeat(11) {
fraction = 0.1f * it
rule.runOnIdle {
assertEquals(IntSize((100 * fraction).toInt(), 50), boxSize)
assertEquals(IntSize((100 * fraction).toInt(), 10), childBoxSize)
}
}
}
@Test
fun firstBaselineAlignmentInLookaheadLayout() {
assertSameLayoutWithAndWithoutLookahead { modifier ->
Box(modifier.fillMaxWidth()) {
Row {
Text("Short", modifier.alignByBaseline())
Text("3\nline\n\text", modifier.alignByBaseline())
Text(
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do" +
" eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim" +
" ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut" +
" aliquip ex ea commodo consequat. Duis aute irure dolor in" +
" reprehenderit in voluptate velit esse cillum dolore eu fugiat" +
" nulla pariatur. Excepteur sint occaecat cupidatat non proident," +
" sunt in culpa qui officia deserunt mollit anim id est laborum.",
modifier.alignByBaseline()
)
}
}
}
}
@Test
fun grandparentQueryBaseline() {
assertSameLayoutWithAndWithoutLookahead { modifier ->
Layout(modifier = modifier, content = {
Row(
modifier
.fillMaxWidth()
.wrapContentHeight()
.background(Color(0xffb4c8ea)),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "First",
fontSize = 80.sp,
color = Color.White,
modifier = modifier
.alignByBaseline()
.background(color = Color(0xfff3722c), RoundedCornerShape(10))
)
Spacer(modifier.size(10.dp))
Text(
text = "Second",
color = Color.White,
fontSize = 30.sp,
modifier = modifier
.alignByBaseline()
.background(color = Color(0xff90be6d), RoundedCornerShape(10))
)
Spacer(modifier.size(10.dp))
Text(
text = "Text",
fontSize = 50.sp,
color = Color.White,
modifier = modifier
.alignByBaseline()
.background(color = Color(0xffffb900), RoundedCornerShape(10))
)
}
Spacer(
modifier
.fillMaxWidth()
.requiredHeight(1.dp)
.background(Color.Black)
)
}) { measurables, constraints ->
val placeables = measurables.map {
it.measure(constraints)
}
val row = placeables.first()
val position = row[FirstBaseline]
layout(row.width, row.height) {
row.place(0, 0)
placeables[1].place(0, position)
}
}
}
}
@Test
fun lookaheadLayoutTransformFrom() {
val matrix = Matrix()
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
LookaheadScope {
Layout(
measurePolicy = { measurables, constraints ->
val placeable = measurables[0].measure(constraints)
// Position the children.
layout(placeable.width + 10, placeable.height + 10) {
placeable.place(10, 10)
}
},
content = {
Box(
Modifier
.intermediateLayout { measurable, constraints ->
measurable
.measure(constraints)
.run {
layout(width, height) {
coordinates!!.transformFrom(
lookaheadScopeCoordinates,
matrix
)
}
}
}
.size(10.dp))
}
)
}
}
}
rule.waitForIdle()
val posInChild = matrix.map(Offset(10f, 10f))
assertEquals(Offset.Zero, posInChild)
}
@Test
fun moveLookaheadScope() {
var scopePositionInRoot by mutableStateOf(IntOffset(Int.MAX_VALUE, Int.MAX_VALUE))
var firstBox by mutableStateOf(true)
rule.setContent {
CompositionLocalProvider(LocalDensity.provides(Density(1f))) {
Box {
val movableContent = remember {
movableContentOf {
LookaheadScope {
Box(
Modifier
.layout { measurable, constraints ->
measurable
.measure(constraints)
.run {
layout(width, height) {
scopePositionInRoot =
lookaheadScopeCoordinates
.localToRoot(Offset.Zero)
.round()
place(0, 0)
}
}
}
.size(200.dp))
}
}
}
Box(Modifier.offset(100.dp, 5.dp)) {
if (firstBox) {
movableContent()
}
}
Box(Modifier.offset(40.dp, 200.dp)) {
if (!firstBox) {
movableContent()
}
}
}
}
}
rule.waitForIdle()
assertEquals(IntOffset(100, 5), scopePositionInRoot)
firstBox = false
rule.waitForIdle()
assertEquals(IntOffset(40, 200), scopePositionInRoot)
}
@Test
fun moveIntermediateLayout() {
var positionInScope by mutableStateOf(IntOffset(Int.MAX_VALUE, Int.MAX_VALUE))
var boxId by mutableStateOf(1)
rule.setContent {
CompositionLocalProvider(LocalDensity.provides(Density(1f))) {
val movableContent = remember {
movableContentOf {
Box(
Modifier
.intermediateLayout { measurable, constraints ->
measurable
.measure(constraints)
.run {
layout(width, height) {
coordinates?.let {
positionInScope =
lookaheadScopeCoordinates
.localLookaheadPositionOf(
it
)
.round()
}
}
}
}
.size(200.dp))
}
}
Box {
LookaheadScope {
Box(Modifier.offset(100.dp, 5.dp)) {
if (boxId == 1) {
movableContent()
}
}
}
}
Box(Modifier.offset(40.dp, 200.dp)) {
if (boxId == 2) {
movableContent()
}
}
Box(Modifier
.offset(50.dp, 50.dp)
.intermediateLayout { measurable, constraints ->
measurable
.measure(constraints)
.run {
layout(width, height) {
place(0, 0)
}
}
}
.offset(60.dp, 60.dp)) {
if (boxId == 3) {
movableContent()
}
}
}
}
rule.waitForIdle()
assertEquals(IntOffset(100, 5), positionInScope)
boxId++
rule.waitForIdle()
// Expect no offset when moving intermediateLayout out of LookaheadScope, as the implicitly
// created lookahead scope will have the same coordinates as intermediateLayout
assertEquals(IntOffset(0, 0), positionInScope)
boxId++
rule.waitForIdle()
// Expect the lookaheadScope to be created by the ancestor intermediateLayoutModifier
assertEquals(IntOffset(60, 60), positionInScope)
}
@Test
fun nestedVirtualNodes() {
val root = node().also {
createDelegate(it)
}
val virtualGrandParent = LayoutNode(isVirtual = true)
val virtualParent = LayoutNode(isVirtual = true)
val child = node()
root.add(virtualGrandParent)
virtualGrandParent.add(virtualParent)
virtualParent.add(child)
val newChild = node()
rule.runOnIdle {
assertEquals(1, root.children.size)
assertEquals(1, virtualGrandParent.children.size)
assertEquals(1, virtualParent.children.size)
assertEquals(child, root.children[0])
assertEquals(child, virtualGrandParent.children[0])
assertEquals(child, virtualParent.children[0])
// Add another child to virtual parent.
virtualParent.add(newChild)
}
rule.runOnIdle {
assertEquals(2, root.children.size)
assertEquals(2, virtualGrandParent.children.size)
assertEquals(2, virtualParent.children.size)
assertEquals(child, root.children[0])
assertEquals(child, virtualGrandParent.children[0])
assertEquals(child, virtualParent.children[0])
assertEquals(newChild, root.children[1])
assertEquals(newChild, virtualGrandParent.children[1])
assertEquals(newChild, virtualParent.children[1])
}
}
@Test
fun nestedLookaheadScope() {
rule.setContent {
Box(Modifier.offset(20.dp, 30.dp)) {
LookaheadScope {
Box(
Modifier
.offset(50.dp, 25.dp)
.intermediateLayout { measurable, constraints ->
measureWithLambdas(prePlacement = {
val outerLookaheadScopeCoords = with(this@LookaheadScope) {
lookaheadScopeCoordinates
}
assertEquals(
outerLookaheadScopeCoords,
lookaheadScopeCoordinates
)
})(measurable, constraints)
}
.offset(15.dp, 20.dp)
) {
LookaheadScope {
val innerLookaheadScope = this
Box(
Modifier
.intermediateLayout { measurable, constraints ->
measureWithLambdas(prePlacement = {
val innerLookaheadCoords = with(innerLookaheadScope) {
lookaheadScopeCoordinates
}
assertEquals(
innerLookaheadCoords,
lookaheadScopeCoordinates
)
})(measurable, constraints)
}
.size(50.dp)
.intermediateLayout { measurable, constraints ->
measureWithLambdas(prePlacement = {
val innerLookaheadCoords = with(innerLookaheadScope) {
lookaheadScopeCoordinates
}
assertEquals(
innerLookaheadCoords,
lookaheadScopeCoordinates
)
})(measurable, constraints)
}
)
}
}
}
}
}
}
@Test
fun lookaheadScopeInImplicitScope() {
rule.setContent {
Box(Modifier.offset(20.dp, 30.dp)) {
Box(
Modifier
.offset(50.dp, 25.dp)
.intermediateLayout { measurable, constraints ->
measureWithLambdas(prePlacement = {
assertEquals(
coordinates!!,
lookaheadScopeCoordinates
)
})(measurable, constraints)
}
.offset(15.dp, 20.dp)
) {
LookaheadScope {
val explicitLookaheadScope = this
Box(
Modifier
.intermediateLayout { measurable, constraints ->
measureWithLambdas(prePlacement = {
val innerLookaheadCoords =
with(explicitLookaheadScope) {
lookaheadScopeCoordinates
}
assertEquals(
innerLookaheadCoords,
lookaheadScopeCoordinates
)
})(measurable, constraints)
}
.size(50.dp)
.intermediateLayout { measurable, constraints ->
measureWithLambdas(prePlacement = {
val innerLookaheadCoords =
with(explicitLookaheadScope) {
lookaheadScopeCoordinates
}
assertEquals(
innerLookaheadCoords,
lookaheadScopeCoordinates
)
})(measurable, constraints)
}
)
}
}
}
}
}
@Test
fun nestedVirtualNodeFromLookaheadScope() {
var small by mutableStateOf(true)
fun size1(): Int = if (small) 20 else 50
fun size2(): Int = if (small) 60 else 120
fun offset1(): IntOffset = if (small) IntOffset.Zero else IntOffset(80, 30)
fun offset2(): IntOffset = if (small) IntOffset(50, 100) else IntOffset(60, 170)
var actualSize1: IntSize = IntSize.Zero
var actualSize2: IntSize = IntSize.Zero
var actualOffset1: IntOffset = IntOffset.Zero
var actualOffset2: IntOffset = IntOffset.Zero
rule.setContent {
CompositionLocalProvider(LocalDensity.provides(Density(1f))) {
Box {
LookaheadScope {
LookaheadScope {
Box(
Modifier
.offset(offset1().x.dp, offset1().y.dp)
.onGloballyPositioned {
actualSize1 = it.size
actualOffset1 = it
.localToRoot(Offset.Zero)
.round()
}
.size(size1().dp)
)
}
Box(
Modifier
.offset(offset2().x.dp, offset2().y.dp)
.onGloballyPositioned {
actualSize2 = it.size
actualOffset2 = it
.localToRoot(Offset.Zero)
.round()
}
.size(size2().dp)
)
}
}
}
}
rule.runOnIdle {
assertEquals(IntSize(size1(), size1()), actualSize1)
assertEquals(IntSize(size2(), size2()), actualSize2)
assertEquals(offset1(), actualOffset1)
assertEquals(offset2(), actualOffset2)
small = !small
}
// Change the size & position of LayoutNode in the nested virtual node
rule.runOnIdle {
assertEquals(IntSize(size1(), size1()), actualSize1)
assertEquals(IntSize(size2(), size2()), actualSize2)
assertEquals(offset1(), actualOffset1)
assertEquals(offset2(), actualOffset2)
}
}
@Test
fun subcomposeLayoutSkipToLookaheadConstraintsPlacementBehavior() {
val actualPlacementOrder = mutableStateListOf<Int>()
val expectedPlacementOrder1 = listOf(1, 3, 5, 2, 4, 0)
val expectedPlacementOrder2 = listOf(2, 0, 3, 1, 5, 4)
val expectedPlacementOrder3 = listOf(5, 2, 4, 0, 3, 1)
val expectedPlacementOrder =
mutableStateListOf<Int>().apply { addAll(expectedPlacementOrder1) }
var iteration by mutableStateOf(0)
// Expect the default placement to be the same as lookahead
rule.setContent {
LookaheadScope {
SubcomposeLayout(
intermediateMeasurePolicy = { lookaheadMeasurePolicy(lookaheadConstraints) }
) { constraints ->
val placeables = mutableListOf<Placeable>()
repeat(3) { id ->
subcompose(id) {
Box(Modifier.trackMainPassPlacement {
actualPlacementOrder.add(id)
})
}.fastMap { it.measure(constraints) }.let { placeables.addAll(it) }
}
layout(100, 100) {
val allPlaceables = mutableListOf<Placeable>().apply { addAll(placeables) }
repeat(3) { index ->
val id = index + 3
subcompose(id) {
Box(Modifier.trackMainPassPlacement {
actualPlacementOrder.add(id)
})
}.fastMap { it.measure(constraints) }.let { allPlaceables.addAll(it) }
}
// Start lookahead placement
assertEquals(6, allPlaceables.size)
expectedPlacementOrder.fastForEach {
allPlaceables[it].place(0, 0)
}
}
}
}
}
rule.runOnIdle {
assertEquals(expectedPlacementOrder.toList(), actualPlacementOrder.toList())
expectedPlacementOrder.clear()
expectedPlacementOrder.addAll(expectedPlacementOrder2)
iteration++
actualPlacementOrder.clear()
}
rule.runOnIdle {
assertEquals(expectedPlacementOrder.toList(), actualPlacementOrder.toList())
expectedPlacementOrder.clear()
expectedPlacementOrder.addAll(expectedPlacementOrder3)
iteration++
actualPlacementOrder.clear()
}
rule.runOnIdle {
assertEquals(expectedPlacementOrder.toList(), actualPlacementOrder.toList())
}
}
private fun Modifier.trackMainPassPlacement(block: () -> Unit) =
Modifier.intermediateLayout { measurable, constraints ->
measurable.measure(constraints).run {
layout(width, height) {
block()
place(0, 0)
}
}
}
@Ignore("b/276805422")
@Test
fun subcomposeLayoutInLookahead() {
val expectedConstraints = mutableStateListOf(
Constraints.fixed(0, 0),
Constraints.fixed(0, 0),
Constraints.fixed(0, 0)
)
val expectedPlacements = mutableStateListOf(
IntOffset.Zero,
IntOffset.Zero,
IntOffset.Zero
)
fun generateRandomConstraintsAndPlacements() {
repeat(3) {
expectedConstraints[it] = Constraints.fixed(
Random.nextInt(100, 1000),
Random.nextInt(100, 1000)
)
expectedPlacements[it] = IntOffset(
Random.nextInt(-200, 1200),
Random.nextInt(-200, 1200)
)
}
}
val actualConstraints = arrayOfNulls<Constraints?>(3)
val actualPlacements = arrayOfNulls<IntOffset?>(3)
generateRandomConstraintsAndPlacements()
rule.setContent {
LookaheadScope {
SubcomposeLayout {
val placeables = mutableVectorOf<Placeable>()
repeat(3) {
subcompose(it) {
Box(
Modifier
.intermediateLayout { measurable, constraints ->
actualConstraints[it] = constraints
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
actualPlacements[it] =
lookaheadScopeCoordinates
.localLookaheadPositionOf(
coordinates!!.toLookaheadCoordinates()
)
.round()
placeable.place(0, 0)
}
}
.fillMaxSize())
// This is intentionally left not placed, to check for crash.
Box(Modifier.size(200.dp))
}[0].measure(expectedConstraints[it]).let {
placeables.add(it)
}
}
layout(100, 100) {
repeat(3) {
placeables[it].place(expectedPlacements[it])
}
}
}
}
}
repeat(5) {
rule.runOnIdle {
repeat(expectedPlacements.size) {
assertEquals(expectedConstraints[it], actualConstraints[it])
}
repeat(expectedPlacements.size) {
assertEquals(expectedPlacements[it], actualPlacements[it])
}
}
generateRandomConstraintsAndPlacements()
}
}
@Test
fun forceMeasureSubtreeWithoutAffectingLookahead() {
var iterations by mutableStateOf(0)
var lookaheadPosition: Offset? = null
rule.setContent {
Box(Modifier.fillMaxSize()) {
LookaheadScope {
// Fill max size will cause the remeasure requests to go down the
// forceMeasureSubtree code path.
CompositionLocalProvider(LocalDensity provides Density(1f)) {
Column(Modifier.fillMaxSize()) {
// This box will get a remeasure request when `iterations` changes.
// Subsequently this Box's size change will trigger a measurement pass
// from Column.
Box(Modifier.intermediateLayout { measurable, _ ->
// Force a state-read (similar to animation but more reliable)
measurable.measure(Constraints.fixed(200 + 100 * iterations, 200))
.run {
layout(width, height) {
place(0, 0)
}
}
}) {
Box(Modifier.size(100.dp))
}
Box { // forceMeasureSubtree starts here
Box {
// Lookahead measure will be marked dirty in the containing box.
// This test ensures it doesn't get ignored when there's a
// forceMeasureSubtree (without lookahead) triggered from
// ancestor while it's lookaheadMeasurePending.
if (iterations % 2 == 0) {
Box(Modifier.size(100.dp))
} else {
Box(Modifier.size(200.dp))
}
}
}
Box(
Modifier
.size(100.dp)
.intermediateLayout { measurable, constraints ->
measurable
.measure(constraints)
.run {
layout(width, height) {
lookaheadPosition = lookaheadScopeCoordinates
.localLookaheadPositionOf(coordinates!!)
place(0, 0)
}
}
})
}
}
}
}
}
repeat(4) {
rule.runOnIdle {
assertEquals(Offset(0f, 200f + 100 * (iterations % 2)), lookaheadPosition)
iterations++
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Test
fun testIsLookingAhead() {
var iterations by mutableStateOf(0)
val size = mutableMapOf<Boolean, IntSize>()
rule.setContent {
Box(Modifier.fillMaxSize()) {
LookaheadScope {
// Fill max size will cause the remeasure requests to go down the
// forceMeasureSubtree code path.
CompositionLocalProvider(LocalDensity provides Density(1f)) {
Column(Modifier.fillMaxSize()) {
// This box will get a remeasure request when `iterations` changes.
// Subsequently this Box's size change will trigger a measurement pass
// from Column.
Box(
Modifier
.layout { measurable, constraints ->
measurable
.measure(constraints)
.run {
size[isLookingAhead] = IntSize(width, height)
layout(width, height) {
place(0, 0)
}
}
}
.intermediateLayout { measurable, _ ->
// Force a state-read (similar to animation but more
// reliable)
measurable
.measure(Constraints.fixed(200 + 100 * iterations, 200))
.run {
layout(width, height) {
place(0, 0)
}
}
}) {
Box(Modifier.size(100.dp))
}
}
SubcomposeLayout(
Modifier
.fillMaxSize()
.requiredSize(200.dp),
intermediateMeasurePolicy = { constraints ->
assertFalse(isLookingAhead)
measurablesForSlot(Unit)[0].measure(constraints)
layout(0, 0) {}
}
) { constraints ->
assertTrue(isLookingAhead)
val placeable = subcompose(Unit) {
Box(Modifier.requiredSize(400.dp, 600.dp))
}[0].measure(constraints)
layout(500, 300) {
placeable.place(0, 0)
}
}
}
}
}
}
repeat(4) {
rule.runOnIdle {
assertEquals(IntSize(100, 100), size[true])
assertEquals(IntSize(200 + 100 * it, 200), size[false])
iterations++
}
}
}
class TestLayoutModifierNode(
var lookaheadIntrinsicResult: MutableMap<String, Int>,
var intrinsicResult: MutableMap<String, Int>
) : LayoutModifierNode, Modifier.Node() {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
return measurable.measure(constraints).run {
layout(width, height) {
place(0, 0)
}
}
}
override fun IntrinsicMeasureScope.maxIntrinsicHeight(
measurable: IntrinsicMeasurable,
width: Int
): Int = measurable.maxIntrinsicHeight(width).also {
if (isLookingAhead) {
lookaheadIntrinsicResult["maxHeight"] = it
} else {
intrinsicResult["maxHeight"] = it
}
}
override fun IntrinsicMeasureScope.minIntrinsicHeight(
measurable: IntrinsicMeasurable,
width: Int
): Int = measurable.minIntrinsicHeight(width).also {
if (isLookingAhead) {
lookaheadIntrinsicResult["minHeight"] = it
} else {
intrinsicResult["minHeight"] = it
}
}
override fun IntrinsicMeasureScope.maxIntrinsicWidth(
measurable: IntrinsicMeasurable,
height: Int
): Int = measurable.maxIntrinsicWidth(height).also {
if (isLookingAhead) {
lookaheadIntrinsicResult["maxWidth"] = it
} else {
intrinsicResult["maxWidth"] = it
}
}
override fun IntrinsicMeasureScope.minIntrinsicWidth(
measurable: IntrinsicMeasurable,
height: Int
): Int = measurable.minIntrinsicWidth(height).also {
if (isLookingAhead) {
lookaheadIntrinsicResult["minWidth"] = it
} else {
intrinsicResult["minWidth"] = it
}
}
}
data class TestElement(
val lookaheadIntrinsicResult: MutableMap<String, Int>,
val intrinsicResult: MutableMap<String, Int>
) : ModifierNodeElement<TestLayoutModifierNode>() {
override fun create(): TestLayoutModifierNode =
TestLayoutModifierNode(lookaheadIntrinsicResult, intrinsicResult)
override fun update(node: TestLayoutModifierNode) {
node.lookaheadIntrinsicResult = lookaheadIntrinsicResult
node.intrinsicResult = intrinsicResult
}
override fun InspectorInfo.inspectableProperties() {
name = "TestElement"
properties["lookaheadIntrinsicResult"] = lookaheadIntrinsicResult
properties["intrinsicResult"] = intrinsicResult
}
}
@Test
fun testIsLookingAheadWithIntrinsics() {
val lookaheadIntrinsicsResult = mutableMapOf<String, Int>()
val intrinsicsResult = mutableMapOf<String, Int>()
val modifierList = listOf(
Modifier.width(IntrinsicSize.Max),
Modifier.width(IntrinsicSize.Min),
Modifier.height(IntrinsicSize.Max),
Modifier.height(IntrinsicSize.Min),
)
var iteration by mutableStateOf(0)
rule.setContent {
LookaheadScope {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
Row(Modifier.width(IntrinsicSize.Max)) {
Box(
Modifier
.fillMaxSize()
.then(modifierList[iteration])
.then(
TestElement(
lookaheadIntrinsicsResult, intrinsicsResult
)
)
.layout { measurable, constraints ->
measurable
.measure(constraints)
.run {
if (isLookingAhead) {
layout(200, 250) {
place(0, 0)
}
} else {
layout(100, 150) {
place(0, 0)
}
}
}
}) {
Box(Modifier.size(10.dp))
}
}
}
}
}
repeat(3) {
rule.waitForIdle()
iteration++
}
rule.runOnIdle {
assertEquals(250, lookaheadIntrinsicsResult["maxHeight"])
assertEquals(250, lookaheadIntrinsicsResult["minHeight"])
assertEquals(200, lookaheadIntrinsicsResult["maxWidth"])
assertEquals(200, lookaheadIntrinsicsResult["minWidth"])
assertEquals(150, intrinsicsResult["maxHeight"])
assertEquals(150, intrinsicsResult["minHeight"])
assertEquals(100, intrinsicsResult["maxWidth"])
assertEquals(100, intrinsicsResult["minWidth"])
}
}
@Test
fun forceMeasureSubtreeWhileLookaheadMeasureRequestedFromSubtree() {
var iterations by mutableStateOf(0)
rule.setContent {
Box(Modifier.fillMaxSize()) {
LookaheadScope {
// Fill max size will cause the remeasure requests to go down the
// forceMeasureSubtree code path.
CompositionLocalProvider(LocalDensity provides Density(1f)) {
Column(Modifier.fillMaxSize()) {
// This box will get a remeasure request when `iterations` changes.
// Subsequently this Box's size change will trigger a measurement pass
// from Column.
Box(Modifier.intermediateLayout { measurable, _ ->
// Force a state-read, so that this node is the node where
// remeasurement starts.
@Suppress("UNUSED_EXPRESSION")
iterations
measurable.measure(Constraints.fixed(200, 200))
.run {
layout(width, height) {
place(0, 0)
}
}
}) {
// Swap modifiers. If lookahead re-measurement from this node isn't
// handled before parent's non-lookahead remeasurement, this would
// lead to a crash.
Box(
if (iterations % 2 == 0)
Modifier.size(100.dp)
else
Modifier
.intermediateLayout { measurable, constraints ->
measurable
.measure(constraints)
.run {
layout(width, height) {
place(5, 5)
}
}
}
.padding(5.dp)
)
}
}
}
}
}
}
repeat(4) {
rule.runOnIdle {
iterations++
}
}
}
@Test
fun multiMeasureLayoutInLookahead() {
var horizontal by mutableStateOf(true)
rule.setContent {
LookaheadScope {
@Suppress("DEPRECATION")
MultiMeasureLayout(
content = {
if (horizontal) {
Row {
repeat(3) {
Box(
Modifier
.weight(1f)
.background(Color.Red)
)
}
}
} else {
Column {
repeat(3) {
Box(
Modifier
.weight(1f)
.background(Color.Red)
)
}
}
}
},
modifier = Modifier.fillMaxSize(0.6f),
measurePolicy = MeasurePolicy { measurables, constraints ->
// Intentionally measure twice here to ensure multi-measure is supported.
measurables.map { it.measure(Constraints.fixed(200, 300)) }
val placeables = measurables.map { it.measure(constraints) }
val maxWidth: Int = placeables.maxOf { it.width }
val maxHeight = placeables.maxOf { it.height }
// Position the children.
layout(maxWidth, maxHeight) {
placeables.forEach {
it.place(0, 0)
}
}
})
}
}
rule.runOnIdle { horizontal = !horizontal }
rule.runOnIdle { horizontal = !horizontal }
rule.waitForIdle()
}
private fun assertSameLayoutWithAndWithoutLookahead(
content: @Composable (
modifier: Modifier
) -> Unit
) {
val controlGroupSizes = mutableVectorOf<IntSize>()
val controlGroupPositions = mutableVectorOf<Offset>()
val sizes = mutableVectorOf<IntSize>()
val positions = mutableVectorOf<Offset>()
var enableControlGroup by mutableStateOf(true)
rule.setContent {
if (enableControlGroup) {
Layout(measurePolicy = defaultMeasurePolicy, content = {
content(
modifier = Modifier.trackSizeAndPosition(
controlGroupSizes,
controlGroupPositions,
)
)
})
} else {
Layout(measurePolicy = defaultMeasurePolicy, content = {
LookaheadScope {
content(
modifier = Modifier
.trackSizeAndPosition(sizes, positions)
.assertSameSizeAndPosition(this)
)
}
})
}
}
rule.runOnIdle {
enableControlGroup = !enableControlGroup
}
rule.runOnIdle {
if (Debug) {
controlGroupPositions.debugPrint("Lookahead")
controlGroupSizes.debugPrint("Lookahead")
positions.debugPrint("Lookahead")
sizes.debugPrint("Lookahead")
}
assertEquals(controlGroupPositions.size, positions.size)
controlGroupPositions.forEachIndexed { i, position ->
assertEquals(position, positions[i])
}
assertEquals(controlGroupSizes.size, sizes.size)
controlGroupSizes.forEachIndexed { i, size ->
assertEquals(size, sizes[i])
}
}
}
private fun Modifier.assertSameSizeAndPosition(scope: LookaheadScope) = composed {
var lookaheadSize by remember {
mutableStateOf(IntSize.Zero)
}
var lookaheadLayoutCoordinates: LayoutCoordinates? by remember {
mutableStateOf(
null
)
}
var onPlacedCoordinates: LayoutCoordinates? by remember { mutableStateOf(null) }
with(scope) {
this@composed
.intermediateLayout { measurable, constraints ->
assertFalse(isLookingAhead)
lookaheadSize = this.lookaheadSize
measureWithLambdas(
prePlacement = {
lookaheadLayoutCoordinates = lookaheadScopeCoordinates
}
).invoke(this, measurable, constraints)
}
.onPlaced { it ->
onPlacedCoordinates = it
}
.onGloballyPositioned {
assertEquals(lookaheadSize, it.size)
assertEquals(
lookaheadLayoutCoordinates!!.localLookaheadPositionOf(
onPlacedCoordinates!!
),
lookaheadLayoutCoordinates!!.localPositionOf(
onPlacedCoordinates!!,
Offset.Zero
)
)
// Also check that localPositionOf with non-zero offset works
// correctly for lookahead coordinates and LayoutCoordinates.
val randomOffset = Offset(
Random
.nextInt(0, 1000)
.toFloat(),
Random
.nextInt(0, 1000)
.toFloat()
)
assertEquals(
lookaheadLayoutCoordinates!!
.toLookaheadCoordinates()
.localPositionOf(
onPlacedCoordinates!!.toLookaheadCoordinates(),
randomOffset
),
lookaheadLayoutCoordinates!!.localPositionOf(
onPlacedCoordinates!!,
randomOffset
)
)
}
}
}
// This is needed because Offset comparison would fail when comparing -0.0f to 0.0f in one
// or both of its dimensions.
private fun assertEquals(expected: Offset, actual: Offset?) {
assertEquals(expected.x, actual!!.x, 0f)
assertEquals(expected.y, actual.y, 0f)
}
private fun Modifier.trackSizeAndPosition(
sizes: MutableVector<IntSize>,
positions: MutableVector<Offset>
) = this
.onGloballyPositioned {
positions.add(it.positionInRoot())
sizes.add(it.size)
}
private fun <T> MutableVector<T>.debugPrint(tag: String) {
print("$tag: [")
forEach { print("$it, ") }
print("]")
println()
}
private val defaultMeasurePolicy: MeasurePolicy =
MeasurePolicy { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints) }
val maxWidth: Int = placeables.maxOf { it.width }
val maxHeight = placeables.maxOf { it.height }
// Position the children.
layout(maxWidth, maxHeight) {
placeables.forEach {
it.place(0, 0)
}
}
}
private fun measureWithLambdas(
preMeasure: () -> Unit = {},
postMeasure: (IntSize) -> Unit = {},
prePlacement: Placeable.PlacementScope.() -> Unit = {},
postPlacement: () -> Unit = {}
): MeasureScope.(Measurable, Constraints) -> MeasureResult = { measurable, constraints ->
preMeasure()
val placeable = measurable.measure(constraints)
postMeasure(IntSize(placeable.width, placeable.height))
layout(placeable.width, placeable.height) {
prePlacement()
placeable.place(0, 0)
postPlacement()
}
}
}