FlowLayout API Design and implementation
Relnote: A Flow Layout is a simple layout manager that arranges components at their preferred sizes, from left to right and top to bottom in the container. A flow layout arranges components in a directional flow, much like lines of text in a paragraph.
Bug: 123123123
Test: Tested with code and manually.
Change-Id: I3a7b26bff88ec172df7ab4acf62c2eefd5edb16d
diff --git a/compose/foundation/foundation-layout/api/current.txt b/compose/foundation/foundation-layout/api/current.txt
index 6537434..98b9343 100644
--- a/compose/foundation/foundation-layout/api/current.txt
+++ b/compose/foundation/foundation-layout/api/current.txt
@@ -111,6 +111,9 @@
method @androidx.compose.runtime.Stable public androidx.compose.ui.Modifier weight(androidx.compose.ui.Modifier, float weight, optional boolean fill);
}
+ public final class FlowLayoutKt {
+ }
+
public final class IntrinsicKt {
method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier height(androidx.compose.ui.Modifier, androidx.compose.foundation.layout.IntrinsicSize intrinsicSize);
method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier requiredHeight(androidx.compose.ui.Modifier, androidx.compose.foundation.layout.IntrinsicSize intrinsicSize);
diff --git a/compose/foundation/foundation-layout/api/public_plus_experimental_current.txt b/compose/foundation/foundation-layout/api/public_plus_experimental_current.txt
index ec7aeca..05b6344 100644
--- a/compose/foundation/foundation-layout/api/public_plus_experimental_current.txt
+++ b/compose/foundation/foundation-layout/api/public_plus_experimental_current.txt
@@ -114,6 +114,11 @@
@kotlin.RequiresOptIn(message="The API of this layout is experimental and is likely to change in the future.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface ExperimentalLayoutApi {
}
+ public final class FlowLayoutKt {
+ method @androidx.compose.foundation.layout.ExperimentalLayoutApi @androidx.compose.runtime.Composable public static void FlowColumn(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.ui.Alignment.Horizontal horizontalAlignment, optional int maxItemsInEachColumn, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.ColumnScope,kotlin.Unit> content);
+ method @androidx.compose.foundation.layout.ExperimentalLayoutApi @androidx.compose.runtime.Composable public static void FlowRow(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.ui.Alignment.Vertical verticalAlignment, optional int maxItemsInEachRow, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ }
+
public final class IntrinsicKt {
method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier height(androidx.compose.ui.Modifier, androidx.compose.foundation.layout.IntrinsicSize intrinsicSize);
method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier requiredHeight(androidx.compose.ui.Modifier, androidx.compose.foundation.layout.IntrinsicSize intrinsicSize);
diff --git a/compose/foundation/foundation-layout/api/restricted_current.txt b/compose/foundation/foundation-layout/api/restricted_current.txt
index ced4c05..f1576ee 100644
--- a/compose/foundation/foundation-layout/api/restricted_current.txt
+++ b/compose/foundation/foundation-layout/api/restricted_current.txt
@@ -114,6 +114,9 @@
method @androidx.compose.runtime.Stable public androidx.compose.ui.Modifier weight(androidx.compose.ui.Modifier, float weight, optional boolean fill);
}
+ public final class FlowLayoutKt {
+ }
+
public final class IntrinsicKt {
method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier height(androidx.compose.ui.Modifier, androidx.compose.foundation.layout.IntrinsicSize intrinsicSize);
method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier requiredHeight(androidx.compose.ui.Modifier, androidx.compose.foundation.layout.IntrinsicSize intrinsicSize);
diff --git a/compose/foundation/foundation-layout/benchmark/src/androidTest/java/androidx/compose/foundation/layout/benchmark/RectsInFlowColumnBenchmark.kt b/compose/foundation/foundation-layout/benchmark/src/androidTest/java/androidx/compose/foundation/layout/benchmark/RectsInFlowColumnBenchmark.kt
new file mode 100644
index 0000000..0a6072f
--- /dev/null
+++ b/compose/foundation/foundation-layout/benchmark/src/androidTest/java/androidx/compose/foundation/layout/benchmark/RectsInFlowColumnBenchmark.kt
@@ -0,0 +1,103 @@
+/*
+ * 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.compose.foundation.layout.benchmark
+
+import androidx.compose.testutils.benchmark.ComposeBenchmarkRule
+import androidx.compose.testutils.benchmark.benchmarkDrawPerf
+import androidx.compose.testutils.benchmark.benchmarkFirstCompose
+import androidx.compose.testutils.benchmark.benchmarkFirstDraw
+import androidx.compose.testutils.benchmark.benchmarkFirstLayout
+import androidx.compose.testutils.benchmark.benchmarkFirstMeasure
+import androidx.compose.testutils.benchmark.benchmarkLayoutPerf
+import androidx.compose.testutils.benchmark.toggleStateBenchmarkDraw
+import androidx.compose.testutils.benchmark.toggleStateBenchmarkLayout
+import androidx.compose.testutils.benchmark.toggleStateBenchmarkMeasure
+import androidx.compose.testutils.benchmark.toggleStateBenchmarkRecompose
+import androidx.test.filters.LargeTest
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+/**
+ * Benchmark that runs [RectsInFlowColumnTestCase].
+ */
+@LargeTest
+@RunWith(Parameterized::class)
+class RectsInFlowColumnBenchmark(private val numberOfRectangles: Int) {
+
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "{0}")
+ fun initParameters(): Array<Any> = arrayOf(10, 100)
+ }
+
+ @get:Rule
+ val benchmarkRule = ComposeBenchmarkRule()
+
+ private val rectsInFlowColumnCaseFactory = { RectsInFlowColumnTestCase(numberOfRectangles) }
+
+ @Test
+ fun first_compose() {
+ benchmarkRule.benchmarkFirstCompose(rectsInFlowColumnCaseFactory)
+ }
+
+ @Test
+ fun first_measure() {
+ benchmarkRule.benchmarkFirstMeasure(rectsInFlowColumnCaseFactory)
+ }
+
+ @Test
+ fun first_layout() {
+ benchmarkRule.benchmarkFirstLayout(rectsInFlowColumnCaseFactory)
+ }
+
+ @Test
+ fun first_draw() {
+ benchmarkRule.benchmarkFirstDraw(rectsInFlowColumnCaseFactory)
+ }
+
+ @Test
+ fun toggleRectangleColor_recompose() {
+ benchmarkRule.toggleStateBenchmarkRecompose(rectsInFlowColumnCaseFactory)
+ }
+
+ @Test
+ fun toggleRectangleColor_measure() {
+ benchmarkRule.toggleStateBenchmarkMeasure(rectsInFlowColumnCaseFactory)
+ }
+
+ @Test
+ fun toggleRectangleColor_layout() {
+ benchmarkRule.toggleStateBenchmarkLayout(rectsInFlowColumnCaseFactory)
+ }
+
+ @Test
+ fun toggleRectangleColor_draw() {
+ benchmarkRule.toggleStateBenchmarkDraw(rectsInFlowColumnCaseFactory)
+ }
+
+ @Test
+ fun layout() {
+ benchmarkRule.benchmarkLayoutPerf(rectsInFlowColumnCaseFactory)
+ }
+
+ @Test
+ fun draw() {
+ benchmarkRule.benchmarkDrawPerf(rectsInFlowColumnCaseFactory)
+ }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation-layout/benchmark/src/androidTest/java/androidx/compose/foundation/layout/benchmark/RectsInFlowColumnTestCase.kt b/compose/foundation/foundation-layout/benchmark/src/androidTest/java/androidx/compose/foundation/layout/benchmark/RectsInFlowColumnTestCase.kt
new file mode 100644
index 0000000..223b9f5
--- /dev/null
+++ b/compose/foundation/foundation-layout/benchmark/src/androidTest/java/androidx/compose/foundation/layout/benchmark/RectsInFlowColumnTestCase.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright 20 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.compose.foundation.layout.benchmark
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowColumn
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.testutils.LayeredComposeTestCase
+import androidx.compose.testutils.ToggleableTestCase
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+
+/**
+ * Test case that puts the given amount of rectangles into a column layout and makes changes by
+ * modifying the color used in the model.
+ *
+ * Note: Each rectangle has its own model so changes should always affect only the first one.
+ */
+@OptIn(ExperimentalLayoutApi::class)
+class RectsInFlowColumnTestCase(
+ private val amountOfRectangles: Int
+) : LayeredComposeTestCase(), ToggleableTestCase {
+
+ private val states = mutableListOf<MutableState<Color>>()
+
+ @Composable
+ override fun MeasuredContent() {
+ FlowColumn(maxItemsInEachColumn = 3) {
+ repeat(amountOfRectangles) {
+ ColoredRectWithModel()
+ }
+ }
+ }
+
+ override fun toggleState() {
+ val state = states.first()
+ if (state.value == Color.Magenta) {
+ state.value = Color.Blue
+ } else {
+ state.value = Color.Magenta
+ }
+ }
+
+ @Composable
+ fun ColoredRectWithModel() {
+ val state = remember { mutableStateOf(Color.Black) }
+ states.add(state)
+ Box(
+ Modifier
+ .size(100.dp, 50.dp)
+ .background(color = state.value))
+ }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation-layout/integration-tests/layout-demos/src/main/java/androidx/compose/foundation/layout/demos/LayoutDemos.kt b/compose/foundation/foundation-layout/integration-tests/layout-demos/src/main/java/androidx/compose/foundation/layout/demos/LayoutDemos.kt
index e6a8e2a..104d27c 100644
--- a/compose/foundation/foundation-layout/integration-tests/layout-demos/src/main/java/androidx/compose/foundation/layout/demos/LayoutDemos.kt
+++ b/compose/foundation/foundation-layout/integration-tests/layout-demos/src/main/java/androidx/compose/foundation/layout/demos/LayoutDemos.kt
@@ -23,6 +23,8 @@
"Layout",
listOf(
ComposableDemo("Row and column") { SimpleLayoutDemo() },
+ ComposableDemo("Flow Column") { SimpleFlowColumnDemo() },
+ ComposableDemo("Flow Row") { SimpleFlowRowDemo() },
ComposableDemo("Rtl support") { RtlDemo() }
)
)
diff --git a/compose/foundation/foundation-layout/integration-tests/layout-demos/src/main/java/androidx/compose/foundation/layout/demos/SimpleFlowColumnDemo.kt b/compose/foundation/foundation-layout/integration-tests/layout-demos/src/main/java/androidx/compose/foundation/layout/demos/SimpleFlowColumnDemo.kt
new file mode 100644
index 0000000..4cdd0f9
--- /dev/null
+++ b/compose/foundation/foundation-layout/integration-tests/layout-demos/src/main/java/androidx/compose/foundation/layout/demos/SimpleFlowColumnDemo.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2021 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.compose.foundation.layout.demos
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowColumn
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredHeight
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun SimpleFlowColumnDemo() {
+ Column() {
+ FlowColumn(
+ Modifier
+ .fillMaxWidth(1f)
+ .wrapContentHeight(align = Alignment.Top)
+ .requiredHeight(200.dp)
+ .border(BorderStroke(2.dp, Color.Gray)),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ maxItemsInEachColumn = 3
+ ) {
+
+ // handling overflow issues properly
+ // reversing the width and height aspect ratios will be buggy
+ repeat(9) {
+ Box(
+ Modifier
+ .padding(10.dp)
+ .width(50.dp)
+ .height(50.dp)
+ .background(Color(0xFF6200ED))
+ .weight(1f, true)
+ ) {
+ Text(text = it.toString(), fontSize = 18.sp, modifier = Modifier.padding(3.dp))
+ }
+ }
+ }
+
+ FlowColumn(
+ Modifier
+ .fillMaxHeight(1f)
+ .wrapContentWidth(),
+ verticalArrangement = Arrangement.Top,
+ maxItemsInEachColumn = 5
+ ) {
+ repeat(10) { _ ->
+ Box(
+ Modifier
+ .size(20.dp)
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation-layout/integration-tests/layout-demos/src/main/java/androidx/compose/foundation/layout/demos/SimpleFlowRowDemo.kt b/compose/foundation/foundation-layout/integration-tests/layout-demos/src/main/java/androidx/compose/foundation/layout/demos/SimpleFlowRowDemo.kt
new file mode 100644
index 0000000..460270d
--- /dev/null
+++ b/compose/foundation/foundation-layout/integration-tests/layout-demos/src/main/java/androidx/compose/foundation/layout/demos/SimpleFlowRowDemo.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2021 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.compose.foundation.layout.demos
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredHeight
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun SimpleFlowRowDemo() {
+ Column() {
+ FlowRow(
+ Modifier
+ .wrapContentWidth(align = Alignment.Start)
+ .wrapContentHeight(align = Alignment.Top)
+ .requiredHeight(150.dp)
+ .border(BorderStroke(2.dp, Color.Gray)),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ maxItemsInEachRow = Int.MAX_VALUE
+ ) {
+ repeat(50) {
+ Text("Heldo")
+ }
+ }
+ FlowRow(
+ Modifier
+ .fillMaxWidth(1f)
+ .wrapContentHeight(align = Alignment.Top)
+ .border(BorderStroke(2.dp, Color.Gray)),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.End,
+ maxItemsInEachRow = 3
+ ) {
+ repeat(10) {
+ Box(
+ Modifier
+ .padding(10.dp)
+ .width(50.dp)
+ .height(
+ if (it % 2 == 0) {
+ 30.dp
+ } else {
+ 50.dp
+ }
+ )
+ .background(Color(0xFF6200ED))
+ .weight(1f, false)
+ ) {
+ Text(text = it.toString(), fontSize = 18.sp, modifier = Modifier.padding(3.dp))
+ }
+ }
+ }
+
+ FlowRow(
+ Modifier
+ .wrapContentHeight()
+ .width(200.dp),
+ horizontalArrangement = Arrangement.Start,
+ maxItemsInEachRow = 5
+ ) {
+ repeat(6) { _ ->
+ Box(
+ Modifier
+ .size(20.dp)
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation-layout/src/androidAndroidTest/kotlin/androidx/compose/foundation/layout/FlowRowColumnTest.kt b/compose/foundation/foundation-layout/src/androidAndroidTest/kotlin/androidx/compose/foundation/layout/FlowRowColumnTest.kt
new file mode 100644
index 0000000..4ac0eb0
--- /dev/null
+++ b/compose/foundation/foundation-layout/src/androidAndroidTest/kotlin/androidx/compose/foundation/layout/FlowRowColumnTest.kt
@@ -0,0 +1,1271 @@
+/*
+ * 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.
+ */
+
+package androidx.compose.foundation.layout
+
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.onPlaced
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.layout.positionInParent
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth
+import kotlin.math.roundToInt
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalLayoutApi::class)
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class FlowRowColumnTest {
+
+ @get:Rule
+ val rule = createComposeRule()
+
+ @Test
+ fun testFlowRow_wrapsToTheNextLine() {
+ var height = 0
+
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(100.toDp())) {
+ FlowRow(
+ Modifier
+ .wrapContentHeight()
+ .onSizeChanged {
+ height = it.height
+ }) {
+ repeat(6) {
+ Box(Modifier.size(20.toDp()))
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ Truth.assertThat(height).isEqualTo(40)
+ }
+
+ @Test
+ fun testFlowColumn_wrapsToTheNextLine() {
+ var width = 0
+
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(100.toDp())) {
+ FlowColumn(
+ Modifier
+ .wrapContentHeight()
+ .onSizeChanged {
+ width = it.width
+ }) {
+ repeat(6) {
+ Box(Modifier.size(20.toDp()))
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ Truth.assertThat(width).isEqualTo(40)
+ }
+
+ @Test
+ fun testFlowRow_wrapsToTheNextLine_withExactSpaceNeeded() {
+ var height = 0
+
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(100.toDp())) {
+ FlowRow(
+ Modifier
+ .wrapContentHeight()
+ .onSizeChanged {
+ height = it.height
+ }) {
+ repeat(10) {
+ Box(Modifier.size(20.toDp()))
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ Truth.assertThat(height).isEqualTo(40)
+ }
+
+ @Test
+ fun testFlowColumn_wrapsToTheNextLine_withExactSpaceNeeded() {
+ var width = 0
+
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(100.toDp())) {
+ FlowColumn(
+ Modifier
+ .wrapContentHeight()
+ .onSizeChanged {
+ width = it.width
+ }) {
+ repeat(10) {
+ Box(Modifier.size(20.toDp()))
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ Truth.assertThat(width).isEqualTo(40)
+ }
+
+ @Test
+ fun testFlowRow_wrapsToTheNextLineMultipleTimes() {
+ var height = 0
+
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(60.toDp())) {
+ FlowRow(
+ Modifier
+ .wrapContentHeight()
+ .onSizeChanged {
+ height = it.height
+ }) {
+ repeat(6) {
+ Box(Modifier.size(20.toDp()))
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ Truth.assertThat(height).isEqualTo(40)
+ }
+
+ @Test
+ fun testFlowColumn_wrapsToTheNextLineMultipleTimes() {
+ var width = 0
+
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(60.toDp())) {
+ FlowColumn(
+ Modifier
+ .wrapContentHeight()
+ .onSizeChanged {
+ width = it.width
+ }) {
+ repeat(6) {
+ Box(Modifier.size(20.toDp()))
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ Truth.assertThat(width).isEqualTo(40)
+ }
+
+ @Test
+ fun testFlowRow_wrapsWithMaxItems() {
+ var height = 0
+
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(60.toDp())) {
+ FlowRow(
+ Modifier
+ .wrapContentHeight()
+ .onSizeChanged {
+ height = it.height
+ }, maxItemsInEachRow = 2) {
+ repeat(6) {
+ Box(Modifier.size(20.toDp()))
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ Truth.assertThat(height).isEqualTo(60)
+ }
+
+ @Test
+ fun testFlowColumn_wrapsWithMaxItems() {
+ var width = 0
+
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(60.toDp())) {
+ FlowColumn(
+ Modifier
+ .wrapContentHeight()
+ .onSizeChanged {
+ width = it.width
+ }, maxItemsInEachColumn = 2) {
+ repeat(6) {
+ Box(Modifier.size(20.toDp()))
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ Truth.assertThat(width).isEqualTo(60)
+ }
+
+ @Test
+ fun testFlowRow_wrapsWithWeights() {
+ var height = 0
+
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(60.toDp())) {
+ FlowRow(
+ Modifier
+ .wrapContentHeight()
+ .onSizeChanged {
+ height = it.height
+ }, maxItemsInEachRow = 2) {
+ repeat(6) {
+ Box(
+ Modifier
+ .size(20.toDp())
+ .weight(1f, true))
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ Truth.assertThat(height).isEqualTo(60)
+ }
+
+ @Test
+ fun testFlowColumn_wrapsWithWeights() {
+ var width = 0
+
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(60.toDp())) {
+ FlowColumn(
+ Modifier
+ .wrapContentHeight()
+ .onSizeChanged {
+ width = it.width
+ }, maxItemsInEachColumn = 2) {
+ repeat(6) {
+ Box(
+ Modifier
+ .size(20.toDp())
+ .weight(1f, true))
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ Truth.assertThat(width).isEqualTo(60)
+ }
+
+ @Test
+ fun testFlowRow_staysInOneRow() {
+ var height = 0
+
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(50.toDp())) {
+ FlowRow(
+ Modifier
+ .wrapContentHeight()
+ .onSizeChanged {
+ height = it.height
+ }) {
+ repeat(2) {
+ Box(Modifier.size(20.toDp()))
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ Truth.assertThat(height).isEqualTo(20)
+ }
+
+ @Test
+ fun testFlowColumn_staysInOneRow() {
+ var width = 0
+
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(50.toDp())) {
+ FlowColumn(
+ Modifier
+ .wrapContentHeight()
+ .onSizeChanged {
+ width = it.width
+ }) {
+ repeat(2) {
+ Box(Modifier.size(20.toDp()))
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ Truth.assertThat(width).isEqualTo(20)
+ }
+
+ @Test
+ fun testFlowRow_wrapsToTheNextLine_Rounding() {
+ var height = 0
+
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(50.toDp())) {
+ FlowRow(
+ Modifier
+ .wrapContentHeight()
+ .onSizeChanged {
+ height = it.height
+ }) {
+ repeat(3) {
+ Box(Modifier.size(20.toDp()))
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ Truth.assertThat(height).isEqualTo(40)
+ }
+
+ @Test
+ fun testFlowColumn_wrapsToTheNextLine_Rounding() {
+ var width = 0
+
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(50.toDp())) {
+ FlowColumn(
+ Modifier
+ .wrapContentHeight()
+ .onSizeChanged {
+ width = it.width
+ }) {
+ repeat(3) {
+ Box(Modifier.size(20.toDp()))
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ Truth.assertThat(width).isEqualTo(40)
+ }
+
+ @Test
+ fun testFlowRow_centerVertically() {
+
+ val totalRowHeight = 20
+ val shorterHeight = 10
+ val expectedResult = (totalRowHeight - shorterHeight) / 2
+ var positionInParentY = 0f
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(200.toDp())) {
+ FlowRow(
+ Modifier.wrapContentHeight(),
+ verticalAlignment = Alignment.CenterVertically) {
+ repeat(5) { index ->
+ Box(
+ Modifier
+ .size(
+ 20.toDp(),
+ if (index == 4) {
+ shorterHeight.toDp()
+ } else {
+ totalRowHeight.toDp()
+ }
+ )
+ .onPlaced {
+ if (index == 4) {
+ val positionInParent = it.positionInParent()
+ positionInParentY = positionInParent.y
+ }
+ })
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ Truth.assertThat(positionInParentY).isEqualTo(expectedResult)
+ }
+
+ @Test
+ fun testFlowColumn_centerHorizontally() {
+
+ val totalColumnWidth = 20
+ val shorterWidth = 10
+ val expectedResult = (totalColumnWidth - shorterWidth) / 2
+ var positionInParentX = 0f
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(200.toDp())) {
+ FlowColumn(
+ Modifier.wrapContentHeight(),
+ horizontalAlignment = Alignment.CenterHorizontally) {
+ repeat(5) { index ->
+ Box(
+ Modifier
+ .size(
+ if (index == 4) {
+ shorterWidth.toDp()
+ } else {
+ totalColumnWidth.toDp()
+ },
+ 20.toDp()
+ )
+ .onPlaced {
+ if (index == 4) {
+ val positionInParent = it.positionInParent()
+ positionInParentX = positionInParent.x
+ }
+ })
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ Truth.assertThat(positionInParentX).isEqualTo(expectedResult)
+ }
+
+ @Test
+ fun testFlowRow_horizontalArrangementSpaceAround() {
+ val size = 200f
+ val noOfItemsPerRow = 5
+ val eachSize = 20
+ val spaceAvailable = size - (noOfItemsPerRow * eachSize) // 100
+ val eachItemSpaceGiven = spaceAvailable / noOfItemsPerRow
+ val gapSize = (eachItemSpaceGiven / 2).roundToInt()
+ // ----
+ // * Visually: #1##2##3# for LTR and #3##2##1# for RTL
+ // --(front) - (back) --
+
+ val xPositions = FloatArray(noOfItemsPerRow)
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(200.toDp())) {
+ FlowRow(Modifier.wrapContentHeight().fillMaxWidth(1f),
+ horizontalArrangement = Arrangement.SpaceAround) {
+ repeat(5) { index ->
+ Box(
+ Modifier
+ .size(20.toDp())
+ .onPlaced {
+ val positionInParent = it.positionInParent()
+ val xPosition = positionInParent.x
+ xPositions[index] = xPosition
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ var expectedXPosition = 0
+ xPositions.forEach {
+ val xPosition = it
+ expectedXPosition += gapSize
+ Truth
+ .assertThat(xPosition)
+ .isEqualTo(expectedXPosition)
+ expectedXPosition += eachSize
+ expectedXPosition += gapSize
+ }
+ }
+
+ @Test
+ fun testFlowColumn_verticalArrangementSpaceAround() {
+ val size = 200f
+ val noOfItemsPerRow = 5
+ val eachSize = 20
+ val spaceAvailable = size - (noOfItemsPerRow * eachSize) // 100
+ val eachItemSpaceGiven = spaceAvailable / noOfItemsPerRow
+ val gapSize = (eachItemSpaceGiven / 2).roundToInt()
+
+ val yPositions = FloatArray(noOfItemsPerRow)
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(200.toDp())) {
+ FlowColumn(Modifier.wrapContentWidth().fillMaxHeight(1f),
+ verticalArrangement = Arrangement.SpaceAround) {
+ repeat(5) { index ->
+ Box(
+ Modifier
+ .size(20.toDp())
+ .onPlaced {
+ val positionInParent = it.positionInParent()
+ val yPosition = positionInParent.y
+ yPositions[index] = yPosition
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ var expectedYPosition = 0
+ yPositions.forEach {
+ val yPosition = it
+ expectedYPosition += gapSize
+ Truth
+ .assertThat(yPosition)
+ .isEqualTo(expectedYPosition)
+ expectedYPosition += eachSize
+ expectedYPosition += gapSize
+ }
+ }
+
+ @Test
+ fun testFlowRow_horizontalArrangementSpaceAround_withTwoRows() {
+ val size = 200f
+ val noOfItemsPerRow = 5
+ val eachSize = 20
+ val spaceAvailable = size - (noOfItemsPerRow * eachSize) // 100
+ val eachItemSpaceGiven = spaceAvailable / noOfItemsPerRow
+ val gapSize = (eachItemSpaceGiven / 2).roundToInt()
+ // ----
+ // * Visually: #1##2##3# for LTR and #3##2##1# for RTL
+ // --(front) - (back) --
+
+ val xPositions = FloatArray(10)
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(200.toDp())) {
+ FlowRow(Modifier.wrapContentHeight().fillMaxWidth(1f),
+ horizontalArrangement = Arrangement.SpaceAround,
+ maxItemsInEachRow = 5
+ ) {
+ repeat(10) { index ->
+ Box(
+ Modifier
+ .size(20.toDp())
+ .onPlaced {
+ val positionInParent = it.positionInParent()
+ val xPosition = positionInParent.x
+ xPositions[index] = xPosition
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ var expectedXPosition = 0
+ xPositions.forEachIndexed { index, xPosition ->
+ if (index % 5 == 0) {
+ expectedXPosition = 0
+ }
+ expectedXPosition += gapSize
+ Truth
+ .assertThat(xPosition)
+ .isEqualTo(expectedXPosition)
+ expectedXPosition += eachSize
+ expectedXPosition += gapSize
+ }
+ }
+
+ @Test
+ fun testFlowColumn_verticalArrangementSpaceAround_withTwoColumns() {
+ val size = 200f
+ val noOfItemsPerRow = 5
+ val eachSize = 20
+ val spaceAvailable = size - (noOfItemsPerRow * eachSize) // 100
+ val eachItemSpaceGiven = spaceAvailable / noOfItemsPerRow
+ val gapSize = (eachItemSpaceGiven / 2).roundToInt()
+
+ val yPositions = FloatArray(10)
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(200.toDp())) {
+ FlowColumn(Modifier.wrapContentWidth().fillMaxHeight(1f),
+ verticalArrangement = Arrangement.SpaceAround,
+ maxItemsInEachColumn = 5
+ ) {
+ repeat(10) { index ->
+ Box(
+ Modifier
+ .size(20.toDp())
+ .onPlaced {
+ val positionInParent = it.positionInParent()
+ val yPosition = positionInParent.y
+ yPositions[index] = yPosition
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ var expectedYPosition = 0
+ yPositions.forEachIndexed { index, position ->
+ if (index % 5 == 0) {
+ expectedYPosition = 0
+ }
+ expectedYPosition += gapSize
+ Truth
+ .assertThat(position)
+ .isEqualTo(expectedYPosition)
+ expectedYPosition += eachSize
+ expectedYPosition += gapSize
+ }
+ }
+
+ @Test
+ fun testFlowRow_horizontalArrangementEnd() {
+ val size = 200f
+ val noOfItemsPerRow = 5
+ val eachSize = 20
+ val spaceAvailable = size - (noOfItemsPerRow * eachSize) // 100
+ val gapSize = spaceAvailable.roundToInt()
+ // * Visually: ####123
+
+ val xPositions = FloatArray(10)
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(200.toDp())) {
+ FlowRow(Modifier.wrapContentHeight().fillMaxWidth(1f),
+ horizontalArrangement = Arrangement.End,
+ maxItemsInEachRow = 5
+ ) {
+ repeat(10) { index ->
+ Box(
+ Modifier
+ .size(20.toDp())
+ .onPlaced {
+ val positionInParent = it.positionInParent()
+ val xPosition = positionInParent.x
+ xPositions[index] = xPosition
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ var expectedXPosition = gapSize
+ xPositions.forEachIndexed { index, position ->
+ if (index % 5 == 0) {
+ expectedXPosition = gapSize
+ }
+ Truth
+ .assertThat(position)
+ .isEqualTo(expectedXPosition)
+ expectedXPosition += eachSize
+ }
+ }
+
+ @Test
+ fun testFlowColumn_verticalArrangementBottom() {
+ val size = 200f
+ val noOfItemsPerRow = 5
+ val eachSize = 20
+ val spaceAvailable = size - (noOfItemsPerRow * eachSize) // 100
+ val gapSize = spaceAvailable.roundToInt()
+
+ val yPositions = FloatArray(10)
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(200.toDp())) {
+ FlowColumn(Modifier.fillMaxHeight(1f).wrapContentWidth(),
+ verticalArrangement = Arrangement.Bottom,
+ maxItemsInEachColumn = 5
+ ) {
+ repeat(10) { index ->
+ Box(
+ Modifier
+ .size(20.toDp())
+ .onPlaced {
+ val positionInParent = it.positionInParent()
+ val yPosition = positionInParent.y
+ yPositions[index] = yPosition
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ var expectedYPosition = gapSize
+ yPositions.forEachIndexed { index, position ->
+ if (index % 5 == 0) {
+ expectedYPosition = gapSize
+ }
+ Truth
+ .assertThat(position)
+ .isEqualTo(expectedYPosition)
+ expectedYPosition += eachSize
+ }
+ }
+ @Test
+ fun testFlowRow_horizontalArrangementStart() {
+ val eachSize = 20
+ val maxItemsInMainAxis = 5
+ // * Visually: 123####
+
+ val xPositions = FloatArray(10)
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(200.toDp())) {
+ FlowRow(Modifier.wrapContentHeight(),
+ horizontalArrangement = Arrangement.Start,
+ maxItemsInEachRow = maxItemsInMainAxis
+ ) {
+ repeat(10) { index ->
+ Box(
+ Modifier
+ .size(eachSize.toDp())
+ .onPlaced {
+ val positionInParent = it.positionInParent()
+ val xPosition = positionInParent.x
+ xPositions[index] = xPosition
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ var expectedXPosition = 0
+ xPositions.forEachIndexed { index, position ->
+ Truth
+ .assertThat(position)
+ .isEqualTo(expectedXPosition)
+ if (index == (maxItemsInMainAxis - 1)) {
+ expectedXPosition = 0
+ } else {
+ expectedXPosition += eachSize
+ }
+ }
+ }
+
+ @Test
+ fun testFlowRow_SpaceAligned() {
+ val eachSize = 10
+ val maxItemsInMainAxis = 5
+ val spaceAligned = 10
+
+ val xPositions = FloatArray(10)
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(200.toDp())) {
+ FlowRow(Modifier.wrapContentHeight(),
+ horizontalArrangement = Arrangement.spacedBy(spaceAligned.toDp()),
+ maxItemsInEachRow = maxItemsInMainAxis
+ ) {
+ repeat(10) { index ->
+ Box(
+ Modifier
+ .size(eachSize.toDp())
+ .onPlaced {
+ val positionInParent = it.positionInParent()
+ val xPosition = positionInParent.x
+ xPositions[index] = xPosition
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ var expectedXPosition = 0
+ xPositions.forEachIndexed { index, position ->
+ if (index % maxItemsInMainAxis == 0) {
+ expectedXPosition = 0
+ } else {
+ expectedXPosition += eachSize
+ expectedXPosition += spaceAligned
+ }
+
+ Truth
+ .assertThat(position)
+ .isEqualTo(expectedXPosition)
+ }
+ }
+
+ @Test
+ fun testFlowColumn_SpaceAligned() {
+ val eachSize = 10
+ val maxItemsInMainAxis = 5
+ val spaceAligned = 10
+
+ val yPositions = FloatArray(10)
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(200.toDp())) {
+ FlowColumn(Modifier.wrapContentHeight(),
+ verticalArrangement = Arrangement.spacedBy(spaceAligned.toDp()),
+ maxItemsInEachColumn = maxItemsInMainAxis
+ ) {
+ repeat(10) { index ->
+ Box(
+ Modifier
+ .size(eachSize.toDp())
+ .onPlaced {
+ val positionInParent = it.positionInParent()
+ val position = positionInParent.y
+ yPositions[index] = position
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ var expectedYPosition = 0
+ yPositions.forEachIndexed { index, position ->
+ if (index % maxItemsInMainAxis == 0) {
+ expectedYPosition = 0
+ } else {
+ expectedYPosition += eachSize
+ expectedYPosition += spaceAligned
+ }
+
+ Truth
+ .assertThat(position)
+ .isEqualTo(expectedYPosition)
+ }
+ }
+
+ @Test
+ fun testFlowRow_SpaceAligned_notExact() {
+ val eachSize = 10
+ val maxItemsInMainAxis = 5
+ val spaceAligned = 10
+ val noOfItemsThatCanFit = 2
+
+ var width = 0
+ val expectedWidth = 30
+ val xPositions = FloatArray(10)
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.wrapContentHeight().widthIn(30.toDp(), 40.toDp())) {
+ FlowRow(Modifier.wrapContentHeight().onSizeChanged {
+ width = it.width
+ },
+ horizontalArrangement = Arrangement.spacedBy(spaceAligned.toDp()),
+ maxItemsInEachRow = maxItemsInMainAxis
+ ) {
+ repeat(10) { index ->
+ Box(
+ Modifier
+ .size(eachSize.toDp())
+ .onPlaced {
+ val positionInParent = it.positionInParent()
+ val xPosition = positionInParent.x
+ xPositions[index] = xPosition
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ Truth.assertThat(width).isEqualTo(expectedWidth)
+ var expectedXPosition = 0
+ xPositions.forEachIndexed { index, position ->
+ if (index % noOfItemsThatCanFit == 0) {
+ expectedXPosition = 0
+ } else {
+ expectedXPosition += eachSize
+ expectedXPosition += spaceAligned
+ }
+
+ Truth
+ .assertThat(position)
+ .isEqualTo(expectedXPosition)
+ }
+ }
+
+ @Test
+ fun testFlowColumn_SpaceAligned_notExact() {
+ val eachSize = 10
+ val maxItemsInMainAxis = 5
+ val spaceAligned = 10
+ val noOfItemsThatCanFit = 2
+
+ var height = 0
+ val expectedHeight = 30
+ val yPositions = FloatArray(10)
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.heightIn(30.toDp(), 40.toDp()).wrapContentWidth()) {
+ FlowColumn(Modifier.wrapContentHeight().onSizeChanged {
+ height = it.height
+ },
+ verticalArrangement = Arrangement.spacedBy(spaceAligned.toDp()),
+ maxItemsInEachColumn = maxItemsInMainAxis
+ ) {
+ repeat(10) { index ->
+ Box(
+ Modifier
+ .size(eachSize.toDp())
+ .onPlaced {
+ val positionInParent = it.positionInParent()
+ val yPosition = positionInParent.y
+ yPositions[index] = yPosition
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ Truth.assertThat(height).isEqualTo(expectedHeight)
+ var expectedYPosition = 0
+ yPositions.forEachIndexed { index, position ->
+ if (index % noOfItemsThatCanFit == 0) {
+ expectedYPosition = 0
+ } else {
+ expectedYPosition += eachSize
+ expectedYPosition += spaceAligned
+ }
+
+ Truth
+ .assertThat(position)
+ .isEqualTo(expectedYPosition)
+ }
+ }
+
+ @Test
+ fun testFlowColumn_verticalArrangementTop() {
+ val size = 200f
+ val eachSize = 20
+ val maxItemsInMainAxis = 5
+
+ val yPositions = FloatArray(10)
+ rule.setContent {
+ with(LocalDensity.current) {
+ Box(Modifier.size(size.toDp())) {
+ FlowColumn(Modifier.fillMaxHeight(1f).wrapContentWidth(),
+ verticalArrangement = Arrangement.Top,
+ maxItemsInEachColumn = maxItemsInMainAxis
+ ) {
+ repeat(10) { index ->
+ Box(
+ Modifier
+ .size(20.toDp())
+ .onPlaced {
+ val positionInParent = it.positionInParent()
+ val yPosition = positionInParent.y
+ yPositions[index] = yPosition
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ var expectedYPosition = 0
+ yPositions.forEachIndexed { index, position ->
+ if (index % 5 == 0) {
+ expectedYPosition = 0
+ }
+ Truth
+ .assertThat(position)
+ .isEqualTo(expectedYPosition)
+ expectedYPosition += eachSize
+ }
+ }
+
+ @Test
+ fun testFlowRow_horizontalArrangementStart_rtl_fillMaxWidth() {
+ val size = 200f
+ val eachSize = 20
+ val maxItemsInMainAxis = 5
+ // * Visually:
+ // #54321
+ // ####6
+
+ val xPositions = FloatArray(6)
+ rule.setContent {
+ CompositionLocalProvider(values = arrayOf(
+ LocalLayoutDirection provides LayoutDirection.Rtl,
+ )) {
+ with(LocalDensity.current) {
+ Box(Modifier.size(size.toDp())) {
+ FlowRow(Modifier.wrapContentHeight().fillMaxWidth(1f),
+ horizontalArrangement = Arrangement.Start,
+ maxItemsInEachRow = maxItemsInMainAxis
+ ) {
+ repeat(6) { index ->
+ Box(
+ Modifier
+ .size(eachSize.toDp())
+ .onPlaced {
+ val positionInParent = it.positionInParent()
+ val xPosition = positionInParent.x
+ xPositions[index] = xPosition
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ var expectedXPosition = size.toInt() - eachSize
+ xPositions.forEachIndexed { index, position ->
+ Truth
+ .assertThat(position)
+ .isEqualTo(expectedXPosition)
+ if (index == (maxItemsInMainAxis - 1)) {
+ expectedXPosition = size.toInt() - eachSize
+ } else {
+ expectedXPosition -= eachSize
+ }
+ }
+ }
+
+ @Test
+ fun testFlowColumn_verticalArrangementTop_rtl_fillMaxWidth() {
+ val size = 200f
+ val eachSize = 20
+ val maxItemsInMainAxis = 5
+
+ val xYPositions = Array<Pair<Float, Float>>(10) { Pair(0f, 0f) }
+ rule.setContent {
+ CompositionLocalProvider(values = arrayOf(
+ LocalLayoutDirection provides LayoutDirection.Rtl,
+ )) {
+ with(LocalDensity.current) {
+ Box(Modifier.size(size.toDp())) {
+ FlowColumn(Modifier.fillMaxHeight(1f).fillMaxWidth(1f),
+ verticalArrangement = Arrangement.Top,
+ maxItemsInEachColumn = maxItemsInMainAxis
+ ) {
+ repeat(10) { index ->
+ Box(
+ Modifier
+ .size(20.toDp())
+ .onPlaced {
+ val positionInParent = it.positionInParent()
+ val yPosition = positionInParent.y
+ val xPosition = positionInParent.x
+ xYPositions[index] = Pair(xPosition, yPosition)
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+
+ var expectedYPosition = 0
+ var expectedXPosition = size.toInt() - eachSize
+ for (index in xYPositions.indices) {
+ val xPosition = xYPositions[index].first
+ val yPosition = xYPositions[index].second
+ if (index % 5 == 0) {
+ expectedYPosition = 0
+ }
+ Truth
+ .assertThat(yPosition)
+ .isEqualTo(expectedYPosition)
+ Truth
+ .assertThat(xPosition)
+ .isEqualTo(expectedXPosition)
+ if (index == (maxItemsInMainAxis - 1)) {
+ expectedXPosition -= eachSize
+ }
+ expectedYPosition += eachSize
+ }
+ }
+
+ @Test
+ fun testFlowColumn_verticalArrangementTop_rtl_wrapContentWidth() {
+ val size = 200f
+ val eachSize = 20
+ val maxItemsInMainAxis = 5
+
+ var itemsThatCanFit = 0
+ var width = 0
+ val xYPositions = Array<Pair<Float, Float>>(10) { Pair(0f, 0f) }
+ rule.setContent {
+ CompositionLocalProvider(values = arrayOf(
+ LocalLayoutDirection provides LayoutDirection.Rtl,
+ )) {
+ with(LocalDensity.current) {
+ Box(Modifier.size(size.toDp())) {
+ FlowColumn(Modifier.fillMaxHeight(1f).wrapContentWidth()
+ .onSizeChanged {
+ width = it.width
+ itemsThatCanFit = it.height / eachSize
+ },
+ verticalArrangement = Arrangement.Top,
+ maxItemsInEachColumn = maxItemsInMainAxis
+ ) {
+ repeat(10) { index ->
+ Box(
+ Modifier
+ .size(20.toDp())
+ .onPlaced {
+ val positionInParent = it.positionInParent()
+ val xPosition = positionInParent.x
+ val yPosition = positionInParent.y
+ xYPositions[index] = Pair(xPosition, yPosition)
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ var expectedYPosition = 0
+ var expectedXPosition = width
+ var fittedItems = 0
+ for (index in xYPositions.indices) {
+ val pair = xYPositions[index]
+ val xPosition = pair.first
+ val yPosition = pair.second
+ if (index % maxItemsInMainAxis == 0 ||
+ fittedItems == itemsThatCanFit) {
+ expectedYPosition = 0
+ expectedXPosition -= eachSize
+ fittedItems = 0
+ }
+ Truth
+ .assertThat(yPosition)
+ .isEqualTo(expectedYPosition)
+ Truth
+ .assertThat(xPosition)
+ .isEqualTo(expectedXPosition)
+ expectedYPosition += eachSize
+ fittedItems++
+ }
+ }
+
+ @Test
+ fun testFlowRow_horizontalArrangementStart_rtl_wrap() {
+ val eachSize = 20
+ val maxItemsInMainAxis = 5
+ val maxMainAxisSize = 100
+ // * Visually:
+ // #54321
+ // ####6
+
+ val xPositions = FloatArray(6)
+ rule.setContent {
+ CompositionLocalProvider(
+ values = arrayOf(
+ LocalLayoutDirection provides LayoutDirection.Rtl,
+ )
+ ) {
+ with(LocalDensity.current) {
+ Box(Modifier.size(200.toDp())) {
+ FlowRow(
+ Modifier.wrapContentHeight().wrapContentWidth(),
+ horizontalArrangement = Arrangement.Start,
+ maxItemsInEachRow = 5
+ ) {
+ repeat(6) { index ->
+ Box(
+ Modifier
+ .size(20.toDp())
+ .onPlaced {
+ val positionInParent = it.positionInParent()
+ val xPosition = positionInParent.x
+ xPositions[index] = xPosition
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ rule.waitForIdle()
+ var expectedXPosition = maxMainAxisSize - eachSize
+ xPositions.forEachIndexed { index, position ->
+ Truth
+ .assertThat(position)
+ .isEqualTo(expectedXPosition)
+ if (index == (maxItemsInMainAxis - 1)) {
+ expectedXPosition = maxMainAxisSize - eachSize
+ } else {
+ expectedXPosition -= eachSize
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/FlowLayout.kt b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/FlowLayout.kt
new file mode 100644
index 0000000..15666d0
--- /dev/null
+++ b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/FlowLayout.kt
@@ -0,0 +1,422 @@
+package androidx.compose.foundation.layout
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collection.MutableVector
+import androidx.compose.runtime.collection.mutableVectorOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.layout.Measurable
+import androidx.compose.ui.layout.MeasurePolicy
+import androidx.compose.ui.layout.MeasureScope
+import androidx.compose.ui.layout.Placeable
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.constrainHeight
+import androidx.compose.ui.unit.constrainWidth
+import kotlin.math.ceil
+
+/**
+ * [FlowRow] is a layout that fills items from left to right (ltr) in LTR layouts
+ * or right to left (rtl) in RTL layouts and when it runs out of space, moves to
+ * the next "row" or "line" positioned on the bottom, and then continues filling items
+ * until the items run out.
+ *
+ * When a Modifier [RowColumnParentData.weight] is provided, it scales the item
+ * based on the number items that fall on the row it was placed in.
+ *
+ * Example:
+ * ```
+ * 1 2 3 4
+ * 5 6 7 8
+ * ```
+ * @param modifier The modifier to be applied to the Row.
+ * @param horizontalArrangement The horizontal arrangement of the layout's children.
+ * @param verticalAlignment The vertical alignment of the layout's children.
+ * @param maxItemsInEachRow The maximum number of items per row
+ * @param content The content as a [RowScope]
+ *
+ * @see FlowColumn
+ * @see [androidx.compose.foundation.layout.Row]
+ */
+@Composable
+@ExperimentalLayoutApi
+fun FlowRow(
+ modifier: Modifier = Modifier,
+ horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
+ verticalAlignment: Alignment.Vertical = Alignment.Top,
+ maxItemsInEachRow: Int = Int.MAX_VALUE,
+ content: @Composable RowScope.() -> Unit
+) {
+ val measurePolicy = rowMeasurementHelper(
+ horizontalArrangement,
+ verticalAlignment,
+ maxItemsInEachRow
+ )
+ Layout(
+ content = { RowScopeInstance.content() },
+ measurePolicy = measurePolicy,
+ modifier = modifier
+ )
+}
+
+/**
+ * [FlowColumn] is a layout that fills items from top to bottom, and when it runs out of space
+ * on the bottom, moves to the next "column" or "line"
+ * on the right or left based on ltr or rtl layouts,
+ * and then continues filling items from top to bottom.
+ *
+ * It supports ltr in LTR layouts, by placing the first column to the left, and then moving
+ * to the right
+ * It supports rtl in RTL layouts, by placing the first column to the right, and then moving
+ * to the left
+ *
+ * When a Modifier [RowColumnParentData.weight] is provided, it scales the item
+ * based on the number items that fall on the column it was placed in.
+ *
+ * Example:
+ * ```
+ * 1 4
+ * 2 5
+ * 3 6
+ * ```
+ * @param modifier The modifier to be applied to the Row.
+ * @param verticalArrangement The vertical arrangement of the layout's children.
+ * @param horizontalAlignment The horizontal alignment of the layout's children.
+ * @param maxItemsInEachColumn The maximum number of items per column
+ * @param content The content as a [ColumnScope]
+ *
+ * @see FlowRow
+ * @see [androidx.compose.foundation.layout.Column]
+ */
+@Composable
+@ExperimentalLayoutApi
+fun FlowColumn(
+ modifier: Modifier = Modifier,
+ verticalArrangement: Arrangement.Vertical = Arrangement.Top,
+ horizontalAlignment: Alignment.Horizontal = Alignment.Start,
+ maxItemsInEachColumn: Int = Int.MAX_VALUE,
+ content: @Composable ColumnScope.() -> Unit
+) {
+ val measurePolicy = columnMeasurementHelper(
+ verticalArrangement,
+ horizontalAlignment,
+ maxItemsInEachColumn
+ )
+ Layout(
+ content = { ColumnScopeInstance.content() },
+ measurePolicy = measurePolicy,
+ modifier = modifier
+ )
+}
+
+@Composable
+private fun mainAxisRowArrangement(horizontalArrangement: Arrangement.Horizontal):
+ (Int, IntArray, LayoutDirection, Density, IntArray) -> Unit =
+ remember(horizontalArrangement) {
+ { totalSize, size, layoutDirection, density, outPosition ->
+ with(horizontalArrangement) {
+ density.arrange(totalSize, size, layoutDirection, outPosition)
+ }
+ }
+ }
+
+@Composable
+private fun mainAxisColumnArrangement(verticalArrangement: Arrangement.Vertical):
+ (Int, IntArray, LayoutDirection, Density, IntArray) -> Unit =
+ remember(verticalArrangement) {
+ { totalSize, size, _, density, outPosition ->
+ with(verticalArrangement) {
+ density.arrange(totalSize, size, outPosition)
+ }
+ }
+ }
+
+private val crossAxisRowArrangement = { totalSize: Int, size: IntArray,
+ measureScope: MeasureScope,
+ outPosition: IntArray ->
+ with(Arrangement.Top) { measureScope.arrange(totalSize, size, outPosition) }
+}
+
+private val crossAxisColumnArrangement = { totalSize: Int,
+ size: IntArray, measureScope: MeasureScope, outPosition: IntArray ->
+ with(Arrangement.Start) {
+ measureScope.arrange(totalSize, size, measureScope.layoutDirection, outPosition)
+ }
+}
+
+@Composable
+private fun rowMeasurementHelper(
+ horizontalArrangement: Arrangement.Horizontal = Arrangement.End,
+ verticalAlignment: Alignment.Vertical = Alignment.Top,
+ maxItemsInMainAxis: Int,
+): MeasurePolicy {
+ val mainAxisArrangement = mainAxisRowArrangement(horizontalArrangement)
+ val crossAxisAlignment = remember(verticalAlignment) {
+ CrossAxisAlignment.vertical(verticalAlignment)
+ }
+ return remember(horizontalArrangement, verticalAlignment, maxItemsInMainAxis) {
+ flowMeasurePolicy(
+ orientation = LayoutOrientation.Horizontal,
+ mainAxisArrangement = mainAxisArrangement,
+ arrangementSpacing = horizontalArrangement.spacing,
+ crossAxisAlignment = crossAxisAlignment,
+ crossAxisSize = SizeMode.Wrap,
+ crossAxisArrangement = crossAxisRowArrangement,
+ maxItemsInMainAxis = maxItemsInMainAxis,
+ )
+ }
+}
+
+@Composable
+private fun columnMeasurementHelper(
+ verticalArrangement: Arrangement.Vertical = Arrangement.Top,
+ horizontalAlignment: Alignment.Horizontal = Alignment.Start,
+ maxItemsInMainAxis: Int,
+): MeasurePolicy {
+ val mainAxisArrangement = mainAxisColumnArrangement(verticalArrangement)
+ val crossAxisAlignment = remember(horizontalAlignment) {
+ CrossAxisAlignment.horizontal(horizontalAlignment)
+ }
+ return remember(verticalArrangement, horizontalAlignment, maxItemsInMainAxis) {
+ flowMeasurePolicy(
+ orientation = LayoutOrientation.Vertical,
+ mainAxisArrangement = mainAxisArrangement,
+ arrangementSpacing = verticalArrangement.spacing,
+ crossAxisAlignment = crossAxisAlignment,
+ crossAxisArrangement = crossAxisColumnArrangement,
+ maxItemsInMainAxis = maxItemsInMainAxis,
+ crossAxisSize = SizeMode.Wrap
+ )
+ }
+}
+
+/**
+ * Returns a Flow Measure Policy
+ */
+private fun flowMeasurePolicy(
+ orientation: LayoutOrientation,
+ mainAxisArrangement: (Int, IntArray, LayoutDirection, Density, IntArray) -> Unit,
+ arrangementSpacing: Dp,
+ crossAxisSize: SizeMode,
+ crossAxisAlignment: CrossAxisAlignment,
+ crossAxisArrangement: (Int, IntArray, MeasureScope, IntArray) -> Unit,
+ maxItemsInMainAxis: Int,
+) = MeasurePolicy { measurables, constraints ->
+ val placeables: Array<Placeable?> = arrayOfNulls(measurables.size)
+ val measureHelper = RowColumnMeasurementHelper(
+ orientation,
+ mainAxisArrangement,
+ arrangementSpacing,
+ crossAxisSize,
+ crossAxisAlignment,
+ measurables,
+ placeables,
+ )
+ val orientationIndependentConstraints =
+ OrientationIndependentConstraints(constraints, orientation)
+ val flowResult = breakDownItems(
+ measureHelper,
+ orientation,
+ orientationIndependentConstraints,
+ maxItemsInMainAxis,
+ )
+ val totalCrossAxisSize = flowResult.crossAxisTotalSize
+ val items = flowResult.items
+ val crossAxisSizes = IntArray(items.size) { index ->
+ items[index].crossAxisSize
+ }
+ val outPosition = IntArray(crossAxisSizes.size)
+ crossAxisArrangement(
+ totalCrossAxisSize,
+ crossAxisSizes, this@MeasurePolicy, outPosition
+ )
+
+ var layoutWidth: Int
+ var layoutHeight: Int
+ if (orientation == LayoutOrientation.Horizontal) {
+ layoutWidth = flowResult.mainAxisTotalSize
+ layoutHeight = flowResult.crossAxisTotalSize
+ } else {
+ layoutWidth = flowResult.crossAxisTotalSize
+ layoutHeight = flowResult.mainAxisTotalSize
+ }
+ layoutWidth = constraints.constrainWidth(layoutWidth)
+ layoutHeight = constraints.constrainHeight(layoutHeight)
+
+ layout(layoutWidth, layoutHeight) {
+ flowResult.items.forEachIndexed { currentRowOrColumnIndex,
+ measureResult ->
+ measureHelper.placeHelper(
+ this,
+ measureResult,
+ outPosition[currentRowOrColumnIndex],
+ this@MeasurePolicy.layoutDirection
+ )
+ }
+ }
+}
+
+/**
+ * Breaks down items based on space, size and maximum items in main axis.
+ * When items run out of space or the maximum items to fit in the main axis is reached,
+ * it moves to the next "line" and moves the next batch of items to a new list of items
+ */
+internal fun MeasureScope.breakDownItems(
+ measureHelper: RowColumnMeasurementHelper,
+ orientation: LayoutOrientation,
+ constraints: OrientationIndependentConstraints,
+ maxItemsInMainAxis: Int,
+): FlowResult {
+ val items = mutableVectorOf<RowColumnMeasureHelperResult>()
+ val mainAxisMax = constraints.mainAxisMax
+ val mainAxisMin = constraints.mainAxisMin
+ val crossAxisMax = constraints.crossAxisMax
+ val measurables = measureHelper.measurables
+ val placeables = measureHelper.placeables
+
+ val spacing = ceil(measureHelper.arrangementSpacing.toPx()).toInt()
+ val subsetConstraints = OrientationIndependentConstraints(
+ mainAxisMin,
+ mainAxisMax,
+ 0,
+ crossAxisMax
+ )
+
+ // nextSize of the list, pre-calculated
+ var nextSize: Int? = measurables.getOrNull(0)?.measureAndCache(
+ subsetConstraints, orientation
+ ) { placeable ->
+ placeables[0] = placeable
+ }
+
+ var startBreakLineIndex = 0
+ val endBreakLineList = arrayOfNulls<Int>(measurables.size)
+ var endBreakLineIndex = 0
+
+ var leftOver = mainAxisMax
+ // figure out the mainAxisTotalSize which will be minMainAxis when measuring the row/column
+ var mainAxisTotalSize = mainAxisMin
+ var currentLineMainAxisSize = 0
+ for (index in measurables.indices) {
+ val itemMainAxisSize = nextSize!!
+ currentLineMainAxisSize += itemMainAxisSize
+ leftOver -= itemMainAxisSize
+ nextSize = measurables.getOrNull(index + 1)?.measureAndCache(
+ subsetConstraints, orientation
+ ) { placeable ->
+ placeables[index + 1] = placeable
+ }?.plus(spacing)
+ if (index + 1 >= measurables.size ||
+ (index + 1) - startBreakLineIndex >= maxItemsInMainAxis ||
+ leftOver - (nextSize ?: 0) < 0
+ ) {
+ mainAxisTotalSize = maxOf(mainAxisTotalSize, currentLineMainAxisSize)
+ currentLineMainAxisSize = 0
+ leftOver = mainAxisMax
+ startBreakLineIndex = index + 1
+ endBreakLineList[endBreakLineIndex] = index + 1
+ endBreakLineIndex++
+ // only add spacing for next items in the row or column, not the starting indexes
+ nextSize = nextSize?.minus(spacing)
+ }
+ }
+
+ val subsetBoxConstraints = subsetConstraints.copy(
+ mainAxisMin = mainAxisTotalSize
+ ).toBoxConstraints(orientation)
+
+ startBreakLineIndex = 0
+ var crossAxisTotalSize = 0
+
+ endBreakLineIndex = 0
+ var endIndex = endBreakLineList.getOrNull(endBreakLineIndex)
+ while (endIndex != null) {
+ val result = measureHelper.measureWithoutPlacing(
+ this,
+ subsetBoxConstraints,
+ startBreakLineIndex,
+ endIndex
+ )
+ crossAxisTotalSize += result.crossAxisSize
+ mainAxisTotalSize = maxOf(mainAxisTotalSize, result.mainAxisSize)
+ items.add(
+ result
+ )
+ startBreakLineIndex = endIndex
+ endBreakLineIndex++
+ endIndex = endBreakLineList.getOrNull(endBreakLineIndex)
+ }
+
+ crossAxisTotalSize = maxOf(crossAxisTotalSize, constraints.crossAxisMin)
+ mainAxisTotalSize = maxOf(mainAxisTotalSize, constraints.mainAxisMin)
+ return FlowResult(
+ mainAxisTotalSize,
+ crossAxisTotalSize,
+ items,
+ )
+}
+
+internal fun Measurable.mainAxisMin(orientation: LayoutOrientation, crossAxisSize: Int) =
+ if (orientation == LayoutOrientation.Horizontal) {
+ minIntrinsicWidth(crossAxisSize)
+ } else {
+ minIntrinsicHeight(crossAxisSize)
+ }
+
+internal fun Measurable.crossAxisMin(orientation: LayoutOrientation, mainAxisSize: Int) =
+ if (orientation == LayoutOrientation.Horizontal) {
+ minIntrinsicHeight(mainAxisSize)
+ } else {
+ minIntrinsicWidth(mainAxisSize)
+ }
+
+internal fun Placeable.mainAxisSize(orientation: LayoutOrientation) =
+ if (orientation == LayoutOrientation.Horizontal) width else height
+
+internal fun Placeable.crossAxisSize(orientation: LayoutOrientation) =
+ if (orientation == LayoutOrientation.Horizontal) height else width
+
+internal val Measurable.weight
+ get() = (parentData as? RowColumnParentData)?.weight ?: 0f
+
+// We measure and cache to improve performance dramatically, instead of using intrinsics
+// This only works so far for fixed size items.
+// For weighted items, we continue to use their intrinsic widths.
+// This is because their fixed sizes are only determined after we determine
+// the number of items that can fit in the row/column it only lies on.
+private fun Measurable.measureAndCache(
+ constraints: OrientationIndependentConstraints,
+ orientation: LayoutOrientation,
+ storePlaceable: (Placeable?) -> Unit
+): Int {
+ val itemSize: Int = if (weight == 0f) {
+ // fixed sizes: measure once
+ val placeable = measure(
+ constraints.copy(
+ mainAxisMin = 0,
+ ).toBoxConstraints(orientation)
+ ).also(storePlaceable)
+ placeable.mainAxisSize(orientation)
+ } else {
+ mainAxisMin(orientation, Constraints.Infinity)
+ }
+ return itemSize
+}
+
+/**
+ * FlowResult when broken down to multiple rows or columns based on [breakDownItems] algorithm
+ *
+ * @param mainAxisTotalSize the total size of the main axis
+ * @param crossAxisTotalSize the total size of the cross axis when taken into account
+ * the cross axis sizes of all items
+ * @param items the row or column measurements for each row or column
+ */
+internal class FlowResult(
+ val mainAxisTotalSize: Int,
+ val crossAxisTotalSize: Int,
+ val items: MutableVector<RowColumnMeasureHelperResult>,
+)
\ No newline at end of file
diff --git a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/RowColumnImpl.kt b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/RowColumnImpl.kt
index 3bed06c..d14e5f6 100644
--- a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/RowColumnImpl.kt
+++ b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/RowColumnImpl.kt
@@ -41,7 +41,6 @@
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
-import kotlin.math.sign
internal fun rowColumnMeasurePolicy(
orientation: LayoutOrientation,
@@ -50,215 +49,44 @@
crossAxisSize: SizeMode,
crossAxisAlignment: CrossAxisAlignment
): MeasurePolicy {
- fun Placeable.mainAxisSize() =
- if (orientation == LayoutOrientation.Horizontal) width else height
-
- fun Placeable.crossAxisSize() =
- if (orientation == LayoutOrientation.Horizontal) height else width
-
return object : MeasurePolicy {
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
- @Suppress("NAME_SHADOWING")
- val constraints = OrientationIndependentConstraints(constraints, orientation)
- val arrangementSpacingPx = arrangementSpacing.roundToPx()
-
- var totalWeight = 0f
- var fixedSpace = 0
- var crossAxisSpace = 0
- var weightChildrenCount = 0
-
- var anyAlignBy = false
- val placeables = arrayOfNulls<Placeable>(measurables.size)
- val rowColumnParentData = Array(measurables.size) { measurables[it].data }
-
- // First measure children with zero weight.
- var spaceAfterLastNoWeight = 0
- for (i in measurables.indices) {
- val child = measurables[i]
- val parentData = rowColumnParentData[i]
- val weight = parentData.weight
-
- if (weight > 0f) {
- totalWeight += weight
- ++weightChildrenCount
- } else {
- val mainAxisMax = constraints.mainAxisMax
- val placeable = child.measure(
- // Ask for preferred main axis size.
- constraints.copy(
- mainAxisMin = 0,
- mainAxisMax = if (mainAxisMax == Constraints.Infinity) {
- Constraints.Infinity
- } else {
- mainAxisMax - fixedSpace
- },
- crossAxisMin = 0
- ).toBoxConstraints(orientation)
- )
- spaceAfterLastNoWeight = min(
- arrangementSpacingPx,
- mainAxisMax - fixedSpace - placeable.mainAxisSize()
- )
- fixedSpace += placeable.mainAxisSize() + spaceAfterLastNoWeight
- crossAxisSpace = max(crossAxisSpace, placeable.crossAxisSize())
- anyAlignBy = anyAlignBy || parentData.isRelative
- placeables[i] = placeable
- }
- }
-
- var weightedSpace = 0
- if (weightChildrenCount == 0) {
- // fixedSpace contains an extra spacing after the last non-weight child.
- fixedSpace -= spaceAfterLastNoWeight
- } else {
- // Measure the rest according to their weights in the remaining main axis space.
- val targetSpace =
- if (totalWeight > 0f && constraints.mainAxisMax != Constraints.Infinity) {
- constraints.mainAxisMax
- } else {
- constraints.mainAxisMin
- }
- val remainingToTarget =
- targetSpace - fixedSpace - arrangementSpacingPx * (weightChildrenCount - 1)
-
- val weightUnitSpace = if (totalWeight > 0) remainingToTarget / totalWeight else 0f
- var remainder = remainingToTarget - rowColumnParentData.sumOf {
- (weightUnitSpace * it.weight).roundToInt()
- }
-
- for (i in measurables.indices) {
- if (placeables[i] == null) {
- val child = measurables[i]
- val parentData = rowColumnParentData[i]
- val weight = parentData.weight
- check(weight > 0) { "All weights <= 0 should have placeables" }
- // After the weightUnitSpace rounding, the total space going to be occupied
- // can be smaller or larger than remainingToTarget. Here we distribute the
- // loss or gain remainder evenly to the first children.
- val remainderUnit = remainder.sign
- remainder -= remainderUnit
- val childMainAxisSize = max(
- 0,
- (weightUnitSpace * weight).roundToInt() + remainderUnit
- )
- val placeable = child.measure(
- OrientationIndependentConstraints(
- if (parentData.fill && childMainAxisSize != Constraints.Infinity) {
- childMainAxisSize
- } else {
- 0
- },
- childMainAxisSize,
- 0,
- constraints.crossAxisMax
- ).toBoxConstraints(orientation)
- )
- weightedSpace += placeable.mainAxisSize()
- crossAxisSpace = max(crossAxisSpace, placeable.crossAxisSize())
- anyAlignBy = anyAlignBy || parentData.isRelative
- placeables[i] = placeable
- }
- }
- weightedSpace = (weightedSpace + arrangementSpacingPx * (weightChildrenCount - 1))
- .coerceAtMost(constraints.mainAxisMax - fixedSpace)
- }
-
- var beforeCrossAxisAlignmentLine = 0
- var afterCrossAxisAlignmentLine = 0
- if (anyAlignBy) {
- for (i in placeables.indices) {
- val placeable = placeables[i]!!
- val parentData = rowColumnParentData[i]
- val alignmentLinePosition = parentData.crossAxisAlignment
- ?.calculateAlignmentLinePosition(placeable)
- if (alignmentLinePosition != null) {
- beforeCrossAxisAlignmentLine = max(
- beforeCrossAxisAlignmentLine,
- alignmentLinePosition.let {
- if (it != AlignmentLine.Unspecified) it else 0
- }
- )
- afterCrossAxisAlignmentLine = max(
- afterCrossAxisAlignmentLine,
- placeable.crossAxisSize() -
- (
- alignmentLinePosition.let {
- if (it != AlignmentLine.Unspecified) {
- it
- } else {
- placeable.crossAxisSize()
- }
- }
- )
- )
- }
- }
- }
-
- // Compute the Row or Column size and position the children.
- val mainAxisLayoutSize = max(fixedSpace + weightedSpace, constraints.mainAxisMin)
- val crossAxisLayoutSize = if (constraints.crossAxisMax != Constraints.Infinity &&
- crossAxisSize == SizeMode.Expand
- ) {
- constraints.crossAxisMax
- } else {
- max(
- crossAxisSpace,
- max(
- constraints.crossAxisMin,
- beforeCrossAxisAlignmentLine + afterCrossAxisAlignmentLine
- )
+ val placeables = arrayOfNulls<Placeable?>(measurables.size)
+ val rowColumnMeasureHelper =
+ RowColumnMeasurementHelper(
+ orientation,
+ arrangement,
+ arrangementSpacing,
+ crossAxisSize,
+ crossAxisAlignment,
+ measurables,
+ placeables
)
- }
- val layoutWidth = if (orientation == Horizontal) {
- mainAxisLayoutSize
- } else {
- crossAxisLayoutSize
- }
- val layoutHeight = if (orientation == Horizontal) {
- crossAxisLayoutSize
- } else {
- mainAxisLayoutSize
- }
- val mainAxisPositions = IntArray(measurables.size) { 0 }
+ val measureResult = rowColumnMeasureHelper
+ .measureWithoutPlacing(this,
+ constraints, 0, measurables.size
+ )
+
+ val layoutWidth: Int
+ val layoutHeight: Int
+ if (orientation == LayoutOrientation.Horizontal) {
+ layoutWidth = measureResult.mainAxisSize
+ layoutHeight = measureResult.crossAxisSize
+ } else {
+ layoutWidth = measureResult.crossAxisSize
+ layoutHeight = measureResult.mainAxisSize
+ }
return layout(layoutWidth, layoutHeight) {
- val childrenMainAxisSize = IntArray(measurables.size) { index ->
- placeables[index]!!.mainAxisSize()
- }
- arrangement(
- mainAxisLayoutSize,
- childrenMainAxisSize,
- layoutDirection,
- this@measure,
- mainAxisPositions
+ rowColumnMeasureHelper.placeHelper(
+ this,
+ measureResult,
+ 0,
+ layoutDirection
)
-
- placeables.forEachIndexed { index, placeable ->
- placeable!!
- val parentData = rowColumnParentData[index]
- val childCrossAlignment = parentData.crossAxisAlignment ?: crossAxisAlignment
-
- val crossAxis = childCrossAlignment.align(
- size = crossAxisLayoutSize - placeable.crossAxisSize(),
- layoutDirection = if (orientation == Horizontal) {
- LayoutDirection.Ltr
- } else {
- layoutDirection
- },
- placeable = placeable,
- beforeCrossAxisAlignmentLine = beforeCrossAxisAlignmentLine
- )
-
- if (orientation == Horizontal) {
- placeable.place(mainAxisPositions[index], crossAxis)
- } else {
- placeable.place(crossAxis, mainAxisPositions[index])
- }
- }
}
}
@@ -349,12 +177,14 @@
*/
@Stable
val Center: CrossAxisAlignment = CenterCrossAxisAlignment
+
/**
* Place children such that their start edge is aligned to the start edge of the cross
* axis. TODO(popam): Consider rtl directionality.
*/
@Stable
val Start: CrossAxisAlignment = StartCrossAxisAlignment
+
/**
* Place children such that their end edge is aligned to the end edge of the cross
* axis. TODO(popam): Consider rtl directionality.
@@ -528,19 +358,19 @@
}
}
-private val IntrinsicMeasurable.data: RowColumnParentData?
+internal val IntrinsicMeasurable.rowColumnParentData: RowColumnParentData?
get() = parentData as? RowColumnParentData
-private val RowColumnParentData?.weight: Float
+internal val RowColumnParentData?.weight: Float
get() = this?.weight ?: 0f
-private val RowColumnParentData?.fill: Boolean
+internal val RowColumnParentData?.fill: Boolean
get() = this?.fill ?: true
-private val RowColumnParentData?.crossAxisAlignment: CrossAxisAlignment?
+internal val RowColumnParentData?.crossAxisAlignment: CrossAxisAlignment?
get() = this?.crossAxisAlignment
-private val RowColumnParentData?.isRelative: Boolean
+internal val RowColumnParentData?.isRelative: Boolean
get() = this.crossAxisAlignment?.isRelative ?: false
private fun MinIntrinsicWidthMeasureBlock(orientation: LayoutOrientation) =
@@ -700,7 +530,7 @@
var fixedSpace = 0
var totalWeight = 0f
children.fastForEach { child ->
- val weight = child.data.weight
+ val weight = child.rowColumnParentData.weight
val size = child.mainAxisSize(crossAxisAvailable)
if (weight == 0f) {
fixedSpace += size
@@ -724,7 +554,7 @@
var crossAxisMax = 0
var totalWeight = 0f
children.fastForEach { child ->
- val weight = child.data.weight
+ val weight = child.rowColumnParentData.weight
if (weight == 0f) {
// Ask the child how much main axis space it wants to occupy. This cannot be more
// than the remaining available space.
@@ -750,7 +580,7 @@
}
children.fastForEach { child ->
- val weight = child.data.weight
+ val weight = child.rowColumnParentData.weight
// Now the main axis for weighted children is known, so ask about the cross axis space.
if (weight > 0f) {
crossAxisMax = max(
@@ -877,6 +707,7 @@
it.crossAxisAlignment = CrossAxisAlignment.vertical(vertical)
}
}
+
override fun equals(other: Any?): Boolean {
if (this === other) return true
val otherModifier = other as? VerticalAlignModifier ?: return false
@@ -928,6 +759,7 @@
* subject to the incoming layout constraints.
*/
Wrap,
+
/**
* Maximize the amount of free space by expanding to fill the available space,
* subject to the incoming layout constraints.
diff --git a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/RowColumnMeasurementHelper.kt b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/RowColumnMeasurementHelper.kt
new file mode 100644
index 0000000..a828f9f
--- /dev/null
+++ b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/RowColumnMeasurementHelper.kt
@@ -0,0 +1,325 @@
+/*
+ * 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.
+ */
+
+package androidx.compose.foundation.layout
+
+import androidx.compose.ui.layout.AlignmentLine
+import androidx.compose.ui.layout.Measurable
+import androidx.compose.ui.layout.MeasureScope
+import androidx.compose.ui.layout.Placeable
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.math.roundToInt
+import kotlin.math.sign
+
+/**
+ * This is a data class that holds the determined width, height of a row,
+ * and information on how to retrieve main axis and cross axis positions.
+ */
+internal class RowColumnMeasureHelperResult(
+ val crossAxisSize: Int,
+ val mainAxisSize: Int,
+ val startIndex: Int,
+ val endIndex: Int,
+ val beforeCrossAxisAlignmentLine: Int,
+ val mainAxisPositions: IntArray,
+)
+
+/**
+ * RowColumnMeasurementHelper
+ * Measures the row and column without placing, useful for reusing row/column logic
+ */
+internal class RowColumnMeasurementHelper(
+ val orientation: LayoutOrientation,
+ val arrangement: (Int, IntArray, LayoutDirection, Density, IntArray) -> Unit,
+ val arrangementSpacing: Dp,
+ val crossAxisSize: SizeMode,
+ val crossAxisAlignment: CrossAxisAlignment,
+ val measurables: List<Measurable>,
+ val placeables: Array<Placeable?>
+) {
+
+ private val rowColumnParentData = Array(measurables.size) {
+ measurables[it].rowColumnParentData
+ }
+
+ fun Placeable.mainAxisSize() =
+ if (orientation == LayoutOrientation.Horizontal) width else height
+
+ fun Placeable.crossAxisSize() =
+ if (orientation == LayoutOrientation.Horizontal) height else width
+
+ /**
+ * Measures the row and column without placing, useful for reusing row/column logic
+ *
+ * @param measureScope The measure scope to retrieve density
+ * @param constraints The desired constraints for the startIndex and endIndex
+ * can hold null items if not measured.
+ * @param startIndex The startIndex (inclusive) when examining measurables, placeable
+ * and parentData
+ * @param endIndex The ending index (exclusive) when examinning measurable, placeable
+ * and parentData
+ */
+ fun measureWithoutPlacing(
+ measureScope: MeasureScope,
+ constraints: Constraints,
+ startIndex: Int,
+ endIndex: Int
+ ): RowColumnMeasureHelperResult {
+ @Suppress("NAME_SHADOWING")
+ val constraints = OrientationIndependentConstraints(constraints, orientation)
+ val arrangementSpacingPx = with(measureScope) {
+ arrangementSpacing.roundToPx()
+ }
+
+ var totalWeight = 0f
+ var fixedSpace = 0
+ var crossAxisSpace = 0
+ var weightChildrenCount = 0
+
+ var anyAlignBy = false
+ val subSize = endIndex - startIndex
+
+ // First measure children with zero weight.
+ var spaceAfterLastNoWeight = 0
+ for (i in startIndex until endIndex) {
+ val child = measurables[i]
+ val parentData = rowColumnParentData[i]
+ val weight = parentData.weight
+
+ if (weight > 0f) {
+ totalWeight += weight
+ ++weightChildrenCount
+ } else {
+ val mainAxisMax = constraints.mainAxisMax
+ val placeable = placeables[i] ?: child.measure(
+ // Ask for preferred main axis size.
+ constraints.copy(
+ mainAxisMin = 0,
+ mainAxisMax = if (mainAxisMax == Constraints.Infinity) {
+ Constraints.Infinity
+ } else {
+ mainAxisMax - fixedSpace
+ },
+ crossAxisMin = 0
+ ).toBoxConstraints(orientation)
+ )
+ spaceAfterLastNoWeight = min(
+ arrangementSpacingPx,
+ mainAxisMax - fixedSpace - placeable.mainAxisSize()
+ )
+ fixedSpace += placeable.mainAxisSize() + spaceAfterLastNoWeight
+ crossAxisSpace = max(crossAxisSpace, placeable.crossAxisSize())
+ anyAlignBy = anyAlignBy || parentData.isRelative
+ placeables[i] = placeable
+ }
+ }
+
+ var weightedSpace = 0
+ if (weightChildrenCount == 0) {
+ // fixedSpace contains an extra spacing after the last non-weight child.
+ fixedSpace -= spaceAfterLastNoWeight
+ } else {
+ // Measure the rest according to their weights in the remaining main axis space.
+ val targetSpace =
+ if (totalWeight > 0f && constraints.mainAxisMax != Constraints.Infinity) {
+ constraints.mainAxisMax
+ } else {
+ constraints.mainAxisMin
+ }
+ val remainingToTarget =
+ targetSpace - fixedSpace - arrangementSpacingPx * (weightChildrenCount - 1)
+
+ val weightUnitSpace = if (totalWeight > 0) remainingToTarget / totalWeight else 0f
+ var remainder = remainingToTarget - (startIndex until endIndex).sumOf {
+ (weightUnitSpace * rowColumnParentData[it].weight).roundToInt()
+ }
+
+ for (i in startIndex until endIndex) {
+ if (placeables[i] == null) {
+ val child = measurables[i]
+ val parentData = rowColumnParentData[i]
+ val weight = parentData.weight
+ check(weight > 0) { "All weights <= 0 should have placeables" }
+ // After the weightUnitSpace rounding, the total space going to be occupied
+ // can be smaller or larger than remainingToTarget. Here we distribute the
+ // loss or gain remainder evenly to the first children.
+ val remainderUnit = remainder.sign
+ remainder -= remainderUnit
+ val childMainAxisSize = max(
+ 0,
+ (weightUnitSpace * weight).roundToInt() + remainderUnit
+ )
+ val placeable = child.measure(
+ OrientationIndependentConstraints(
+ if (parentData.fill && childMainAxisSize != Constraints.Infinity) {
+ childMainAxisSize
+ } else {
+ 0
+ },
+ childMainAxisSize,
+ 0,
+ constraints.crossAxisMax
+ ).toBoxConstraints(orientation)
+ )
+ weightedSpace += placeable.mainAxisSize()
+ crossAxisSpace = max(crossAxisSpace, placeable.crossAxisSize())
+ anyAlignBy = anyAlignBy || parentData.isRelative
+ placeables[i] = placeable
+ }
+ }
+ weightedSpace = (weightedSpace + arrangementSpacingPx * (weightChildrenCount - 1))
+ .coerceAtMost(constraints.mainAxisMax - fixedSpace)
+ }
+
+ var beforeCrossAxisAlignmentLine = 0
+ var afterCrossAxisAlignmentLine = 0
+ if (anyAlignBy) {
+ for (i in startIndex until endIndex) {
+ val placeable = placeables[i]!!
+ val parentData = rowColumnParentData[i]
+ val alignmentLinePosition = parentData.crossAxisAlignment
+ ?.calculateAlignmentLinePosition(placeable)
+ if (alignmentLinePosition != null) {
+ beforeCrossAxisAlignmentLine = max(
+ beforeCrossAxisAlignmentLine,
+ alignmentLinePosition.let {
+ if (it != AlignmentLine.Unspecified) it else 0
+ }
+ )
+ afterCrossAxisAlignmentLine = max(
+ afterCrossAxisAlignmentLine,
+ placeable.crossAxisSize() -
+ (
+ alignmentLinePosition.let {
+ if (it != AlignmentLine.Unspecified) {
+ it
+ } else {
+ placeable.crossAxisSize()
+ }
+ }
+ )
+ )
+ }
+ }
+ }
+
+ // Compute the Row or Column size and position the children.
+ val mainAxisLayoutSize = max(fixedSpace + weightedSpace, constraints.mainAxisMin)
+ val crossAxisLayoutSize = if (constraints.crossAxisMax != Constraints.Infinity &&
+ crossAxisSize == SizeMode.Expand
+ ) {
+ constraints.crossAxisMax
+ } else {
+ max(
+ crossAxisSpace,
+ max(
+ constraints.crossAxisMin,
+ beforeCrossAxisAlignmentLine + afterCrossAxisAlignmentLine
+ )
+ )
+ }
+ val mainAxisPositions = IntArray(subSize) { 0 }
+ val childrenMainAxisSize = IntArray(subSize) { index ->
+ placeables[index + startIndex]!!.mainAxisSize()
+ }
+
+ return RowColumnMeasureHelperResult(
+ mainAxisSize = mainAxisLayoutSize,
+ crossAxisSize = crossAxisLayoutSize,
+ startIndex = startIndex,
+ endIndex = endIndex,
+ beforeCrossAxisAlignmentLine = beforeCrossAxisAlignmentLine,
+ mainAxisPositions = mainAxisPositions(
+ mainAxisLayoutSize,
+ childrenMainAxisSize,
+ mainAxisPositions,
+ measureScope
+ ))
+ }
+
+ private fun mainAxisPositions(
+ mainAxisLayoutSize: Int,
+ childrenMainAxisSize: IntArray,
+ mainAxisPositions: IntArray,
+ measureScope: MeasureScope
+ ): IntArray {
+ arrangement(
+ mainAxisLayoutSize,
+ childrenMainAxisSize,
+ measureScope.layoutDirection,
+ measureScope,
+ mainAxisPositions
+ )
+ return mainAxisPositions
+ }
+
+ private fun getCrossAxisPosition(
+ placeable: Placeable,
+ parentData: RowColumnParentData?,
+ crossAxisLayoutSize: Int,
+ layoutDirection: LayoutDirection,
+ beforeCrossAxisAlignmentLine: Int
+ ): Int {
+ val childCrossAlignment = parentData?.crossAxisAlignment ?: crossAxisAlignment
+ return childCrossAlignment.align(
+ size = crossAxisLayoutSize - placeable.crossAxisSize(),
+ layoutDirection = if (orientation == LayoutOrientation.Horizontal) {
+ LayoutDirection.Ltr
+ } else {
+ layoutDirection
+ },
+ placeable = placeable,
+ beforeCrossAxisAlignmentLine = beforeCrossAxisAlignmentLine
+ )
+ }
+ fun placeHelper(
+ placeableScope: Placeable.PlacementScope,
+ measureResult: RowColumnMeasureHelperResult,
+ crossAxisOffset: Int,
+ layoutDirection: LayoutDirection,
+ ) {
+ with(placeableScope) {
+ for (i in measureResult.startIndex until measureResult.endIndex) {
+ val placeable = placeables[i]
+ placeable!!
+ val mainAxisPositions = measureResult.mainAxisPositions
+ val crossAxisPosition = getCrossAxisPosition(
+ placeable,
+ (measurables[i].parentData as? RowColumnParentData),
+ measureResult.crossAxisSize,
+ layoutDirection,
+ measureResult.beforeCrossAxisAlignmentLine
+ ) + crossAxisOffset
+ if (orientation == LayoutOrientation.Horizontal) {
+ placeable.place(
+ mainAxisPositions[i - measureResult.startIndex],
+ crossAxisPosition
+ )
+ } else {
+ placeable.place(
+ crossAxisPosition,
+ mainAxisPositions[i - measureResult.startIndex]
+ )
+ }
+ }
+ }
+ }
+}
\ No newline at end of file