blob: 48461f448c0cdb1441f95c9129d3a1e443f98aae [file] [log] [blame]
/*
* Copyright 2019 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 androidx.ui.core.test
import android.app.Activity
import android.graphics.Bitmap
import android.os.Build
import android.os.Handler
import android.view.PixelCopy
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import androidx.annotation.RequiresApi
import androidx.compose.Composable
import androidx.compose.Compose
import androidx.compose.Model
import androidx.compose.composer
import androidx.test.filters.SdkSuppress
import androidx.test.filters.SmallTest
import androidx.test.rule.ActivityTestRule
import androidx.ui.core.AndroidCraneView
import androidx.ui.core.Constraints
import androidx.ui.core.ContextAmbient
import androidx.ui.core.Density
import androidx.ui.core.DensityAmbient
import androidx.ui.core.Draw
import androidx.ui.core.IntPx
import androidx.ui.core.Layout
import androidx.ui.core.ParentData
import androidx.ui.core.Ref
import androidx.ui.core.RepaintBoundary
import androidx.ui.core.WithConstraints
import androidx.ui.core.coerceAtLeast
import androidx.ui.core.coerceIn
import androidx.ui.core.ipx
import androidx.ui.core.max
import androidx.ui.core.setContent
import androidx.ui.core.toRect
import androidx.ui.engine.geometry.Rect
import androidx.ui.framework.test.TestActivity
import androidx.ui.graphics.Color
import androidx.ui.painting.Paint
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* Corresponds to ContainingViewTest, but tests single composition measure, layout and draw.
* It also tests that layouts with both Layout and MeasureBox work.
* TODO(popam): remove this comment and ContainingViewTest when ComplexMeasureBox is removed
*/
@SmallTest
@RunWith(JUnit4::class)
class AndroidLayoutDrawTest {
@get:Rule
val activityTestRule = ActivityTestRule<TestActivity>(
TestActivity::class.java
)
private lateinit var activity: TestActivity
private lateinit var drawLatch: CountDownLatch
@Before
fun setup() {
activity = activityTestRule.activity
activity.hasFocusLatch.await(5, TimeUnit.SECONDS)
drawLatch = CountDownLatch(1)
}
// Tests that simple drawing works with layered squares
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
@Test
fun simpleDrawTest() {
val yellow = Color(0xFFFFFF00.toInt())
val red = Color(0xFF800000.toInt())
val model = SquareModel(outerColor = yellow, innerColor = red, size = 10.ipx)
composeSquares(model)
validateSquareColors(outerColor = yellow, innerColor = red, size = 10)
}
// Tests that simple drawing works with draw with nested children
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
@Test
fun nestedDrawTest() {
val yellow = Color(0xFFFFFF00.toInt())
val red = Color(0xFF800000.toInt())
val model = SquareModel(outerColor = yellow, innerColor = red, size = 10.ipx)
composeNestedSquares(model)
validateSquareColors(outerColor = yellow, innerColor = red, size = 10)
}
// Tests that recomposition works with models used within Draw components
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
@Test
fun recomposeDrawTest() {
val white = Color(0xFFFFFFFF.toInt())
val blue = Color(0xFF000080.toInt())
val model = SquareModel(outerColor = blue, innerColor = white)
composeSquares(model)
validateSquareColors(outerColor = blue, innerColor = white, size = 10)
drawLatch = CountDownLatch(1)
val red = Color(0xFF800000.toInt())
val yellow = Color(0xFFFFFF00.toInt())
activityTestRule.runOnUiThreadIR {
model.outerColor = red
model.innerColor = yellow
}
validateSquareColors(outerColor = red, innerColor = yellow, size = 10)
}
// Tests that recomposition of nested repaint boundaries work
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
@Test
fun recomposeNestedRepaintBoundariesColorChange() {
val white = Color(0xFFFFFFFF.toInt())
val blue = Color(0xFF000080.toInt())
val model = SquareModel(outerColor = blue, innerColor = white)
composeSquaresWithNestedRepaintBoundaries(model)
validateSquareColors(outerColor = blue, innerColor = white, size = 10)
drawLatch = CountDownLatch(1)
val yellow = Color(0xFFFFFF00.toInt())
activityTestRule.runOnUiThreadIR {
model.innerColor = yellow
}
validateSquareColors(outerColor = blue, innerColor = yellow, size = 10)
}
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
@Test
fun recomposeNestedRepaintBoundariesSizeChange() {
val white = Color(0xFFFFFFFF.toInt())
val blue = Color(0xFF000080.toInt())
val model = SquareModel(outerColor = blue, innerColor = white)
composeSquaresWithNestedRepaintBoundaries(model)
validateSquareColors(outerColor = blue, innerColor = white, size = 10)
drawLatch = CountDownLatch(1)
activityTestRule.runOnUiThreadIR {
model.size = 20.ipx
}
validateSquareColors(outerColor = blue, innerColor = white, size = 20)
}
// When there is a repaint boundary around a moving child, the child move
// should be reflected in the repainted bitmap
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
@Test
fun recomposeRepaintBoundariesMove() {
val white = Color(0xFFFFFFFF.toInt())
val blue = Color(0xFF000080.toInt())
val model = SquareModel(outerColor = blue, innerColor = white)
var offset = OffsetModel(10.ipx)
composeMovingSquaresWithRepaintBoundary(model, offset)
validateSquareColors(outerColor = blue, innerColor = white, size = 10)
drawLatch = CountDownLatch(1)
activityTestRule.runOnUiThreadIR {
// there isn't going to be a normal draw because we are just moving the repaint
// boundary, but we should have a draw cycle
activityTestRule.findAndroidCraneView().viewTreeObserver.addOnDrawListener(object :
ViewTreeObserver.OnDrawListener {
override fun onDraw() {
drawLatch.countDown()
}
})
offset.offset = 20.ipx
}
validateSquareColors(outerColor = blue, innerColor = white, offset = 10, size = 10)
}
// When there is no repaint boundary around a moving child, the child move
// should be reflected in the repainted bitmap
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
@Test
fun recomposeMove() {
val white = Color(0xFFFFFFFF.toInt())
val blue = Color(0xFF000080.toInt())
val model = SquareModel(outerColor = blue, innerColor = white)
var offset = OffsetModel(10.ipx)
composeMovingSquares(model, offset)
validateSquareColors(outerColor = blue, innerColor = white, size = 10)
drawLatch = CountDownLatch(1)
activityTestRule.runOnUiThreadIR {
// there isn't going to be a normal draw because we are just moving the repaint
// boundary, but we should have a draw cycle
activityTestRule.findAndroidCraneView().viewTreeObserver.addOnDrawListener(object :
ViewTreeObserver.OnDrawListener {
override fun onDraw() {
drawLatch.countDown()
}
})
offset.offset = 20.ipx
}
validateSquareColors(outerColor = blue, innerColor = white, offset = 10, size = 10)
}
// Tests that recomposition works with models used within Layout components
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
@Test
fun recomposeSizeTest() {
val white = Color(0xFFFFFFFF.toInt())
val blue = Color(0xFF000080.toInt())
val model = SquareModel(outerColor = blue, innerColor = white)
composeSquares(model)
validateSquareColors(outerColor = blue, innerColor = white, size = 10)
drawLatch = CountDownLatch(1)
activityTestRule.runOnUiThreadIR { model.size = 20.ipx }
validateSquareColors(outerColor = blue, innerColor = white, size = 20)
}
// The size and color are both changed in a simpler single-color square.
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
@Test
fun simpleSquareColorAndSizeTest() {
val green = Color(0xFF00FF00.toInt())
val model = SquareModel(size = 20.ipx, outerColor = green, innerColor = green)
activityTestRule.runOnUiThreadIR {
activity.setContent {
Padding(size = (model.size * 3)) {
FillColor(model.outerColor)
}
}
}
validateSquareColors(outerColor = green, innerColor = green, size = 20)
drawLatch = CountDownLatch(1)
activityTestRule.runOnUiThreadIR {
model.size = 30.ipx
}
validateSquareColors(outerColor = green, innerColor = green, size = 30)
drawLatch = CountDownLatch(1)
val blue = Color(0xFF0000FF.toInt())
activityTestRule.runOnUiThreadIR {
model.innerColor = blue
model.outerColor = blue
}
validateSquareColors(outerColor = blue, innerColor = blue, size = 30)
}
// Components that aren't placed shouldn't be drawn.
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
@Test
fun noPlaceNoDraw() {
val green = Color(0xFF00FF00.toInt())
val white = Color(0xFFFFFFFF.toInt())
val model = SquareModel(size = 20.ipx, outerColor = green, innerColor = white)
activityTestRule.runOnUiThreadIR {
activity.setContent {
Layout(children = {
Padding(size = (model.size * 3)) {
FillColor(model.outerColor)
}
Padding(size = model.size) {
FillColor(model.innerColor)
}
}, measureBlock = { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints) }
layout(placeables[0].width, placeables[0].height) {
placeables[0].place(0.ipx, 0.ipx)
}
})
}
}
validateSquareColors(outerColor = green, innerColor = green, size = 20)
}
// Make sure that draws intersperse properly with sub-layouts
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
@Test
fun drawOrderWithChildren() {
val green = Color(0xFF00FF00.toInt())
val white = Color(0xFFFFFFFF.toInt())
val model = SquareModel(size = 20.ipx, outerColor = green, innerColor = white)
activityTestRule.runOnUiThreadIR {
activity.setContent {
Draw { canvas, parentSize ->
// Fill the space with the outerColor
val paint = Paint()
paint.color = model.outerColor
canvas.drawRect(parentSize.toRect(), paint)
canvas.nativeCanvas.save()
val offset = parentSize.width.value / 3
// clip drawing to the inner rectangle
canvas.clipRect(Rect(offset, offset, offset * 2, offset * 2))
}
Padding(size = (model.size * 3)) {
Draw { canvas, parentSize ->
// Fill top half with innerColor -- should be clipped
drawLatch.countDown()
val paint = Paint()
paint.color = model.innerColor
val paintRect = Rect(
0f, 0f, parentSize.width.value,
parentSize.height.value / 2f
)
canvas.drawRect(paintRect, paint)
}
}
Draw { canvas, parentSize ->
// Fill bottom half with innerColor -- should be clipped
val paint = Paint()
paint.color = model.innerColor
val paintRect = Rect(
0f, parentSize.height.value / 2f,
parentSize.width.value, parentSize.height.value
)
canvas.drawRect(paintRect, paint)
// restore the canvas
canvas.nativeCanvas.restore()
}
}
}
validateSquareColors(outerColor = green, innerColor = white, size = 20)
}
@Test
fun withConstraintsTest() {
val size = 20.ipx
val countDownLatch = CountDownLatch(1)
val topConstraints = Ref<Constraints>()
val paddedConstraints = Ref<Constraints>()
val firstChildConstraints = Ref<Constraints>()
val secondChildConstraints = Ref<Constraints>()
activityTestRule.runOnUiThreadIR {
activity.setContent {
WithConstraints { constraints ->
topConstraints.value = constraints
Padding(size = size) {
WithConstraints { constraints ->
paddedConstraints.value = constraints
Layout(measureBlock = { _, childConstraints ->
firstChildConstraints.value = childConstraints
layout(size, size) { }
}, children = { })
Layout(measureBlock = { _, chilConstraints ->
secondChildConstraints.value = chilConstraints
layout(size, size) { }
}, children = { })
Draw { _, _ ->
countDownLatch.countDown()
}
}
}
}
}
}
assertTrue(countDownLatch.await(1, TimeUnit.SECONDS))
val expectedPaddedConstraints = Constraints(
topConstraints.value!!.minWidth - size * 2,
topConstraints.value!!.maxWidth - size * 2,
topConstraints.value!!.minHeight - size * 2,
topConstraints.value!!.maxHeight - size * 2
)
assertEquals(expectedPaddedConstraints, paddedConstraints.value)
assertEquals(paddedConstraints.value, firstChildConstraints.value)
assertEquals(paddedConstraints.value, secondChildConstraints.value)
}
// Tests that calling measure multiple times on the same Measurable causes an exception
@Test
fun multipleMeasureCall() {
val latch = CountDownLatch(1)
activityTestRule.runOnUiThreadIR {
activity.setContent {
TwoMeasureLayout(50.ipx, latch) {
AtLeastSize(50.ipx) {
}
}
}
}
assertTrue(latch.await(1, TimeUnit.SECONDS))
}
@Test
fun multiChildLayoutTest() {
val childrenCount = 3
val childConstraints = arrayOf(
Constraints(),
Constraints.tightConstraintsForWidth(50.ipx),
Constraints.tightConstraintsForHeight(50.ipx)
)
val headerChildrenCount = 1
val footerChildrenCount = 2
activityTestRule.runOnUiThreadIR {
activity.setContent {
val header = @Composable {
Layout(measureBlock = { _, constraints ->
assertEquals(childConstraints[0], constraints)
layout(0.ipx, 0.ipx) {}
}, children = {})
}
val footer = @Composable {
Layout(measureBlock = { _, constraints ->
assertEquals(childConstraints[1], constraints)
layout(0.ipx, 0.ipx) {}
}, children = {})
Layout(measureBlock = { _, constraints ->
assertEquals(childConstraints[2], constraints)
layout(0.ipx, 0.ipx) {}
}, children = {})
}
@Suppress("USELESS_CAST")
Layout(header, footer) { measurables, _ ->
assertEquals(childrenCount, measurables.size)
measurables.forEachIndexed { index, measurable ->
measurable.measure(childConstraints[index])
}
assertEquals(headerChildrenCount, measurables[header].size)
assertSame(measurables[0], measurables[header][0])
assertEquals(footerChildrenCount, measurables[footer].size)
assertSame(measurables[1], measurables[footer][0])
assertSame(measurables[2], measurables[footer][1])
layout(0.ipx, 0.ipx) {}
}
}
}
}
@Test
fun multiChildLayoutTest_doesNotOverrideChildrenParentData() {
activityTestRule.runOnUiThreadIR {
activity.setContent {
val header = @Composable {
ParentData(data = 0) {
Layout(measureBlock = { _, _ -> layout(0.ipx, 0.ipx, {}) }, children = {})
}
}
val footer = @Composable {
ParentData(data = 1) {
Layout(measureBlock = { _, _ -> layout(0.ipx, 0.ipx, {}) }, children = {})
}
}
Layout(header, footer) { measurables, _ ->
assertEquals(0, measurables[0].parentData)
assertEquals(1, measurables[1].parentData)
layout(0.ipx, 0.ipx, {})
}
}
}
}
// TODO(lmr): refactor to use the globally provided one when it lands
private fun Activity.compose(composable: @Composable() () -> Unit) {
val root = AndroidCraneView(this)
setContentView(root)
Compose.composeInto(root.root, context = this) {
ContextAmbient.Provider(value = this) {
DensityAmbient.Provider(value = Density(this)) {
composable()
}
}
}
}
// When a child's measure() is done within the layout, it should not affect the parent's
// size. The parent's layout shouldn't be called when the child's size changes
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
@Test
fun measureInLayoutDoesNotAffectParentSize() {
val white = Color(0xFFFFFFFF.toInt())
val blue = Color(0xFF000080.toInt())
val model = SquareModel(outerColor = blue, innerColor = white)
var measureCalls = 0
var layoutCalls = 0
val layoutLatch = CountDownLatch(1)
activityTestRule.runOnUiThreadIR {
activity.compose {
Draw { canvas, parentSize ->
val paint = Paint()
paint.color = model.outerColor
canvas.drawRect(parentSize.toRect(), paint)
}
Layout(children = {
AtLeastSize(size = model.size) {
Draw { canvas, parentSize ->
drawLatch.countDown()
val paint = Paint()
paint.color = model.innerColor
canvas.drawRect(parentSize.toRect(), paint)
}
}
}, measureBlock = { measurables, constraints ->
measureCalls++
layout(30.ipx, 30.ipx) {
layoutCalls++
layoutLatch.countDown()
val placeable = measurables[0].measure(constraints)
placeable.place(
(30.ipx - placeable.width) / 2,
(30.ipx - placeable.height) / 2
)
}
})
}
}
layoutLatch.await(1, TimeUnit.SECONDS)
validateSquareColors(outerColor = blue, innerColor = white, size = 10)
layoutCalls = 0
measureCalls = 0
drawLatch = CountDownLatch(1)
activityTestRule.runOnUiThreadIR {
model.size = 20.ipx
}
validateSquareColors(outerColor = blue, innerColor = white, size = 20, totalSize = 30)
assertEquals(0, measureCalls)
assertEquals(1, layoutCalls)
}
@Test
fun testLayout_whenMeasuringIsDoneDuringPlacing() {
@Composable
fun FixedSizeRow(
width: IntPx,
height: IntPx,
children: @Composable() () -> Unit
) {
Layout(children = children, measureBlock = { measurables, constraints ->
val resolvedWidth = width.coerceIn(constraints.minWidth, constraints.maxWidth)
val resolvedHeight = height.coerceIn(constraints.minHeight, constraints.maxHeight)
layout(resolvedWidth, resolvedHeight) {
val childConstraints = Constraints(
IntPx.Zero,
IntPx.Infinity,
resolvedHeight,
resolvedHeight
)
var left = IntPx.Zero
for (measurable in measurables) {
val placeable = measurable.measure(childConstraints)
if (left + placeable.width > width) {
break
}
placeable.place(left, IntPx.Zero)
left += placeable.width
}
}
})
}
@Composable
fun FixedWidthBox(
width: IntPx,
measured: Ref<Boolean?>,
laidOut: Ref<Boolean?>,
drawn: Ref<Boolean?>,
latch: CountDownLatch
) {
Layout(children = {
Draw { _, _ ->
drawn.value = true
latch.countDown()
}
}, measureBlock = { _, constraints ->
measured.value = true
val resolvedWidth = width.coerceIn(constraints.minWidth, constraints.maxWidth)
val resolvedHeight = constraints.minHeight
layout(resolvedWidth, resolvedHeight) { laidOut.value = true }
})
}
val childrenCount = 5
val measured = Array(childrenCount) { Ref<Boolean?>() }
val laidOut = Array(childrenCount) { Ref<Boolean?>() }
val drawn = Array(childrenCount) { Ref<Boolean?>() }
val latch = CountDownLatch(3)
activityTestRule.runOnUiThreadIR {
activity.setContent {
Align {
FixedSizeRow(width = 90.ipx, height = 40.ipx) {
for (i in 0 until childrenCount) {
FixedWidthBox(
width = 30.ipx,
measured = measured[i],
laidOut = laidOut[i],
drawn = drawn[i],
latch = latch
)
}
}
}
}
}
assertTrue(latch.await(1, TimeUnit.SECONDS))
for (i in 0 until childrenCount) {
assertEquals(i <= 3, measured[i].value ?: false)
assertEquals(i <= 2, laidOut[i].value ?: false)
assertEquals(i <= 2, drawn[i].value ?: false)
}
}
// When a new child is added, the parent must be remeasured because we don't know
// if it affects the size and the child's measure() must be called as well.
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
@Test
fun testRelayoutOnNewChild() {
val drawChild = DoDraw()
val outerColor = Color(0xFF000080.toInt())
val innerColor = Color(0xFFFFFFFF.toInt())
activityTestRule.runOnUiThreadIR {
activity.setContent {
AtLeastSize(size = 30.ipx) {
FillColor(outerColor)
if (drawChild.value) {
Padding(size = 20.ipx) {
AtLeastSize(size = 20.ipx) {
FillColor(innerColor)
}
}
}
}
}
}
// The padded area doesn't draw
validateSquareColors(outerColor = outerColor, innerColor = outerColor, size = 10)
drawLatch = CountDownLatch(1)
activityTestRule.runOnUiThreadIR { drawChild.value = true }
validateSquareColors(outerColor = outerColor, innerColor = innerColor, size = 20)
}
// When we change a position of one LayoutNode up the tree it automatically
// changes the position of all the children. RepaintBoundary with few intermediate
// LayoutNode parents should be drawn on a correct position
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
@Test
fun moveRootLayoutRedrawsLeafRepaintBoundary() {
val offset = OffsetModel(0.ipx)
drawLatch = CountDownLatch(2)
activityTestRule.runOnUiThreadIR {
activity.setContent {
FillColor(Color.Green)
Layout(
children = {
AtLeastSize(size = 10.ipx) {
AtLeastSize(size = 10.ipx) {
RepaintBoundary {
FillColor(Color.Cyan)
}
}
}
}
) { measurables, constraints ->
layout(width = 20.ipx, height = 20.ipx) {
measurables.first().measure(constraints)
.place(offset.offset, offset.offset)
}
}
}
}
assertTrue(drawLatch.await(1, TimeUnit.SECONDS))
activityTestRule.waitAndScreenShot().apply {
assertRect(Color.Cyan, size = 10, centerX = 5, centerY = 5)
assertRect(Color.Green, size = 10, centerX = 15, centerY = 15)
}
drawLatch = CountDownLatch(1)
activityTestRule.runOnUiThreadIR { offset.offset = 10.ipx }
assertTrue(drawLatch.await(1, TimeUnit.SECONDS))
activityTestRule.waitAndScreenShot().apply {
assertRect(Color.Green, size = 10, centerX = 5, centerY = 5)
assertRect(Color.Cyan, size = 10, centerX = 15, centerY = 15)
}
}
// When a child is removed, the parent must be remeasured and redrawn.
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
@Test
fun testRedrawOnRemovedChild() {
val drawChild = DoDraw(true)
val outerColor = Color(0xFF000080.toInt())
val innerColor = Color(0xFFFFFFFF.toInt())
activityTestRule.runOnUiThreadIR {
activity.setContent {
AtLeastSize(size = 30.ipx) {
Draw { canvas, parentSize ->
drawLatch.countDown()
val paint = Paint()
paint.color = outerColor
canvas.drawRect(parentSize.toRect(), paint)
}
AtLeastSize(size = 30.ipx) {
if (drawChild.value) {
Padding(size = 10.ipx) {
AtLeastSize(size = 10.ipx) {
Draw { canvas, parentSize ->
drawLatch.countDown()
val paint = Paint()
paint.color = innerColor
canvas.drawRect(parentSize.toRect(), paint)
}
}
}
}
}
}
}
}
validateSquareColors(outerColor = outerColor, innerColor = innerColor, size = 10)
drawLatch = CountDownLatch(1)
activityTestRule.runOnUiThreadIR { drawChild.value = false }
// The padded area doesn't draw
validateSquareColors(outerColor = outerColor, innerColor = outerColor, size = 10)
}
// When a child is removed, the parent must be remeasured.
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
@Test
fun testRelayoutOnRemovedChild() {
val drawChild = DoDraw(true)
val outerColor = Color(0xFF000080.toInt())
val innerColor = Color(0xFFFFFFFF.toInt())
activityTestRule.runOnUiThreadIR {
activity.setContent {
AtLeastSize(size = 30.ipx) {
Draw { canvas, parentSize ->
drawLatch.countDown()
val paint = Paint()
paint.color = outerColor
canvas.drawRect(parentSize.toRect(), paint)
}
Padding(size = 20.ipx) {
if (drawChild.value) {
AtLeastSize(size = 20.ipx) {
Draw { canvas, parentSize ->
drawLatch.countDown()
val paint = Paint()
paint.color = innerColor
canvas.drawRect(parentSize.toRect(), paint)
}
}
}
}
}
}
}
validateSquareColors(outerColor = outerColor, innerColor = innerColor, size = 20)
drawLatch = CountDownLatch(1)
activityTestRule.runOnUiThreadIR { drawChild.value = false }
// The padded area doesn't draw
validateSquareColors(outerColor = outerColor, innerColor = outerColor, size = 10)
}
private fun composeSquares(model: SquareModel) {
activityTestRule.runOnUiThreadIR {
activity.setContent {
Draw { canvas, parentSize ->
val paint = Paint()
paint.color = model.outerColor
canvas.drawRect(parentSize.toRect(), paint)
}
Padding(size = model.size) {
AtLeastSize(size = model.size) {
Draw { canvas, parentSize ->
drawLatch.countDown()
val paint = Paint()
paint.color = model.innerColor
canvas.drawRect(parentSize.toRect(), paint)
}
}
}
}
}
}
private fun composeSquaresWithNestedRepaintBoundaries(model: SquareModel) {
activityTestRule.runOnUiThreadIR {
activity.setContent {
FillColor(model.outerColor, doCountDown = false)
Padding(size = model.size) {
RepaintBoundary {
RepaintBoundary {
AtLeastSize(size = model.size) {
FillColor(model.innerColor)
}
}
}
}
}
}
}
private fun composeMovingSquaresWithRepaintBoundary(model: SquareModel, offset: OffsetModel) {
activityTestRule.runOnUiThreadIR {
activity.setContent {
FillColor(model.outerColor, doCountDown = false)
Position(size = model.size * 3, offset = offset) {
RepaintBoundary {
AtLeastSize(size = model.size) {
FillColor(model.innerColor)
}
}
}
}
}
}
private fun composeMovingSquares(model: SquareModel, offset: OffsetModel) {
activityTestRule.runOnUiThreadIR {
activity.setContent {
FillColor(model.outerColor, doCountDown = false)
Position(size = model.size * 3, offset = offset) {
AtLeastSize(size = model.size) {
FillColor(model.innerColor)
}
}
}
}
}
private fun composeNestedSquares(model: SquareModel) {
activityTestRule.runOnUiThreadIR {
activity.setContent {
Draw(children = {
AtLeastSize(size = (model.size * 3)) {
Draw(children = {
FillColor(model.innerColor)
}, onPaint = { canvas, parentSize ->
val paint = Paint()
paint.color = model.outerColor
canvas.drawRect(parentSize.toRect(), paint)
val start = model.size.value.toFloat()
val end = start * 2
canvas.nativeCanvas.save()
canvas.clipRect(Rect(start, start, end, end))
drawChildren()
canvas.nativeCanvas.restore()
})
}
}, onPaint = { canvas, parentSize ->
val paint = Paint()
paint.color = Color(0xFF000000.toInt())
canvas.drawRect(parentSize.toRect(), paint)
})
}
}
}
private fun validateSquareColors(
outerColor: Color,
innerColor: Color,
size: Int,
offset: Int = 0,
totalSize: Int = size * 3
) {
assertTrue(drawLatch.await(1, TimeUnit.SECONDS))
val bitmap = activityTestRule.waitAndScreenShot()
assertEquals(totalSize, bitmap.width)
assertEquals(totalSize, bitmap.height)
val squareStart = (totalSize - size) / 2 + offset
val squareEnd = totalSize - ((totalSize - size) / 2) + offset
for (x in 0 until totalSize) {
for (y in 0 until totalSize) {
val pixel = bitmap.getPixel(x, y)
val pixelString = Color(pixel).toString()
if (x < squareStart || x >= squareEnd || y < squareStart || y >= squareEnd) {
assertEquals(
"Pixel within drawn rect[$x, $y] is $outerColor, " +
"but was $pixelString", outerColor.toArgb(), pixel
)
} else {
assertEquals(
"Pixel within drawn rect[$x, $y] is $innerColor, " +
"but was $pixelString", innerColor.toArgb(), pixel
)
}
}
}
}
@Composable
private fun FillColor(color: Color, doCountDown: Boolean = true) {
Draw { canvas, parentSize ->
canvas.drawRect(parentSize.toRect(), Paint().apply {
this.color = color
})
if (doCountDown) {
drawLatch.countDown()
}
}
}
}
@Composable
fun AtLeastSize(size: IntPx, children: @Composable() () -> Unit) {
Layout(
measureBlock = { measurables, constraints ->
val newConstraints = Constraints(
minWidth = max(size, constraints.minWidth),
maxWidth = max(size, constraints.maxWidth),
minHeight = max(size, constraints.minHeight),
maxHeight = max(size, constraints.maxHeight)
)
val placeables = measurables.map { m ->
m.measure(newConstraints)
}
var maxWidth = size
var maxHeight = size
placeables.forEach { child ->
maxHeight = max(child.height, maxHeight)
maxWidth = max(child.width, maxWidth)
}
layout(maxWidth, maxHeight) {
placeables.forEach { child ->
child.place(0.ipx, 0.ipx)
}
}
}, children = children
)
}
@Composable
fun Align(children: @Composable() () -> Unit) {
Layout(
measureBlock = { measurables, constraints ->
val newConstraints = Constraints(
minWidth = IntPx.Zero,
maxWidth = constraints.maxWidth,
minHeight = IntPx.Zero,
maxHeight = constraints.maxHeight
)
val placeables = measurables.map { m ->
m.measure(newConstraints)
}
var maxWidth = constraints.minWidth
var maxHeight = constraints.minHeight
placeables.forEach { child ->
maxHeight = max(child.height, maxHeight)
maxWidth = max(child.width, maxWidth)
}
layout(maxWidth, maxHeight) {
placeables.forEach { child ->
child.place(0.ipx, 0.ipx)
}
}
}, children = children
)
}
@Composable
fun Padding(size: IntPx, children: @Composable() () -> Unit) {
Layout(
measureBlock = { measurables, constraints ->
val totalDiff = size * 2
val newConstraints = Constraints(
minWidth = (constraints.minWidth - totalDiff).coerceAtLeast(0.ipx),
maxWidth = (constraints.maxWidth - totalDiff).coerceAtLeast(0.ipx),
minHeight = (constraints.minHeight - totalDiff).coerceAtLeast(0.ipx),
maxHeight = (constraints.maxHeight - totalDiff).coerceAtLeast(0.ipx)
)
val placeables = measurables.map { m ->
m.measure(newConstraints)
}
var maxWidth = size
var maxHeight = size
placeables.forEach { child ->
maxHeight = max(child.height + totalDiff, maxHeight)
maxWidth = max(child.width + totalDiff, maxWidth)
}
layout(maxWidth, maxHeight) {
placeables.forEach { child ->
child.place(size, size)
}
}
}, children = children
)
}
@Composable
fun TwoMeasureLayout(
size: IntPx,
latch: CountDownLatch,
children: @Composable() () -> Unit
) {
Layout(children = children) { measurables, _ ->
val testConstraints = Constraints()
measurables.forEach { it.measure(testConstraints) }
val childConstraints = Constraints.tightConstraints(size, size)
try {
val placeables2 = measurables.map { it.measure(childConstraints) }
fail("Measuring twice on the same Measurable should throw an exception")
layout(size, size) {
placeables2.forEach { child ->
child.place(0.ipx, 0.ipx)
}
}
} catch (_: IllegalStateException) {
// expected
latch.countDown()
}
layout(0.ipx, 0.ipx, {})
}
}
@Composable
fun Position(size: IntPx, offset: OffsetModel, children: @Composable() () -> Unit) {
Layout(children) { measurables, constraints ->
val placeables = measurables.map { m ->
m.measure(constraints)
}
layout(size, size) {
placeables.forEach { child ->
child.place(offset.offset, offset.offset)
}
}
}
}
class DrawCounterListener(private val view: View) :
ViewTreeObserver.OnPreDrawListener {
val latch = CountDownLatch(5)
override fun onPreDraw(): Boolean {
latch.countDown()
if (latch.count > 0) {
view.postInvalidate()
} else {
view.viewTreeObserver.removeOnPreDrawListener(this)
}
return true
}
}
@Model
class SquareModel(
var size: IntPx = 10.ipx,
var outerColor: Color = Color(0xFF000080.toInt()),
var innerColor: Color = Color(0xFFFFFFFF.toInt())
)
@Model
class OffsetModel(var offset: IntPx)
@Model
class DoDraw(var value: Boolean = false)
// We only need this because IR compiler doesn't like converting lambdas to Runnables
fun ActivityTestRule<*>.runOnUiThreadIR(block: () -> Unit) {
val runnable: Runnable = object : Runnable {
override fun run() {
block()
}
}
runOnUiThread(runnable)
}
fun ActivityTestRule<*>.findAndroidCraneView(): AndroidCraneView {
val contentViewGroup = activity.findViewById<ViewGroup>(android.R.id.content)
return findAndroidCraneView(contentViewGroup)!!
}
fun findAndroidCraneView(parent: ViewGroup): AndroidCraneView? {
for (index in 0 until parent.childCount) {
val child = parent.getChildAt(index)
if (child is AndroidCraneView) {
return child
} else if (child is ViewGroup) {
val craneView = findAndroidCraneView(child)
if (craneView != null) {
return craneView
}
}
}
return null
}
@RequiresApi(Build.VERSION_CODES.O)
fun ActivityTestRule<*>.waitAndScreenShot(): Bitmap {
val view = findAndroidCraneView()
val flushListener = DrawCounterListener(view)
val offset = intArrayOf(0, 0)
var handler: Handler? = null
runOnUiThreadIR {
view.getLocationInWindow(offset)
view.viewTreeObserver.addOnPreDrawListener(flushListener)
view.invalidate()
handler = Handler()
}
assertTrue(flushListener.latch.await(1, TimeUnit.SECONDS))
val width = view.width
val height = view.height
val dest =
Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val srcRect = android.graphics.Rect(0, 0, width, height)
srcRect.offset(offset[0], offset[1])
val latch = CountDownLatch(1)
var copyResult = 0
val onCopyFinished = object : PixelCopy.OnPixelCopyFinishedListener {
override fun onPixelCopyFinished(result: Int) {
copyResult = result
latch.countDown()
}
}
PixelCopy.request(activity.window, srcRect, dest, onCopyFinished, handler!!)
assertTrue(latch.await(1, TimeUnit.SECONDS))
assertEquals(PixelCopy.SUCCESS, copyResult)
return dest
}