blob: cd084495636465537cdf63e1e7396be6ca855325 [file]
/*
* Copyright 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalGridApi::class)
package androidx.compose.foundation.layout
import androidx.annotation.FloatRange
import androidx.annotation.IntRange as AndroidXIntRange
import androidx.collection.LongList
import androidx.collection.MutableIntList
import androidx.collection.MutableIntSet
import androidx.collection.MutableObjectList
import androidx.collection.MutableObjectLongMap
import androidx.collection.ObjectLongMap
import androidx.collection.mutableLongListOf
import androidx.collection.mutableObjectLongMapOf
import androidx.compose.foundation.layout.GridScope.Companion.GridIndexUnspecified
import androidx.compose.foundation.layout.GridScope.Companion.MaxGridIndex
import androidx.compose.foundation.layout.internal.JvmDefaultWithCompatibility
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
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.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.ParentDataModifierNode
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.unit.constrainWidth
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceAtLeast
import androidx.compose.ui.util.fastCoerceAtMost
import androidx.compose.ui.util.fastForEach
import kotlin.jvm.JvmInline
import kotlin.math.max
import kotlin.math.roundToInt
/**
* A 2D layout composable that arranges children into a grid of rows and columns.
*
* The [Grid] allows defining explicit tracks (columns and rows) with various sizing capabilities,
* including fixed sizes (`dp`), flexible fractions (`fr`), percentages, and content-based sizing
* (`Auto`).
*
* **Key Features:**
* * **Explicit vs. Implicit:** You define the main structure via [config] (explicit tracks). If
* items are placed outside these defined bounds, or if auto-placement creates new rows/columns,
* the grid automatically extends using implicit sizing (defaults to `Auto`).
* * **Flexible Sizing:** Use [Fr] units (e.g., `1.fr`, `2.fr`) to distribute available space
* proportionally among tracks.
* * **Auto-placement:** Items without a specific [GridScope.gridItem] modifier flow automatically
* into the next available cell based on the configured [GridFlow]. .
*
* Example usage:
*
* @sample androidx.compose.foundation.layout.samples.SimpleGrid
* @sample androidx.compose.foundation.layout.samples.GridWithSpanningItems
* @sample androidx.compose.foundation.layout.samples.GridWithAutoPlacement
* @param config A block that defines the columns, rows, and gaps of the grid. This block runs
* during the measure pass, enabling efficient updates based on state.
* @param modifier The modifier to be applied to the layout.
* @param content The content of the grid. Direct children can use [GridScope.gridItem] to configure
* their position and span.
* @see GridScope.gridItem
* @see GridConfigurationScope
*/
@Composable
@ExperimentalGridApi
inline fun Grid(
noinline config: GridConfigurationScope.() -> Unit,
modifier: Modifier = Modifier,
content: @Composable GridScope.() -> Unit,
) {
// Capture the latest config lambda in a State object.
// This ensures we always have access to the latest lambda without recreating the policy.
val currentConfig = rememberUpdatedState(config)
// Create a stable MeasurePolicy instance.
// We use 'remember' without keys so the policy instance itself never changes.
// The policy reads 'currentConfig.value' inside measure(), triggering invalidation
// when the config changes.
val measurePolicy = remember { GridMeasurePolicy(currentConfig) }
Layout(
content = { GridScopeInstance.content() },
modifier = modifier,
measurePolicy = measurePolicy,
)
}
/** Scope for the children of [Grid]. */
@LayoutScopeMarker
@Immutable
@JvmDefaultWithCompatibility
@ExperimentalGridApi
interface GridScope {
/**
* Configures the position, span, and alignment of an element within a [Grid] layout.
*
* Apply this modifier to direct children of a [Grid] composable.
*
* **Default Behavior:** If this modifier is not applied to a child, the child will be
* automatically placed in the next available cell (spanning 1 row and 1 column) according to
* the configured [GridFlow].
*
* **Indexing:** Grid row and column indices are **1-based**.
* * **Positive** values count from the start (1 is the first row/column).
* * **Negative** values count from the end (-1 is the last explicitly defined row/column).
*
* **Auto-placement:** If [row] or [column] are left to their default value
* ([GridIndexUnspecified]), the [Grid] layout will automatically place the item based on the
* configured [GridFlow].
*
* @param row The specific 1-based row index to place the item in. Positive values count from
* the start (1 is the first row). Negative values count from the end (-1 is the last row).
* Must be within the range [-[MaxGridIndex], [MaxGridIndex]]. Defaults to
* [GridIndexUnspecified] for auto-placement.
* @param column The specific 1-based column index to place the item in. Positive values count
* from the start (1 is the first column). Negative values count from the end (-1 is the last
* column). Must be within the range [-[MaxGridIndex], [MaxGridIndex]]. Defaults to
* [GridIndexUnspecified] for auto-placement.
* @param rowSpan The number of rows this item should occupy. Must be greater than 0. Defaults
* to 1.
* @param columnSpan The number of columns this item should occupy. Must be greater than 0.
* Defaults to 1.
* @param alignment Specifies how the content should be aligned within the grid cell(s) it
* occupies. Defaults to [Alignment.TopStart].
* @throws IllegalArgumentException if [row] or [column] (when specified) are outside the valid
* range, or if [rowSpan] or [columnSpan] are less than 1.
* @see GridIndexUnspecified
* @see MaxGridIndex
*/
@Stable
fun Modifier.gridItem(
@AndroidXIntRange(from = -MaxGridIndex.toLong(), to = MaxGridIndex.toLong())
row: Int = GridIndexUnspecified,
@AndroidXIntRange(from = -MaxGridIndex.toLong(), to = MaxGridIndex.toLong())
column: Int = GridIndexUnspecified,
@AndroidXIntRange(from = 1) rowSpan: Int = 1,
@AndroidXIntRange(from = 1) columnSpan: Int = 1,
alignment: Alignment = Alignment.TopStart,
): Modifier
/**
* Configures the position, span, and alignment of an element within a [Grid] layout using
* ranges.
*
* This convenience overload converts [IntRange] inputs into row/column indices and spans.
*
* **Equivalence:**
* - `rows = 4..5` maps to `row = 4`, `rowSpan = 2`.
* - `columns = 1..1` maps to `column = 1`, `columnSpan = 1`.
*
* Example: `Modifier.gridItem(rows = 2..3, columns = 1..2)` is functionally equivalent to
* `Modifier.gridItem(row = 2, rowSpan = 2, column = 1, columnSpan = 2)`.
*
* @param rows The range of rows to occupy (e.g., `1..2`). The start determines the row index,
* and the size of the range determines the span.
* @param columns The range of columns to occupy (e.g., `1..3`). The start determines the column
* index, and the size of the range determines the span.
* @param alignment Specifies how the content should be aligned within the grid cell(s).
* Defaults to [Alignment.TopStart].
* @see Modifier.gridItem
*/
@Stable
fun Modifier.gridItem(
rows: IntRange,
columns: IntRange,
alignment: Alignment = Alignment.TopStart,
): Modifier
/**
* Configures the position and alignment of an element within a [Grid] layout by referencing a
* named area.
*
* Apply this modifier to direct children of a [Grid] composable. The [areaId] must correspond
* to an identifier defined using [GridConfigurationScope.area] within the `config` block of the
* [Grid].
*
* **Multiple Items & Overlapping:**
* - **2D Areas:** If multiple items are assigned to the same fully specified 2D area (both row
* and column are fixed), they will stack on top of each other within those bounds. Z-ordering
* is determined by composition order (items declared later draw on top, mirroring `Box`).
* - **1D Areas & Flow:** If the referenced area is one-dimensional (e.g., it defines a row but
* leaves the column unspecified), placing multiple items into it triggers auto-flow. The
* items will automatically flow into the next available cells within that specific track.
*
* **Fallback Behavior for Unknown Areas:** If the provided [areaId] identifier is not
* registered in the Grid configuration, this item will silently fall back to automatic
* placement to prevent runtime crashes.
*
* @sample androidx.compose.foundation.layout.samples.GridWithNamedAreas
* @sample androidx.compose.foundation.layout.samples.GridWithOneDimensionalAreas
* @param areaId The user-defined identifier corresponding to the area defined in the Grid
* configuration. This identifier **must** have a stable `equals()` and `hashCode()`
* implementation (e.g., an `enum`, `String`, `data class`, or singleton `object`) to
* correctly match the area registered in the configuration.
* @param alignment Specifies how the content should be aligned within the grid cell(s).
* Defaults to [Alignment.TopStart].
*/
@Stable
@ExperimentalGridApi
fun Modifier.gridItem(areaId: Any, alignment: Alignment = Alignment.TopStart): Modifier
companion object {
/**
* The maximum allowed index for a row or column (inclusive).
*
* This hard limit prevents performance degradation, layout timeouts, or memory issues
* potentially caused by accidental loop overflows or unreasonably large sparse grid
* definitions.
*
* **Note:** This value MUST NOT exceed `Short.MAX_VALUE` (32767). Named Area bounds are
* bit-packed into 16-bit segments, and larger values will silently truncate.
*/
@ExperimentalGridApi const val MaxGridIndex: Int = 1000
/**
* Sentinel value indicating that a grid position (row or column) is not manually specified
* and should be determined automatically by the layout flow.
*/
@ExperimentalGridApi const val GridIndexUnspecified: Int = 0
}
}
/** Internal implementation of [GridScope]. Stateless object to avoid allocations. */
@PublishedApi
@ExperimentalGridApi
internal object GridScopeInstance : GridScope {
override fun Modifier.gridItem(
row: Int,
column: Int,
rowSpan: Int,
columnSpan: Int,
alignment: Alignment,
): Modifier {
if (row != GridIndexUnspecified) {
require(row in -MaxGridIndex..MaxGridIndex) {
"row must be between -$MaxGridIndex and $MaxGridIndex"
}
}
if (column != GridIndexUnspecified) {
require(column in -MaxGridIndex..MaxGridIndex) {
"column must be between -$MaxGridIndex and $MaxGridIndex"
}
}
require(rowSpan > 0) { "rowSpan must be > 0" }
require(columnSpan > 0) { "columnSpan must be > 0" }
return this.then(GridItemElement(null, row, column, rowSpan, columnSpan, alignment))
}
override fun Modifier.gridItem(
rows: IntRange,
columns: IntRange,
alignment: Alignment,
): Modifier {
require(!rows.isEmpty()) { "Row range ($rows) cannot be empty" }
require(!columns.isEmpty()) { "Column range ($columns) cannot be empty" }
val row = rows.first
val rowSpan = rows.last - rows.first + 1
val column = columns.first
val columnSpan = columns.last - columns.first + 1
return this.gridItem(row, column, rowSpan, columnSpan, alignment)
}
override fun Modifier.gridItem(areaId: Any, alignment: Alignment): Modifier {
return this.then(
GridItemElement(
areaId,
row = GridIndexUnspecified,
column = GridIndexUnspecified,
rowSpan = 1,
columnSpan = 1,
alignment,
)
)
}
}
/**
* Scope for configuring the structure of a [Grid].
*
* This interface is implemented by the configuration block in [Grid]. It allows defining columns,
* rows, and gaps.
*
* The order in which [column] and [row] functions are called within the `config` block is
* important. Tracks are added to the grid definition sequentially based on these calls. For
* example, calling `column(100.dp)` twice defines two columns.
*
* Gap configuration calls ([gap], [rowGap], [columnGap]) follow a "last-call-wins" policy for their
* respective axes.
*
* @sample androidx.compose.foundation.layout.samples.GridConfigurationDslSample
* @sample androidx.compose.foundation.layout.samples.GridWithConstraints
*/
@LayoutScopeMarker
@ExperimentalGridApi
interface GridConfigurationScope : Density {
/**
* The layout constraints passed to this [Grid] from its parent.
*
* These constraints represent the minimum and maximum size limits that the parent has imposed
* on this Grid. This can be useful for creating responsive layouts that adapt based on
* available space.
*
* @see Constraints
*/
val constraints: Constraints
/**
* The direction in which items that do not specify a position are placed. Defaults to
* [GridFlow.Row].
*/
var flow: GridFlow
/** Defines a fixed-width column. Maps to [GridTrackSize.Fixed]. */
fun column(size: Dp)
/** Defines a flexible column. Maps to [GridTrackSize.Flex]. */
fun column(weight: Fr)
/**
* Defines a percentage-based column. Maps to [GridTrackSize.Percentage].
*
* @param percentage The percentage (0.0 to 1.0) of the available space.
*/
fun column(@FloatRange(from = 0.0, to = 1.0) percentage: Float)
/** Defines a new column track with the specified [size]. */
fun column(size: GridTrackSize)
/** Defines a fixed-width row. Maps to [GridTrackSize.Fixed]. */
fun row(size: Dp)
/** Defines a flexible row. Maps to [GridTrackSize.Flex]. */
fun row(weight: Fr)
/**
* Defines a percentage-based row. Maps to [GridTrackSize.Percentage].
*
* @param percentage The percentage (0.0 to 1.0) of the available space.
*/
fun row(@FloatRange(from = 0.0, to = 1.0) percentage: Float)
/** Defines a new row track with the specified [size]. */
fun row(size: GridTrackSize)
/**
* Defines a named area or a 1-dimensional track within the grid by mapping an identifier to
* physical starting coordinates and spans.
*
* Once defined, this identifier can be referenced by child composables using
* `Modifier.gridItem(areaId)` to place them into this specific area. This decouples a
* component's semantic intent from its exact physical layout coordinates.
*
* **1D Areas & Flow:** To create a 1-dimensional track, explicitly pass [GridIndexUnspecified]
* to the dimension you want to auto-flow. For example, `area("Header", row = 1, column =
* GridIndexUnspecified)` restricts the area to the first row, allowing multiple items placed
* into it to automatically flow side-by-side into available columns.
*
* @sample androidx.compose.foundation.layout.samples.GridWithNamedAreas
* @sample androidx.compose.foundation.layout.samples.GridWithOneDimensionalAreas
* @param areaId A user-defined identifier (e.g., an Enum, String, or object marker) that
* represents this area. This identifier **must** have a stable `equals()` and `hashCode()`.
* @param row The 1-based starting row index of the area. Defaults to [GridIndexUnspecified] to
* create a 1D column-based area where items flow vertically.
* @param column The 1-based starting column index of the area. Defaults to
* [GridIndexUnspecified] to create a 1D row-based area where items flow horizontally.
* @param rowSpan The number of rows this area should occupy. Must be greater than 0. Defaults
* to 1.
* @param columnSpan The number of columns this area should occupy. Must be greater than 0.
* Defaults to 1.
* @throws IllegalArgumentException if both [row] and [column] are [GridIndexUnspecified].
*/
fun area(
areaId: Any,
row: Int = GridIndexUnspecified,
column: Int = GridIndexUnspecified,
rowSpan: Int = 1,
columnSpan: Int = 1,
)
/**
* Defines a named area within the grid using explicit coordinate ranges.
*
* This is a convenience overload that computes the starting coordinate and span based on the
* provided [IntRange] boundaries.
*
* Example: `area(AppArea.Footer, rows = 2..3, columns = 1..2)` is functionally equivalent to
* `area(AppArea.Footer, row = 2, column = 1, rowSpan = 2, columnSpan = 2)`.
*
* @sample androidx.compose.foundation.layout.samples.GridWithAreaRanges
* @param areaId A user-defined identifier (e.g., an Enum, String, or object marker) that
* represents this area.
* @param rows The range of rows to occupy (e.g., `1..2`). The start determines the 1-based row
* index, and the size of the range determines the span.
* @param columns The range of columns to occupy (e.g., `1..3`). The start determines the
* 1-based column index, and the size of the range determines the span.
*/
fun area(areaId: Any, rows: IntRange, columns: IntRange) {
require(!rows.isEmpty()) { "Row range ($rows) cannot be empty" }
require(!columns.isEmpty()) { "Column range ($columns) cannot be empty" }
area(
areaId = areaId,
row = rows.first,
column = columns.first,
rowSpan = rows.last - rows.first + 1,
columnSpan = columns.last - columns.first + 1,
)
}
/**
* Sets both the row and column gaps (gutters) to [all].
*
* **Precedence:** If this is called multiple times, or mixed with [columnGap] or [rowGap], the
* **last call** takes precedence.
*
* @throws IllegalArgumentException if [all] is negative.
*/
fun gap(all: Dp)
/**
* Sets independent gaps for rows and columns.
*
* **Precedence:** If this is called multiple times, or mixed with [columnGap] or [rowGap], the
* **last call** takes precedence.
*
* @throws IllegalArgumentException if [row] or [column] is negative.
*/
fun gap(row: Dp, column: Dp)
/**
* Sets the gap (gutter) size between columns.
*
* **Precedence:** If this is called multiple times, the **last call** takes precedence. This
* call will overwrite the column component of any previous [gap] call.
*
* @throws IllegalArgumentException if [gap] is negative.
*/
fun columnGap(gap: Dp)
/**
* Sets the gap (gutter) size between rows.
*
* **Precedence:** If this is called multiple times, the **last call** takes precedence. This
* call will overwrite the row component of any previous [gap] call.
*
* @throws IllegalArgumentException if [gap] is negative.
*/
fun rowGap(gap: Dp)
/**
* A flexible track with an explicitly defined minimum base size and a flexible maximum size.
* Conceptually, this behaves identically to the CSS Grid `minmax(min, max)` function.
*
* **Usage with Lazy Lists:** Because `minmax` relies on a predefined [min] size (e.g., `0.dp`),
* it entirely bypasses the intrinsic measurement pass. This makes it the **required** choice
* when placing `SubcomposeLayout`-backed components (such as `LazyColumn` or `LazyRow`) inside
* a flexible grid track.
*
* @sample androidx.compose.foundation.layout.samples.GridWithLazyList
* @param min The explicit minimum fixed base size (e.g., `0.dp`).
* @param max The maximum flexible distribution weight (e.g., `1.fr`).
*/
@Stable fun minmax(min: Dp, max: Fr): GridTrackSize = GridTrackSize.MinMax(min, max)
/** Creates an [Fr] unit from an [Int]. */
@Stable
@ExperimentalGridApi
val Int.fr: Fr
get() = Fr(this.toFloat())
/** Creates an [Fr] unit from a [Float]. */
@Stable
@ExperimentalGridApi
val Float.fr: Fr
get() = Fr(this)
/** Creates an [Fr] unit from a [Double]. */
@Stable
@ExperimentalGridApi
val Double.fr: Fr
get() = Fr(this.toFloat())
}
/** Adds multiple columns with the specified [specs]. */
@ExperimentalGridApi
fun GridConfigurationScope.columns(vararg specs: GridTrackSpec) {
for (spec in specs) {
if (spec is GridTrackSize) {
column(spec)
}
}
}
/** Adds multiple rows with the specified [specs]. */
@ExperimentalGridApi
fun GridConfigurationScope.rows(vararg specs: GridTrackSpec) {
for (spec in specs) {
if (spec is GridTrackSize) {
row(spec)
}
}
}
/** Defines the direction in which auto-placed items flow within the grid. */
@JvmInline
@ExperimentalGridApi
value class GridFlow @PublishedApi internal constructor(private val bits: Int) {
companion object {
/** Items are placed filling the first row, then moving to the next row. */
@ExperimentalGridApi
inline val Row
get() = GridFlow(0)
/** Items are placed filling the first column, then moving to the next column. */
@ExperimentalGridApi
inline val Column
get() = GridFlow(1)
}
override fun toString(): String =
when (this) {
Row -> "Row"
Column -> "Column"
else -> "GridFlow($bits)"
}
}
/**
* Represents a flexible unit used for sizing [Grid] tracks.
*
* One [Fr] unit represents a fraction of the *remaining* space in the grid container after
* [GridTrackSize.Fixed] and [GridTrackSize.Percentage] tracks have been allocated.
*
* When multiple tracks use [Fr] units (e.g., `1.fr`, `2.fr`, `1.fr`), the remaining space is
* divided proportionally to their weights. The total number of "fractional units" is the sum of all
* weights (in the example, 1 + 2 + 1 = 4). Each track receives a share of the space equal to its
* weight divided by the total weight.
* - The `1.fr` tracks would each get 1/4 of the remaining space.
* - The `2.fr` track would get 2/4 (or 1/2) of the remaining space.
*/
@JvmInline
@ExperimentalGridApi
value class Fr(val value: Float) {
override fun toString(): String = "$value.fr"
}
/**
* Marker interface to enable vararg usage with [GridTrackSize].
*
* This allows the configuration DSL to accept [GridTrackSize] items in a vararg (e.g.,
* `columns(Fixed(10.dp), Flex(1.fr))`), bypassing the Kotlin limitation on value class varargs.
*/
@ExperimentalGridApi sealed interface GridTrackSpec
/**
* Defines the size of a track (a row or a column) in a [Grid].
*
* Use the companion functions (e.g., [Fixed], [Flex]) to create instances.
*/
@Immutable
@JvmInline
@ExperimentalGridApi
value class GridTrackSize internal constructor(internal val encodedValue: Long) : GridTrackSpec {
// 1. Unpacking the Type
internal val type: Int
get() = (encodedValue ushr 60).toInt() // Top 4 bits
// 2. Unpacking a single value (for Fixed, Flex, Percentage)
internal val value: Float
get() = Float.fromBits(encodedValue.toInt()) // Bottom 32 bits
// 3. Unpacking dual values (for MinMax)
internal val minValue: Float
get() = Float.fromBits(((encodedValue ushr 30) and 0x3FFFFFFF).toInt() shl 2)
internal val maxValue: Float
get() = Float.fromBits((encodedValue and 0x3FFFFFFF).toInt() shl 2)
override fun toString(): String =
when (type) {
TypeFixed -> "Fixed(${value}dp)"
TypePercentage -> "Percentage($value)"
TypeFlex -> "Flex(${value}fr)"
TypeMinContent -> "MinContent"
TypeMaxContent -> "MaxContent"
TypeAuto -> "Auto"
TypeMinMax -> "MinMax(${minValue}dp, ${maxValue}fr)"
else -> "Unknown"
}
companion object {
internal const val TypeFixed = 1
internal const val TypePercentage = 2
internal const val TypeFlex = 3
internal const val TypeMinContent = 4
internal const val TypeMaxContent = 5
internal const val TypeAuto = 6
internal const val TypeMinMax = 7
/**
* A track with a fixed [Dp] size.
*
* @param size The size of the track.
* @throws IllegalArgumentException if [size] is negative or [Dp.Unspecified].
*/
@Stable
fun Fixed(size: Dp): GridTrackSize {
require(size != Dp.Unspecified && size.value >= 0f) {
"Fixed size must be non-negative and specified (was $size)"
}
return pack(TypeFixed, size.value)
}
/**
* A track sized as a percentage of the **total** available size of the grid container.
* **Note:** In this implementation, percentages are calculated based on the **remaining
* available space after gaps**. This differs from the W3C CSS Grid spec, where percentages
* are based on the container size regardless of gaps. This behavior prevents unexpected
* overflows when mixing gaps and percentages (e.g., `50%` + `50%` + `gap` will fit
* perfectly here, but would overflow in CSS).
*
* @param value The percentage of the container size.
* @throws IllegalArgumentException if [value] is negative.
*/
@Stable
fun Percentage(@FloatRange(from = 0.0) value: Float): GridTrackSize {
require(value >= 0f) { "Percentage cannot be negative" }
return pack(TypePercentage, value)
}
/**
* A flexible track that takes a share of the **remaining** space in the grid after
* non-flexible tracks (like [Fixed] and [Percentage]) are allocated.
*
* **Intrinsic Sizing:** By default, a [Flex] track behaves like CSS `1fr` (which implies
* `minmax(min-content, <weight>fr)`). Before distributing the remaining space, it queries
* the minimum intrinsic size (`min-content`) of its children to establish a base size and
* ensure content is not crushed.
*
* Jetpack Compose strictly forbids querying the intrinsic size of a `SubcomposeLayout`
* (such as [LazyColumn][androidx.compose.foundation.lazy.LazyColumn] or
* [LazyRow][androidx.compose.foundation.lazy.LazyRow]). Placing a lazy list directly inside
* a standard [Flex] track will result in an `IllegalStateException` crash. To safely place
* lazy lists in a flexible track, use [MinMax] instead.
*
* @param weight The flexible weight. Remaining space is distributed proportionally to this
* weight divided by the sum of all flex weights. Must be non-negative.
* @throws IllegalArgumentException if [weight] is negative.
* @see MinMax
*/
@Stable
fun Flex(@FloatRange(from = 0.0) weight: Fr): GridTrackSize {
require(weight.value >= 0f) { "Flex weight must be non-negative" }
return pack(TypeFlex, weight.value)
}
/**
* A flexible track with an explicitly defined minimum base size and a flexible maximum
* size. Conceptually, this behaves identically to the CSS Grid `minmax(min, max)` function.
*
* **Difference from [Flex]:** While a standard [Flex] track inherently queries the
* `min-content` intrinsic size of its children to determine its minimum base size, [MinMax]
* strictly uses the provided [min] size.
*
* **Usage with Lazy Lists:** Because [MinMax] relies on a predefined [min] size (e.g.,
* `0.dp`), it entirely bypasses the intrinsic measurement pass. This makes it the
* **required** choice when placing `SubcomposeLayout`-backed components (such as
* `LazyColumn` or `LazyRow`) inside a flexible grid track, as these components will crash
* if their intrinsic sizes are queried.
*
* @sample androidx.compose.foundation.layout.samples.GridWithLazyList
* @param min The explicit minimum fixed base size (e.g., `0.dp`).
* @param max The maximum flexible distribution weight (e.g., `1.fr`).
* @throws IllegalArgumentException if [min] is negative or [max] is negative.
* @see Flex
*/
@Stable
fun MinMax(min: Dp, @FloatRange(from = 0.0) max: Fr): GridTrackSize {
require(min.value >= 0f) { "MinMax minimum size cannot be negative" }
require(max.value >= 0f) { "MinMax max weight cannot be negative" }
return packMinMax(min.value, max.value)
}
/** A track that sizes itself to fit the minimum intrinsic size of its contents. */
val MinContent = pack(TypeMinContent, 0f)
/** A track that sizes itself to fit the maximum intrinsic size of its contents. */
val MaxContent = pack(TypeMaxContent, 0f)
/**
* A track that behaves as minmax(min-content, max-content). It occupies at least its
* minimum content size, and grows to fit its maximum content size if space is available.
*/
val Auto = pack(TypeAuto, 0f)
private fun packMinMax(min: Float, max: Float): GridTrackSize {
require(min >= 0f && max >= 0f) { "minmax values must be non-negative" }
val typeLong = TypeMinMax.toLong() and 0xFL
val minLong = (min.toRawBits() ushr 2).toLong() and 0x3FFFFFFFL
val maxLong = (max.toRawBits() ushr 2).toLong() and 0x3FFFFFFFL
val raw = (typeLong shl 60) or (minLong shl 30) or maxLong
return GridTrackSize(raw)
}
private fun pack(type: Int, value: Float): GridTrackSize {
val typeLong = type.toLong() and 0xFL // 4 bits
val valLong = value.toRawBits().toLong() and 0xFFFFFFFFL // 32 bits
// Format: [4 bits Type] [28 bits Empty] [32 bits Value]
val raw = (typeLong shl 60) or valLong
return GridTrackSize(raw)
}
}
}
/**
* The modifier element that creates and updates [GridItemNode].
*
* @property areaId The user-defined identifier for named area placement, or null if explicit
* coordinates are used.
* @property row The 1-based row index, or [GridScope.GridIndexUnspecified] for auto-placement.
* @property column The 1-based column index, or [GridScope.GridIndexUnspecified] for
* auto-placement.
* @property rowSpan The number of rows the item should occupy.
* @property columnSpan The number of columns the item should occupy.
* @property alignment The alignment of the content within the grid cell.
* @see GridItemNode
*/
private class GridItemElement(
val areaId: Any?,
val row: Int,
val column: Int,
val rowSpan: Int,
val columnSpan: Int,
val alignment: Alignment,
) : ModifierNodeElement<GridItemNode>() {
constructor(
row: Int,
column: Int,
rowSpan: Int,
columnSpan: Int,
alignment: Alignment,
) : this(null, row, column, rowSpan, columnSpan, alignment)
override fun create(): GridItemNode =
GridItemNode(areaId, row, column, rowSpan, columnSpan, alignment)
override fun update(node: GridItemNode) {
node.areaId = areaId
node.row = row
node.column = column
node.rowSpan = rowSpan
node.columnSpan = columnSpan
node.alignment = alignment
}
override fun InspectorInfo.inspectableProperties() {
name = "gridItem"
if (areaId != null) {
properties["area"] = areaId
} else {
properties["row"] = row
properties["column"] = column
properties["rowSpan"] = rowSpan
properties["columnSpan"] = columnSpan
}
properties["alignment"] = alignment
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is GridItemElement) return false
if (row != other.row) return false
if (column != other.column) return false
if (rowSpan != other.rowSpan) return false
if (columnSpan != other.columnSpan) return false
if (areaId != other.areaId) return false
if (alignment != other.alignment) return false
return true
}
override fun hashCode(): Int {
var result = row
result = 31 * result + column
result = 31 * result + rowSpan
result = 31 * result + columnSpan
result = 31 * result + (areaId?.hashCode() ?: 0)
result = 31 * result + alignment.hashCode()
return result
}
}
/**
* The modifier node that provides parent data to the [Grid] layout.
*
* This class implements [ParentDataModifierNode], allowing the parent [Grid] layout to inspect the
* configuration (row, column, spans) of this specific child during the measurement phase via the
* [modifyParentData] method.
*
* @property areaId The user-defined identifier for named area placement, or null if explicit
* coordinates are used.
* @property row The 1-based row index, or [GridScope.GridIndexUnspecified] for auto-placement.
* @property column The 1-based column index, or [GridScope.GridIndexUnspecified] for
* auto-placement.
* @property rowSpan The number of rows the item should occupy.
* @property columnSpan The number of columns the item should occupy.
* @property alignment The alignment of the content within the grid cell.
* @throws IllegalArgumentException if [rows] or [columns] ranges are empty, or if the derived
* row/column indices or spans do not meet the requirements of the primary [GridScope.gridItem]
* function.
* @see GridScope.gridItem for the public API and input validation.
*/
private class GridItemNode(
var areaId: Any?,
var row: Int,
var column: Int,
var rowSpan: Int,
var columnSpan: Int,
var alignment: Alignment,
) : Modifier.Node(), ParentDataModifierNode {
override fun Density.modifyParentData(parentData: Any?) = this@GridItemNode
}
/** A stable MeasurePolicy that reads configuration from a State. */
@PublishedApi
@ExperimentalGridApi
internal class GridMeasurePolicy(
private val configState: State<GridConfigurationScope.() -> Unit>
) : MeasurePolicy {
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints,
): MeasureResult {
// 1. Run Configuration DSL
val gridConfig = GridConfigurationScopeImpl(this, constraints).apply(configState.value)
// 2. Resolve Grid Item Indices (Resolve explicit and Auto placement)
// This calculates the concrete index (row, col) for every item and determines total grid
// size.
val resolvedGridItemsResult =
resolveGridItemIndices(
measurables = measurables,
columnSpecs = gridConfig.columnSpecs,
rowSpecs = gridConfig.rowSpecs,
flow = gridConfig.flow,
namedAreas = gridConfig.namedAreas,
)
// 3. Resolve Track Sizes
val trackSizes =
calculateGridTrackSizes(
density = this,
gridItems = resolvedGridItemsResult.gridItems,
columnSpecs = gridConfig.columnSpecs,
rowSpecs = gridConfig.rowSpecs,
totalColCount = resolvedGridItemsResult.gridSize.width,
totalRowCount = resolvedGridItemsResult.gridSize.height,
columnGap = gridConfig.columnGap,
rowGap = gridConfig.rowGap,
constraints = constraints,
)
// 4. Measure Children
// Measures content constraints based on track sizes and mutates GridItem with result.
measureItems(
gridItems = resolvedGridItemsResult.gridItems,
trackSizes = trackSizes,
layoutDirection = layoutDirection,
)
// 5. Layout
// Coerce the final size within constraints.
// If content is larger, it will overflow (report Max).
// If content is smaller than Min, it will expand (report Min).
val layoutWidth = constraints.constrainWidth(trackSizes.totalWidth)
val layoutHeight = constraints.constrainHeight(trackSizes.totalHeight)
return layout(layoutWidth, layoutHeight) {
val columnOffsets =
calculateTrackOffsets(trackSizes.columnWidths, trackSizes.columnGapPx)
val rowOffsets = calculateTrackOffsets(trackSizes.rowHeights, trackSizes.rowGapPx)
resolvedGridItemsResult.gridItems.forEach { gridItem ->
val placeable = gridItem.placeable
// Only place if measurement succeeded (guard against edge cases)
if (placeable != null) {
val x = columnOffsets[gridItem.column] + gridItem.offsetX
val y = rowOffsets[gridItem.row] + gridItem.offsetY
placeable.place(x, y)
}
}
}
}
}
private class GridConfigurationScopeImpl(density: Density, override val constraints: Constraints) :
GridConfigurationScope, Density by density {
val columnSpecs = mutableLongListOf()
val rowSpecs = mutableLongListOf()
private var _namedAreas: MutableObjectLongMap<Any>? = null
val namedAreas: ObjectLongMap<Any>?
get() = _namedAreas
var columnGap: Dp = 0.dp
var rowGap: Dp = 0.dp
override var flow: GridFlow = GridFlow.Row
override fun column(size: Dp) {
column(GridTrackSize.Fixed(size))
}
override fun column(weight: Fr) {
column(GridTrackSize.Flex(weight))
}
override fun column(percentage: Float) {
column(GridTrackSize.Percentage(percentage))
}
override fun column(size: GridTrackSize) {
columnSpecs.add(size.encodedValue)
}
override fun row(size: Dp) {
row(GridTrackSize.Fixed(size))
}
override fun row(weight: Fr) {
row(GridTrackSize.Flex(weight))
}
override fun row(percentage: Float) {
row(GridTrackSize.Percentage(percentage))
}
override fun row(size: GridTrackSize) {
rowSpecs.add(size.encodedValue)
}
override fun area(areaId: Any, row: Int, column: Int, rowSpan: Int, columnSpan: Int) {
require(row != GridIndexUnspecified || column != GridIndexUnspecified) {
"An area must specify at least a row or a column."
}
require(row in -MaxGridIndex..MaxGridIndex) {
"row must be between -$MaxGridIndex and $MaxGridIndex"
}
require(column in -MaxGridIndex..MaxGridIndex) {
"column must be between -$MaxGridIndex and $MaxGridIndex"
}
require(rowSpan in 1..MaxGridIndex) { "rowSpan must be between 1 and $MaxGridIndex" }
require(columnSpan in 1..MaxGridIndex) { "columnSpan must be between 1 and $MaxGridIndex" }
// Ensure future changes to MaxGridIndex don't break the 16-bit packing.
require(MaxGridIndex <= Short.MAX_VALUE) {
"MaxGridIndex ($MaxGridIndex) shouldn't exceed Short.MAX_VALUE for 16-bit bit-packing."
}
val packedRow = (row.toShort().toLong() and 0xFFFFL) shl 48
val packedCol = (column.toShort().toLong() and 0xFFFFL) shl 32
val packedRowSpan = (rowSpan.toShort().toLong() and 0xFFFFL) shl 16
val packedColSpan = (columnSpan.toShort().toLong() and 0xFFFFL)
val packedArea = packedRow or packedCol or packedRowSpan or packedColSpan
val map = _namedAreas ?: mutableObjectLongMapOf<Any>().also { _namedAreas = it }
map[areaId] = packedArea
}
override fun gap(all: Dp) {
require(all.value >= 0f) { "Gap must be non-negative" }
columnGap = all
rowGap = all
}
override fun gap(row: Dp, column: Dp) {
require(row.value >= 0f) { "Row gap must be non-negative" }
require(column.value >= 0f) { "Column gap must be non-negative" }
rowGap = row
columnGap = column
}
override fun columnGap(gap: Dp) {
require(gap.value >= 0f) { "Column gap must be non-negative" }
columnGap = gap
}
override fun rowGap(gap: Dp) {
require(gap.value >= 0f) { "Row gap must be non-negative" }
rowGap = gap
}
}
/**
* A mutable state object representing a single child in the Grid throughout the layout lifecycle.
*
* This object is created once during the [resolveGridItemIndices] phase (containing only placement
* info) and is reused and mutated during the [measureItems] phase to store the resulting
* [Placeable] and calculation offsets. This significantly reduces object allocation per layout
* pass.
*/
private class GridItem(
val measurable: Measurable,
var row: Int,
var column: Int,
var rowSpan: Int,
var columnSpan: Int,
val alignment: Alignment,
var placeable: Placeable? = null,
var offsetX: Int = 0,
var offsetY: Int = 0,
)
/**
* The output of the [resolveGridItemIndices] algorithm.
*
* This container holds the complete layout plan required for subsequent measurement phases. It
* encapsulates both the individual item positions and the aggregate dimensions of the grid.
*
* The [gridSize] is critical because it reveals the extent of the "Implicit Grid" — tracks that
* were not explicitly defined by the user but were created automatically to accommodate auto-placed
* items or items with out-of-bounds indices.
*
* @property gridItems The list of all items with their resolved (row, column) coordinates.
* @property gridSize The total number of rows and columns required to house all items. (width =
* total columns, height = total rows).
*/
private class ResolvedGridItemIndicesResult(
val gridItems: MutableObjectList<GridItem>,
val gridSize: IntSize,
)
/**
* The "Master Blueprint" holding the calculated pixel dimensions for the entire grid.
*
* This class acts as a lookup table during the measurement phase. Instead of recalculating sizes
* for every item, we compute the track sizes once and pass this object around.
*
* @property columnWidths Array containing the exact width in pixels for each column index.
* @property rowHeights Array containing the exact height in pixels for each row index.
* @property totalWidth The sum of all column widths plus gaps.
* @property totalHeight The sum of all row heights plus gaps.
* @property columnGapPx The spacing between columns.
* @property rowGapPx The spacing between rows.
*/
private class GridTrackSizes(
val columnWidths: IntArray,
val rowHeights: IntArray,
val totalWidth: Int,
val totalHeight: Int,
val columnGapPx: Int,
val rowGapPx: Int,
)
/**
* Executes the "Sparse Packing" auto-placement algorithm to resolve every item's position.
*
* This function is the "Engine" of the auto-placement logic. It transforms a list of raw
* measurables (with potentially unspecified `row`/`column` values) into a concrete plan where every
* item has a specific (row, column) coordinate.
*
* **Algorithm Overview:**
* 1. **Named Areas Resolution:** If an item specifies an `area`, we look up its physical bounds
* from the [namedAreas] map.
* 2. **Explicit Placement:** Items with both `row` and `column` manually specified are placed
* first. They anchor the grid and do not move.
* 3. **Auto-Placement Cursor:** A "cursor" (current row/column pointer) tracks the next available
* position.
* 4. **Filling Gaps:** The algorithm iterates through the remaining items. For each item:
* - It advances the cursor to the first slot that can accommodate the item's span without
* overlapping existing items.
* - It respects the [flow] direction (Row-major vs Column-major).
* - It creates "Implicit Tracks" (expanding the grid bounds) if an item is placed outside the
* currently defined area.
*
* @param measurables The raw list of children to place.
* @param columnSpecs The explicit column definitions (used to determine wrapping points).
* @param rowSpecs The explicit row definitions (used to determine wrapping points).
* @param flow The direction ([GridFlow.Row] or [GridFlow.Column]) to fill the grid.
* @param namedAreas The map of user-defined areas to their bit-packed coordinate and span
* definitions.
* @return A [ResolvedGridItemIndicesResult] containing the final positions and the *total* grid
* dimensions (Explicit + Implicit).
*/
private fun resolveGridItemIndices(
measurables: List<Measurable>,
columnSpecs: LongList,
rowSpecs: LongList,
flow: GridFlow,
namedAreas: ObjectLongMap<Any>?,
): ResolvedGridItemIndicesResult {
val gridItems = MutableObjectList<GridItem>(measurables.size)
// Key = (row shl 16) | (column & 0xFFFF)
// Supports up to 65,535 rows/cols (well within MaxGridIndex).
val occupiedCells = MutableIntSet()
val explicitColCount = columnSpecs.size
val explicitRowCount = rowSpecs.size
// Track the effective size of the grid (starts at explicit size, expands if items are placed
// outside)
var maxRow = explicitRowCount
var maxCol = explicitColCount
// Pack into Int (Row in high 16 bits, Col in low 16 bits)
// Supports up to 65,535 rows/cols.
fun packCoordinate(row: Int, column: Int): Int = (row shl 16) or (column and 0xFFFF)
// Checks if the target area (defined by start position and span) overlaps with any existing
// item.
fun isAreaOccupied(startRow: Int, startCol: Int, rowSpan: Int, colSpan: Int): Boolean {
// Fast-path: Check boundary limits first
if (startRow + rowSpan > MaxGridIndex || startCol + colSpan > MaxGridIndex) return true
for (r in startRow until startRow + rowSpan) {
for (c in startCol until startCol + colSpan) {
if (occupiedCells.contains(packCoordinate(r, c))) return true
}
}
return false
}
// Marks the cells in the area as occupied.
fun markAreaOccupied(startRow: Int, startCol: Int, rowSpan: Int, colSpan: Int) {
for (r in startRow until startRow + rowSpan) {
for (c in startCol until startCol + colSpan) {
occupiedCells.add(packCoordinate(r, c))
}
}
}
// The "Cursor" tracks the position of the last auto-placed item.
// Subsequent auto-placed items attempt to start searching from here to avoid re-scanning the
// whole grid.
var autoPlacementCursorRow = 0
var autoPlacementCursorCol = 0
measurables.fastForEach { measurable ->
val parentData = measurable.parentData as? GridItemNode
var rowSpan = 1
var colSpan = 1
var requestedRow = UnspecifiedResolvedIndex
var requestedCol = UnspecifiedResolvedIndex
var alignment = Alignment.TopStart
if (parentData != null) {
alignment = parentData.alignment
// Determine the specified layout coordinates and spans for this item.
// These can originate from either a semantic Named Area or direct modifier coordinates.
var specifiedRow: Int
var specifiedCol: Int
val areaId = parentData.areaId
if (areaId != null) {
// Handle Named Area Placement
// Look up the bit-packed bounds that were registered in the Grid config block.
if (namedAreas != null && namedAreas.contains(areaId)) {
val packedBounds = namedAreas[areaId]
specifiedRow = (packedBounds ushr 48).toShort().toInt()
specifiedCol = ((packedBounds ushr 32) and 0xFFFF).toShort().toInt()
rowSpan = ((packedBounds ushr 16) and 0xFFFF).toShort().toInt()
colSpan = (packedBounds and 0xFFFF).toShort().toInt()
} else {
// Fallback for unknown area
// If the user requested an area that was not defined in the config,
// we gracefully fall back to Auto-Placement.
specifiedRow = GridIndexUnspecified
specifiedCol = GridIndexUnspecified
rowSpan = 1
colSpan = 1
}
} else {
// Explicit Coordinate Modifier
// No area was provided, meaning the user used the absolute coordinate modifier
// (e.g., Modifier.gridItem(row = 1, column = 2)). Use those exact values directly.
specifiedRow = parentData.row
specifiedCol = parentData.column
rowSpan = parentData.rowSpan
colSpan = parentData.columnSpan
}
// Clamp spans for items that rely on auto-placement.
// A child might request a large span (e.g., 4 columns) on a
// small screen that only defines 2 columns. Clamping ensures the item safely
// acts as a "full-width" item instead of measuring extra gap space and bleeding
// outside the grid's bounds.
// We leave explicitly positioned items alone so developers can still intentionally
// span items into implicit tracks.
if (
specifiedRow == GridIndexUnspecified &&
explicitRowCount > 0 &&
flow == GridFlow.Column
) {
rowSpan = rowSpan.fastCoerceAtMost(explicitRowCount)
}
if (
specifiedCol == GridIndexUnspecified && explicitColCount > 0 && flow == GridFlow.Row
) {
colSpan = colSpan.fastCoerceAtMost(explicitColCount)
}
// Convert 1-based user indices to 0-based internal indices.
// Returns null if the user index was unspecified (Auto).
requestedRow = resolveToZeroBasedIndex(specifiedRow, explicitRowCount)
requestedCol = resolveToZeroBasedIndex(specifiedCol, explicitColCount)
}
var finalRow = UnspecifiedResolvedIndex
var finalCol = UnspecifiedResolvedIndex
// 1. Fully Explicit (Row & Column fixed)
// We simply place it there. Overlaps are allowed for explicit placement.
if (requestedRow != UnspecifiedResolvedIndex && requestedCol != UnspecifiedResolvedIndex) {
finalRow = requestedRow
finalCol = requestedCol
}
// 2. Fixed Row (Search for Column)
else if (requestedRow != UnspecifiedResolvedIndex) {
// Search for the first available column in the specified row.
finalRow = requestedRow
var candidateCol = 0
// If flowing by Row, and we are on the cursor's row, start searching from cursor
if (flow == GridFlow.Row && requestedRow == autoPlacementCursorRow) {
candidateCol = autoPlacementCursorCol
}
while (candidateCol < MaxGridIndex) {
if (!isAreaOccupied(requestedRow, candidateCol, rowSpan, colSpan)) {
finalCol = candidateCol
break
}
candidateCol++
}
}
// 3. Fixed Column (Search for Row)
else if (requestedCol != UnspecifiedResolvedIndex) {
// Search for the first available row in the specified column.
finalCol = requestedCol
var candidateRow = 0
// If flowing by Column, and we are on the cursor's col, start searching from cursor
if (flow == GridFlow.Column && requestedCol == autoPlacementCursorCol) {
candidateRow = autoPlacementCursorRow
}
while (candidateRow < MaxGridIndex) {
if (!isAreaOccupied(candidateRow, requestedCol, rowSpan, colSpan)) {
finalRow = candidateRow
break
}
candidateRow++
}
}
// 4. Fully Auto (Search for Slot)
else {
// Start searching from the current cursor position.
var candidateRow = autoPlacementCursorRow
var candidateCol = autoPlacementCursorCol
while (candidateRow < MaxGridIndex && candidateCol < MaxGridIndex) {
// Wrapping Logic
// If the item doesn't fit in the current track (explicit bounds), wrap to next.
if (flow == GridFlow.Row) {
// If we have explicit columns and exceed them...
if (explicitColCount > 0 && candidateCol + colSpan > explicitColCount) {
// If we are NOT at start, wrap.
// If we ARE at start (0) and still don't fit, we must overflow (create
// implicit track).
if (candidateCol > 0) {
candidateCol = 0
candidateRow++
continue // Re-evaluate wrapping at new position
}
}
} else { // GridFlow.Column
if (explicitRowCount > 0 && candidateRow + rowSpan > explicitRowCount) {
if (candidateRow > 0) {
candidateRow = 0
candidateCol++
continue
}
}
}
if (!isAreaOccupied(candidateRow, candidateCol, rowSpan, colSpan)) {
finalRow = candidateRow
finalCol = candidateCol
break
}
// Increment
if (flow == GridFlow.Row) {
candidateCol++
// If we drift too far right without wrapping (infinite grid), force wrap safety
if (candidateCol > MaxGridIndex) {
candidateCol = 0
candidateRow++
}
} else {
candidateRow++
if (candidateRow > MaxGridIndex) {
candidateRow = 0
candidateCol++
}
}
}
}
// If auto-placement failed to find a spot (e.g. MaxGridIndex reached),
// we default to 0,0 to avoid crashing, though visual overlap will occur.
val placementRow = max(0, finalRow)
val placementCol = max(0, finalCol)
markAreaOccupied(placementRow, placementCol, rowSpan, colSpan)
// Populate the mutable GridItem
gridItems.add(
GridItem(
measurable = measurable,
row = placementRow,
column = placementCol,
rowSpan = rowSpan,
columnSpan = colSpan,
alignment = alignment,
)
)
// Expand total grid bounds if necessary
maxRow = max(maxRow, placementRow + rowSpan)
maxCol = max(maxCol, placementCol + colSpan)
// Update Cursor (Only for non-explicit / fully auto placements)
// Only update cursor if the item was NOT fully explicit.
// 1D areas (fixed row or fixed col) and explicit items shouldn't drag the global cursor.
if (requestedRow == UnspecifiedResolvedIndex && requestedCol == UnspecifiedResolvedIndex) {
if (flow == GridFlow.Row) {
autoPlacementCursorRow = placementRow
autoPlacementCursorCol = placementCol + colSpan
} else {
autoPlacementCursorRow = placementRow + rowSpan
autoPlacementCursorCol = placementCol
}
}
}
// Sort the items immediately after their physical coordinates are resolved.
// This ensures that Track Sizing, Measuring, and Placement all iterate over the
// items in spatial Z-order (top-start to bottom-end).
gridItems.sortWith(GridItemsComparator)
return ResolvedGridItemIndicesResult(gridItems, IntSize(maxCol, maxRow))
}
/**
* A singleton comparator used to sort GridItems into their visual Z-order (row-major). Statically
* allocated to prevent object creation during the measure/layout pass.
*/
private val GridItemsComparator =
Comparator<GridItem> { a, b ->
val rowCompare = a.row.compareTo(b.row)
if (rowCompare != 0) rowCompare else a.column.compareTo(b.column)
}
/**
* Temporary extension to sort a [MutableObjectList] using a [Comparator]. Uses an allocation-free
* insertion sort to guarantee zero memory allocations during the high-frequency measurement/layout
* phase.
*
* Remove this once `sortBy` / `sortWith` is natively added to `MutableObjectList`.
*/
private fun <T> MutableObjectList<T>.sortWith(comparator: Comparator<T>) {
for (i in 1 until size) {
val current = this[i]
var j = i - 1
// Shift elements to the right to make room for the current item
while (j >= 0 && comparator.compare(this[j], current) > 0) {
this[j + 1] = this[j]
j--
}
this[j + 1] = current
}
}
private const val UnspecifiedResolvedIndex = -1
/**
* Resolves a 1-based user index (positive or negative) to a 0-based concrete index.
*
* @param index The user-provided index (e.g., 1, -1, or [GridIndexUnspecified]).
* @param maxCount The number of explicit tracks defined (used for negative index resolution).
* @return The 0-based index, or -1 if the index was unspecified or invalid (e.g. negative index out
* of bounds).
*/
private fun resolveToZeroBasedIndex(index: Int, maxCount: Int): Int {
if (index == GridIndexUnspecified) return UnspecifiedResolvedIndex
// Positive Index (e.g., 5): Maps to 4.
// Always valid (allows creating implicit tracks if > maxCount).
if (index > 0) return index - 1
// Negative Index (e.g., -1): Maps to maxCount - 1.
// Must check if it points to a valid explicit track [0..maxCount-1].
// If it points before 0 (e.g. -5 in a 2-row grid), it is invalid.
val resolved = maxCount + index
return if (resolved >= 0) resolved else -1
}
/**
* Resolves the abstract [GridTrackSize] specifications for all rows and columns into concrete pixel
* dimensions. This function is the core of the size calculation logic for the [Grid].
*
* **Calculation Order:** The calculation is performed in two main phases:
* 1. **Column Widths:** Column widths are calculated first. This is crucial because the height of
* many UI elements (like text) depends on the available width.
* 2. **Row Heights:** Row heights are calculated second, utilizing the resolved column widths to
* accurately measure items, especially those with content that wraps.
*
* **Track Type Resolution:** Within each phase, different [GridTrackSize] types are resolved as
* follows:
* - [GridTrackSize.Fixed]: Converted directly to pixels using the [density].
* - [GridTrackSize.Percentage]: Calculated based on the available space for tracks (after
* subtracting gaps). Falls back to content-based size (MaxContent) if the available space on that
* axis is infinite (e.g., in a scrollable container).
* - [GridTrackSize.MinContent], [GridTrackSize.MaxContent], [GridTrackSize.Auto]: Determined by
* measuring the intrinsic sizes of the items within the track. `Auto` typically behaves like
* `MaxContent`.
* - [GridTrackSize.Flex]: Initially sized to their minimum content size. After all other types and
* spanning items are accounted for, any remaining space is distributed proportionally among flex
* tracks.
*
* **Implicit Tracks:** Tracks not explicitly defined in [columnSpecs] or [rowSpecs] (i.e., indices
* beyond the spec list sizes) are treated as `GridTrackSize.Auto`.
*
* **Spanning Items:** The function accounts for items spanning multiple tracks, potentially
* increasing the sizes of growable tracks ([GridTrackSize.Auto], [GridTrackSize.MinContent],
* [GridTrackSize.MaxContent], [GridTrackSize.Flex]) to accommodate them.
*
* @param density The current screen density, used for converting Dp to pixels.
* @param gridItems The list of all grid items, including their placement and spans.
* @param columnSpecs The explicit configurations for columns.
* @param rowSpecs The explicit configurations for rows.
* @param totalColCount The total number of columns in the grid (explicit + implicit).
* @param totalRowCount The total number of rows in the grid (explicit + implicit).
* @param constraints The layout constraints from the parent composable.
* @param columnGap The spacing in Dp between columns.
* @param rowGap The spacing in Dp between rows.
* @return A [GridTrackSizes] object containing the calculated pixel sizes for each column and row,
* the total grid dimensions, and the gap sizes in pixels.
*/
private fun calculateGridTrackSizes(
density: Density,
gridItems: MutableObjectList<GridItem>,
columnSpecs: LongList,
rowSpecs: LongList,
totalColCount: Int, // Total (Implicit + Explicit)
totalRowCount: Int, // Total (Implicit + Explicit)
constraints: Constraints,
columnGap: Dp,
rowGap: Dp,
): GridTrackSizes {
val colGapPx = with(density) { columnGap.roundToPx() }
val rowGapPx = with(density) { rowGap.roundToPx() }
// Group items by track index to avoid O(Tracks * Items) loop
// Array of lists, where index corresponds to the column index
val itemsByColumn = arrayOfNulls<MutableObjectList<GridItem>>(totalColCount)
// Array of lists, where index corresponds to the row index
val itemsByRow = arrayOfNulls<MutableObjectList<GridItem>>(totalRowCount)
gridItems.forEach { item ->
// Populate Column Lookup
if (item.column < totalColCount) {
val list =
itemsByColumn[item.column]
?: MutableObjectList<GridItem>().also { itemsByColumn[item.column] = it }
list.add(item)
}
// Populate Row Lookup
if (item.row < totalRowCount) {
val list =
itemsByRow[item.row]
?: MutableObjectList<GridItem>().also { itemsByRow[item.row] = it }
list.add(item)
}
}
// --- Phase 1: Calculate Column Widths ---
// Use totalColCount for array size
val columnWidths = IntArray(totalColCount)
val totalTrackWidth =
calculateColumnWidths(
density = density,
explicitSpecs = columnSpecs,
totalCount = totalColCount,
availableSpace = constraints.maxWidth,
outSizes = columnWidths,
itemsByColumn = itemsByColumn,
constraints = constraints,
gridItems = gridItems,
columnGap = colGapPx,
)
// --- Phase 2: Calculate Row Heights ---
val rowHeights = IntArray(totalRowCount)
val totalTrackHeight =
calculateRowHeights(
density = density,
explicitSpecs = rowSpecs,
totalCount = totalRowCount,
availableSpace = constraints.maxHeight,
outSizes = rowHeights,
itemsByRow = itemsByRow,
constraints = constraints,
columnWidths = columnWidths,
gridItems = gridItems,
rowGap = rowGapPx,
columnGap = colGapPx,
)
// Use totalColCount and totalRowCount instead of the explicit spec sizes.
// This ensures gaps between implicitly created tracks are included in the final Grid size.
val totalColumnGap = max(0, totalColCount - 1) * colGapPx
val totalRowGap = max(0, totalRowCount - 1) * rowGapPx
return GridTrackSizes(
columnWidths = columnWidths,
rowHeights = rowHeights,
columnGapPx = colGapPx,
rowGapPx = rowGapPx,
totalWidth = totalTrackWidth + totalColumnGap,
totalHeight = totalTrackHeight + totalRowGap,
)
}
/**
* Calculates the specific pixel width of every column in the grid.
*
* This function implements the horizontal axis sizing logic. It resolves column widths based on
* explicit configuration, available space, and content intrinsic sizes.
*
* **Algorithm Overview:**
* 1. **Pass 1 (Base Sizes):** Calculates the initial width of each column based on its
* [GridTrackSize].
* * **Implicit Tracks:** Indices beyond `explicitSpecs` default to [GridTrackSize.Auto].
* * **Fixed:** Resolves directly to pixels.
* * **Percentage:** Resolves against total available width. Falls back to `Auto` (MaxContent) if
* width is infinite (e.g., inside a container made horizontally scrollable with the
* `horizontalScroll` modifier).
* * **Flex:** Starts at `min-content` size to prevent collapse if content exists.
* * **Auto/Content-based:** Measured using the intrinsic width of items in that column.
* 2. **Pass 1.5 (Spanning Items):** Increases column widths if an item spanning multiple columns
* requires more width than the sum of those columns.
* 3. **Pass 1.8 (Expand Auto Tracks):** Distributes remaining available space to `Auto` tracks,
* allowing them to grow from their `min-content` floor toward their `max-content` cap. This
* ensures `Auto` columns are responsive but don't aggressively consume space needed for `Flex`
* columns.
* 4. **Pass 2 (Flex Distribution):** Distributes any remaining horizontal space among
* [GridTrackSize.Flex] columns according to their weight.
*
* @param density Used for Dp-to-Px conversion.
* @param explicitSpecs The user-defined column configurations.
* @param totalCount The total number of columns (explicit + implicit).
* @param availableSpace The maximum width available (or [Constraints.Infinity]).
* @param outSizes Output array where calculated widths are stored. **Mutated in-place**.
* @param itemsByColumn Optimization lookup: List of items starting in each column index.
* @param constraints Parent constraints (used for fallback behavior and cross-axis limits).
* @param gridItems All items in the grid (used for spanning logic).
* @param columnGap The spacing between columns.
* @return The total used width in pixels (sum of all column widths).
*/
private fun calculateColumnWidths(
density: Density,
explicitSpecs: LongList,
totalCount: Int,
availableSpace: Int,
outSizes: IntArray,
itemsByColumn: Array<MutableObjectList<GridItem>?>,
constraints: Constraints,
gridItems: MutableObjectList<GridItem>,
columnGap: Int,
): Int {
if (totalCount == 0) return 0
var totalFlex = 0f
// Calculate total space consumed by gaps.
// e.g., 3 columns have 2 gaps. (N-1) * gap.
val totalGapSpace = (columnGap * (totalCount - 1)).fastCoerceAtLeast(0)
// Calculate space available for actual tracks (Total - Gaps).
// If availableSpace is Infinity, availableTrackSpace value becomes Constraints.Infinity
val availableTrackSpace =
if (availableSpace == Constraints.Infinity) {
Constraints.Infinity
} else {
(availableSpace - totalGapSpace).fastCoerceAtLeast(0)
}
// Keep track of which columns are Auto so we can expand them later
val autoIndices = MutableIntList()
// Store max intrinsic widths for Auto tracks here to avoid re-measuring later.
val autoColumnMaxSizes = IntArray(totalCount)
// --- Pass 1: Base Sizes (Single-Span Items) ---
// Iterate through every column index (both explicit and implicit).
for (index in 0 until totalCount) {
// If index exceeds explicit specs, treat it as an Implicit Auto track.
val specRaw =
if (index < explicitSpecs.size) explicitSpecs[index]
else GridTrackSize.Auto.encodedValue
val spec = GridTrackSize(specRaw)
val size =
when (spec.type) {
GridTrackSize.TypeFixed -> with(density) { spec.value.dp.roundToPx() }
GridTrackSize.TypePercentage -> {
if (availableTrackSpace != Constraints.Infinity) {
(spec.value * availableTrackSpace).roundToInt()
} else {
// If the Grid is in a horizontally scrolling container
// (infinite width), we cannot calculate a percentage of "Infinity".
// We default to 'Auto' (MaxIntrinsic) so the content remains visible.
calculateMaxIntrinsicWidth(itemsByColumn[index])
}
}
GridTrackSize.TypeFlex -> {
totalFlex += spec.value
// Flex tracks start at their 'min-content' size.
// This implements `minmax(min-content, <flex-factor>fr)`.
// It ensures that even if there is no remaining space to distribute,
// the column is at least wide enough to show its content.
calculateMinIntrinsicWidth(itemsByColumn[index])
}
GridTrackSize.TypeMinContent -> calculateMinIntrinsicWidth(itemsByColumn[index])
GridTrackSize.TypeMaxContent -> calculateMaxIntrinsicWidth(itemsByColumn[index])
GridTrackSize.TypeAuto -> {
if (availableTrackSpace == Constraints.Infinity) {
// If infinite space, Auto behaves like MaxContent
calculateMaxIntrinsicWidth(itemsByColumn[index])
} else {
// Finite space: Auto needs Min (for base) AND Max (for growth).
val packed = calculateMinMaxIntrinsicWidth(itemsByColumn[index])
// Unpack the Long (High 32 = Max, Low 32 = Min)
val max = (packed ushr 32).toInt()
val min = (packed and 0xFFFFFFFFL).toInt()
autoIndices.add(index)
// Cache Max for Pass 1.8
autoColumnMaxSizes[index] = max
// Return Min for Base Size
min
}
}
GridTrackSize.TypeMinMax -> {
// Accumulate the max Fr weight for Pass 2
totalFlex += spec.maxValue
// The base size is the exact fixed minimum, skipping intrinsic queries.
with(density) { spec.minValue.dp.roundToPx() }
}
// Measure the max intrinsic width of all items in this column.
else -> calculateMaxIntrinsicWidth(itemsByColumn[index])
}
outSizes[index] = size
}
// --- Pass 1.5: Spanning Items ---
// If an item spans 2 columns, and those 2 columns (base sizes) sum to 100px, but the item
// is 150px wide, we must grow the columns by 50px.
distributeSpanningSpace(
explicitSpecs = explicitSpecs,
sizes = outSizes,
gridItems = gridItems,
isRowAxis = false,
constraints = constraints,
crossAxisSizes = null, // Not needed for column width calculation
mainAxisGap = columnGap,
crossAxisGap = 0,
)
// --- Pass 1.8: Expand Auto Tracks ---
// Only strictly needed if we are constrained.
// Auto tracks consume space AFTER fixed/min-content, but BEFORE Flex tracks.
if (availableTrackSpace != Constraints.Infinity && autoIndices.isNotEmpty()) {
expandAutoTracks(
autoTrackIndices = autoIndices,
outSizes = outSizes,
maxSizes = autoColumnMaxSizes,
availableSpace = availableTrackSpace,
)
}
// --- Pass 2: Flex Distribution ---
return distributeFlexSpaceAndGetTotal(
outSizes = outSizes,
availableTrackSpace = availableTrackSpace,
totalFlex = totalFlex,
totalCount = totalCount,
explicitSpecs = explicitSpecs,
)
}
/**
* Calculates the specific pixel height of every row in the grid.
*
* This function implements the vertical axis sizing logic. Unlike columns (which are usually fixed
* or determined by parent width), row heights often depend on the *width* of the content within
* them. Therefore, this function **must** be called after [calculateColumnWidths].
*
* **Algorithm Overview:**
* 1. **Pass 1 (Base Sizes):** Calculates the initial height of each row based on its
* [GridTrackSize].
* * **Implicit Tracks:** Indices beyond `explicitSpecs` default to [GridTrackSize.Auto].
* * **Auto/Content-based:** Measured using the pre-calculated `columnWidths`. This ensures text
* wraps correctly within its specific cell width.
* * **Percentage:** Resolves against total height. Falls back to `Auto` if height is infinite
* (e.g., inside a ScrollView).
* * **Flex:** Starts at `min-content` size to prevent collapse if content exists.
* 2. **Pass 1.5 (Spanning Items):** Increases row heights if an item spanning multiple rows is
* taller than the sum of those rows.
* 3. **Pass 1.8 (Expand Auto Tracks):** Distributes remaining available space to `Auto` tracks,
* allowing them to grow from their `min-content` floor toward their `max-content` cap. This
* ensures `Auto` columns are responsive but don't aggressively consume space needed for `Flex`
* columns.
* 4. **Pass 2 (Flex Distribution):** Distributes any remaining vertical space among
* [GridTrackSize.Flex] rows according to their weight.
*
* @param density Used for Dp-to-Px conversion.
* @param explicitSpecs The user-defined row configurations.
* @param totalCount The total number of rows (explicit + implicit).
* @param availableSpace The maximum height available (or [Constraints.Infinity]).
* @param outSizes Output array where calculated heights are stored. **Mutated in-place**.
* @param itemsByRow Optimization lookup: List of items starting in each row index.
* @param constraints Parent constraints (used for max height limits).
* @param columnWidths The resolved widths of columns. **Critical** for measuring text height.
* @param gridItems All items in the grid (used for spanning logic).
* @param rowGap The spacing between rows.
* @return The total used height in pixels (sum of all row heights).
*/
private fun calculateRowHeights(
density: Density,
explicitSpecs: LongList,
totalCount: Int,
availableSpace: Int,
outSizes: IntArray,
itemsByRow: Array<MutableObjectList<GridItem>?>,
constraints: Constraints,
columnWidths: IntArray,
gridItems: MutableObjectList<GridItem>,
rowGap: Int,
columnGap: Int,
): Int {
if (totalCount == 0) return 0
var totalFlex = 0f
// Calculate total space consumed by gaps.
// e.g., 3 columns have 2 gaps. (N-1) * gap.
val totalGapSpace = (rowGap * (totalCount - 1)).fastCoerceAtLeast(0)
// Calculate space available for actual tracks (Total - Gaps).
// If availableSpace is Infinity, availableTrackSpace value becomes Constraints.Infinity
val availableTrackSpace =
if (availableSpace == Constraints.Infinity) {
Constraints.Infinity
} else {
(availableSpace - totalGapSpace).fastCoerceAtLeast(0)
}
// Keep track of which columns are Auto so we can expand them later
val autoIndices = MutableIntList()
val autoRowMaxSizes = IntArray(totalCount)
// --- Pass 1: Base Sizes (Single-Span Items) ---
// We iterate through every row index (both explicit and implicit).
for (index in 0 until totalCount) {
// If index exceeds explicit specs, treat it as an Implicit Auto track.
val specRaw =
if (index < explicitSpecs.size) explicitSpecs[index]
else GridTrackSize.Auto.encodedValue
val spec = GridTrackSize(specRaw)
val size =
when (spec.type) {
GridTrackSize.TypeFixed -> with(density) { spec.value.dp.roundToPx() }
GridTrackSize.TypePercentage -> {
if (availableTrackSpace != Constraints.Infinity) {
(spec.value * availableTrackSpace).roundToInt()
} else {
// If the Grid is in a vertically scrolling container
// (infinite height), we cannot calculate a percentage of "Infinity".
// We default to 'Auto' (MaxIntrinsic) so the content remains visible.
calculateMaxIntrinsicHeight(
items = itemsByRow[index],
columnWidths = columnWidths,
fallbackWidth = constraints.maxWidth,
columnGap = columnGap,
)
}
}
GridTrackSize.TypeFlex -> {
totalFlex += spec.value
// Flex tracks start at their 'min-content' size.
// This implements `minmax(min-content, <flex-factor>fr)`.
// It ensures that even if there is no remaining space to distribute,
// the row is at least tall enough to show its content.
calculateMinIntrinsicHeight(
items = itemsByRow[index],
columnWidths = columnWidths,
fallbackWidth = constraints.maxWidth,
columnGap = columnGap,
)
}
GridTrackSize.TypeMinContent ->
calculateMinIntrinsicHeight(
items = itemsByRow[index],
columnWidths = columnWidths,
fallbackWidth = constraints.maxWidth,
columnGap = columnGap,
)
GridTrackSize.TypeMaxContent ->
calculateMaxIntrinsicHeight(
items = itemsByRow[index],
columnWidths = columnWidths,
fallbackWidth = constraints.maxWidth,
columnGap = columnGap,
)
GridTrackSize.TypeAuto -> {
// If infinite space, Auto behaves like MaxContent
if (availableTrackSpace == Constraints.Infinity) {
calculateMaxIntrinsicHeight(
items = itemsByRow[index],
columnWidths = columnWidths,
fallbackWidth = constraints.maxWidth,
columnGap = columnGap,
)
} else {
// Finite space: Auto needs Min (for base) and Max (for growth).
val packed =
calculateMinMaxIntrinsicHeight(
itemsByRow[index],
columnWidths,
constraints.maxWidth,
columnGap = columnGap,
)
// Unpack the Long (High 32 = Max, Low 32 = Min)
val max = (packed ushr 32).toInt()
val min = (packed and 0xFFFFFFFFL).toInt()
autoIndices.add(index)
// Cache Max for Pass 1.8
autoRowMaxSizes[index] = max
// Return Min for Base Size
min
}
}
GridTrackSize.TypeMinMax -> {
// Accumulate the max [Fr] weight for Pass 2
totalFlex += spec.maxValue
// The base size is the fixed minimum, skipping intrinsic queries.
with(density) { spec.minValue.dp.roundToPx() }
}
else ->
calculateMaxIntrinsicHeight(
items = itemsByRow[index],
columnWidths = columnWidths,
fallbackWidth = constraints.maxWidth,
columnGap = columnGap,
)
}
outSizes[index] = size
}
// --- Pass 1.5: Spanning Items ---
// If an item spans 2 rows, and those 2 rows (base sizes) sum to 100px, but the item
// is 150px tall, we must grow the rows by 50px.
distributeSpanningSpace(
explicitSpecs = explicitSpecs,
sizes = outSizes,
gridItems = gridItems,
isRowAxis = true,
constraints = constraints,
crossAxisSizes = columnWidths,
mainAxisGap = rowGap,
crossAxisGap = columnGap,
)
// --- Pass 1.8: Expand Auto Tracks ---
// Only strictly needed if we are constrained.
// Auto tracks consume space AFTER fixed/min-content, but BEFORE Flex tracks.
if (availableTrackSpace != Constraints.Infinity && autoIndices.isNotEmpty()) {
expandAutoTracks(
autoTrackIndices = autoIndices,
outSizes = outSizes,
maxSizes = autoRowMaxSizes,
availableSpace = availableTrackSpace,
)
}
// --- Pass 2: Flex Distribution ---
return distributeFlexSpaceAndGetTotal(
outSizes = outSizes,
availableTrackSpace = availableTrackSpace,
totalFlex = totalFlex,
totalCount = totalCount,
explicitSpecs = explicitSpecs,
)
}
/**
* Calculates the total used space and distributes any remaining finite space proportionally among
* tracks defined as [GridTrackSize.Flex].
*/
private fun distributeFlexSpaceAndGetTotal(
outSizes: IntArray,
availableTrackSpace: Int,
totalFlex: Float,
totalCount: Int,
explicitSpecs: LongList,
): Int {
var usedSpace = 0
for (size in outSizes) {
usedSpace += size
}
// If we have finite space and unused space, distribute it to Flex tracks.
val remainingSpace =
if (availableTrackSpace == Constraints.Infinity) 0
else max(0, availableTrackSpace - usedSpace)
var totalAddedFromFlex = 0
if (totalFlex > 0 && remainingSpace > 0) {
var distributed = 0
var accumulatedFlex = 0f
for (index in 0 until totalCount) {
val specRaw =
if (index < explicitSpecs.size) explicitSpecs[index]
else GridTrackSize.Auto.encodedValue
val spec = GridTrackSize(specRaw)
val weight =
when (spec.type) {
GridTrackSize.TypeFlex -> spec.value
GridTrackSize.TypeMinMax -> spec.maxValue
else -> 0f
}
if (weight > 0f) {
accumulatedFlex += weight
// Distribute space proportionally based on weight.
// Uses an accumulation algorithm to avoid rounding errors summing to >
// remainingSpace.
val targetSpace = (accumulatedFlex / totalFlex * remainingSpace).roundToInt()
val share = max(0, targetSpace - distributed)
outSizes[index] += share
distributed += share
totalAddedFromFlex = distributed
}
}
}
return usedSpace + totalAddedFromFlex
}
private fun calculateMaxIntrinsicWidth(items: MutableObjectList<GridItem>?): Int {
if (items == null) return 0
var maxSize = 0
items.forEach { item ->
if (item.columnSpan == 1) {
val size = item.measurable.maxIntrinsicWidth(Constraints.Infinity)
if (size > maxSize) maxSize = size
}
}
return maxSize
}
private fun calculateMinIntrinsicWidth(items: MutableObjectList<GridItem>?): Int {
if (items == null) return 0
var maxSize = 0
items.forEach { item ->
if (item.columnSpan == 1) {
val size = item.measurable.minIntrinsicWidth(Constraints.Infinity)
if (size > maxSize) maxSize = size
}
}
return maxSize
}
private fun calculateMaxIntrinsicHeight(
items: MutableObjectList<GridItem>?,
columnWidths: IntArray,
fallbackWidth: Int,
columnGap: Int,
): Int {
if (items == null) return 0
var maxSize = 0
items.forEach { item ->
if (item.rowSpan == 1) {
val width = getSpannedWidth(item, columnWidths, fallbackWidth, columnGap)
val size = item.measurable.maxIntrinsicHeight(width)
if (size > maxSize) maxSize = size
}
}
return maxSize
}
private fun calculateMinIntrinsicHeight(
items: MutableObjectList<GridItem>?,
columnWidths: IntArray,
fallbackWidth: Int,
columnGap: Int,
): Int {
if (items == null) return 0
var maxSize = 0
items.forEach { item ->
if (item.rowSpan == 1) {
val width = getSpannedWidth(item, columnWidths, fallbackWidth, columnGap)
val size = item.measurable.minIntrinsicHeight(width)
if (size > maxSize) maxSize = size
}
}
return maxSize
}
/**
* Calculates both the minimum and maximum intrinsic widths of the provided [items] in a single
* pass.
*
* @param items The list of items in this column.
* @return A packed [Long] containing both values to avoid object allocation:
* * **High 32 bits:** The maximum intrinsic width. Extract via `(packed ushr 32).toInt()`.
* * **Low 32 bits:** The minimum intrinsic width. Extract via `(packed and 0xFFFFFFFFL).toInt()`.
*/
private fun calculateMinMaxIntrinsicWidth(items: MutableObjectList<GridItem>?): Long {
if (items == null) return 0L
var maxMin = 0
var maxMax = 0
items.forEach { item ->
if (item.columnSpan == 1) {
val min = item.measurable.minIntrinsicWidth(Constraints.Infinity)
val max = item.measurable.maxIntrinsicWidth(Constraints.Infinity)
if (min > maxMin) maxMin = min
if (max > maxMax) maxMax = max
}
}
return (maxMax.toLong() shl 32) or (maxMin.toLong() and 0xFFFFFFFFL)
}
/**
* Calculates both the minimum and maximum intrinsic heights of the provided [items] in a single
* pass.
*
* @param items The list of items in this row.
* @param columnWidths The calculated pixel widths of all columns. Used to measure height correctly.
* @param fallbackWidth The width to use if an item resides in an implicit column (index out of
* bounds).
* @return A packed [Long] containing both values:
* * **High 32 bits:** The maximum intrinsic height. Extract via `(packed ushr 32).toInt()`.
* * **Low 32 bits:** The minimum intrinsic height. Extract via `(packed and 0xFFFFFFFFL).toInt()`.
*/
private fun calculateMinMaxIntrinsicHeight(
items: MutableObjectList<GridItem>?,
columnWidths: IntArray,
fallbackWidth: Int,
columnGap: Int,
): Long {
if (items == null) return 0L
var maxMin = 0
var maxMax = 0
items.forEach { item ->
if (item.rowSpan == 1) {
val width = getSpannedWidth(item, columnWidths, fallbackWidth, columnGap)
val min = item.measurable.minIntrinsicHeight(width)
val max = item.measurable.maxIntrinsicHeight(width)
if (min > maxMin) maxMin = min
if (max > maxMax) maxMax = max
}
}
return (maxMax.toLong() shl 32) or (maxMin.toLong() and 0xFFFFFFFFL)
}
/**
* Calculates the total width occupied by a [GridItem] spanning multiple columns.
*
* This function sums the widths of all columns the item spans and includes any gaps between these
* columns. This is necessary to provide the correct width constraint when calculating intrinsic
* heights for items in [GridTrackSize.Auto] rows.
*
* @param item The [GridItem] for which to calculate the spanned width.
* @param columnWidths An array containing the calculated widths of each column.
* @param fallbackWidth The width to use for columns outside the bounds of [columnWidths].
* @param columnGap The spacing in pixels between columns.
* @return The total width in pixels occupied by the item, including gaps.
*/
private fun getSpannedWidth(
item: GridItem,
columnWidths: IntArray,
fallbackWidth: Int,
columnGap: Int,
): Int {
val colStart = item.column
if (colStart >= columnWidths.size) return fallbackWidth
var width = 0
val colEnd = (colStart + item.columnSpan).fastCoerceAtMost(columnWidths.size)
for (i in colStart until colEnd) {
width += columnWidths[i]
}
// Add the gaps that are included in the span
val spannedGaps = max(0, (colEnd - colStart) - 1) * columnGap
width += spannedGaps
return width
}
/**
* Increases the size of "growable" tracks (Auto, Flex, MinContent, MaxContent) to accommodate items
* that span across multiple tracks.
*
* This represents **Pass 1.5** of the grid sizing algorithm. It runs after base track sizes
* (Pass 1) are calculated but before flexible space (Pass 2) is distributed.
*
* **The Problem:** An item spanning 2 columns might have a minimum intrinsic width of 200px. If the
* base size of those 2 columns (plus the gap) only equals 150px, the item will be clipped or
* overlap.
*
* **The Solution (Deficit Distribution):**
* 1. Calculate the **Deficit**: `RequiredSize - (SumOfTracks + SumOfGaps)`.
* 2. Distribute this deficit evenly among the tracks involved in the span, *excluding* rigid tracks
* ([GridTrackSize.Fixed] and [GridTrackSize.Percentage]).
*
* @param explicitSpecs The user-defined track specifications. Used to determine if a track is rigid
* (Fixed/Percentage) or growable (Intrinsic).
* @param sizes The current calculated pixel sizes of the tracks.
* @param gridItems The list of all items to check for spanning requirements.
* @param isRowAxis `true` if calculating Row Heights, `false` if calculating Column Widths.
* @param constraints The parent layout constraints.
* @param crossAxisSizes The calculated sizes of the *opposite* axis (e.g., Column Widths when
* calculating Row Heights). This is crucial for correctly measuring the intrinsic height of items
* that wrap text based on specific column widths.
* @param mainAxisGap The spacing between tracks on the axis currently being calculated.
* @param crossAxisGap The spacing between tracks on the opposite axis.
*/
private fun distributeSpanningSpace(
explicitSpecs: LongList,
sizes: IntArray,
gridItems: MutableObjectList<GridItem>,
isRowAxis: Boolean,
constraints: Constraints,
crossAxisSizes: IntArray?,
mainAxisGap: Int,
crossAxisGap: Int,
) {
gridItems.forEach { item ->
val trackIndex = if (isRowAxis) item.row else item.column
val span = if (isRowAxis) item.rowSpan else item.columnSpan
// Single-span items were already handled during Base Size calculation (Pass 1).
if (span <= 1) return@forEach
val endIndex = (trackIndex + span).fastCoerceAtMost(sizes.size)
// --- Step 1: Analyze current space & identifying growable tracks ---
// We sum the current size of all tracks this item spans to see if they are already big
// enough.
var currentSpannedSize = 0
var tracksToGrowCount = 0
for (i in trackIndex until endIndex) {
currentSpannedSize += sizes[i]
// Implicit tracks (indices >= specs.size) default to Auto.
val specRaw =
if (i < explicitSpecs.size) explicitSpecs[i] else GridTrackSize.Auto.encodedValue
val spec = GridTrackSize(specRaw)
// Fixed and Percentage tracks are considered "Rigid". They respect the user's explicit
// definition and do not expand to fit content from spanning items.
// Only Intrinsic tracks (Auto, Flex, Min/MaxContent) absorb the deficit.
if (spec.type != GridTrackSize.TypeFixed && spec.type != GridTrackSize.TypePercentage) {
tracksToGrowCount++
}
}
// Add the gaps that are internal to the span on the main axis.
// If an item spans 3 columns, it spans 2 gaps. We must include these
// gaps in the 'currentSpannedSize' so we don't overestimate the deficit.
val spannedGapsMain = max(0, span - 1) * mainAxisGap
currentSpannedSize += spannedGapsMain
// --- Step 2: Calculate the Item's Required Size (Intrinsic Measurement) ---
// This differs based on the axis.
val requiredSize =
if (isRowAxis) {
// Case: Calculating Row Heights.
// To get the correct intrinsic height (e.g., for wrapping text), we need to know
// the exact width the item occupies. This is the sum of the columns it spans.
var itemWidth = 0
if (crossAxisSizes != null) {
val colStart = item.column
val colEnd = (colStart + item.columnSpan).fastCoerceAtMost(crossAxisSizes.size)
for (i in colStart until colEnd) {
itemWidth += crossAxisSizes[i]
}
// Add the gaps that are included in the span.
val spannedGapsCross = max(0, item.columnSpan - 1) * crossAxisGap
itemWidth += spannedGapsCross
} else {
// If we don't know column widths, constrain only by parent max.
itemWidth = constraints.maxWidth
}
item.measurable.maxIntrinsicHeight(itemWidth)
} else {
// Case: Calculating Column Widths.
// Intrinsic width must be calculated against infinite height to prevent
// aspect ratio modifiers from demanding widths based on the grid's height.
item.measurable.maxIntrinsicWidth(Constraints.Infinity)
}
// --- Step 3: Distribute Deficit ---
val deficit = requiredSize - currentSpannedSize
// If the item needs more space than currently available, and we have eligible tracks to
// grow, we distribute the missing pixels evenly.
if (deficit > 0 && tracksToGrowCount > 0) {
val share = deficit / tracksToGrowCount
var remainder = deficit % tracksToGrowCount
for (i in trackIndex until endIndex) {
val specRaw =
if (i < explicitSpecs.size) explicitSpecs[i]
else GridTrackSize.Auto.encodedValue
val spec = GridTrackSize(specRaw)
// Only add space to the "growable" tracks identified in Step 1.
if (
spec.type != GridTrackSize.TypeFixed &&
spec.type != GridTrackSize.TypePercentage
) {
// Add the base share + 1 pixel if we still have remainder to distribute.
// This ensures (share * count) + remainder == total deficit.
val add = share + if (remainder > 0) 1 else 0
sizes[i] += add
if (remainder > 0) remainder--
}
}
}
}
}
/**
* Expands [GridTrackSize.Auto] tracks from their minimum intrinsic size toward their maximum
* intrinsic size using the remaining available space.
*
* This behavior allows tracks to occupy at least their minimum content size, growing to fit their
* maximum content size if the container allows it.
*
* This runs **Pass 1.8**, after `MinContent` base sizes are calculated but before `Flex` tracks
* receive space. This ensures `Auto` tracks typically size to fit their content comfortably before
* `Flex` tracks consume the rest of the container.
*
* **Distribution Logic:**
* 1. **Growth Potential:** For each Auto track, we calculate `Potential = MaxIntrinsicSize -
* MinIntrinsicSize`.
* 2. **Proportional Allocation:**
* - If `remainingSpace` covers the total potential, all Auto tracks become their
* `MaxIntrinsicSize`.
* - If space is scarce, it is distributed **proportionally** based on potential. A track with a
* large difference between its Min and Max (e.g., a long paragraph that can wrap) receives more
* space than a track with little potential (e.g., an icon).
*
* @param autoTrackIndices The list of indices corresponding to [GridTrackSize.Auto] tracks.
* @param outSizes The array of current track sizes in pixels. **Mutated in-place.**
* - **Input:** Contains the `min-content` size (Pass 1 result).
* - **Output:** Contains the expanded size (up to `max-content`).
*
* @param maxSizes The array of pre-calculated maximum intrinsic sizes for these tracks.
* @param availableSpace The total constrained size of the container (width or height).
*/
private fun expandAutoTracks(
autoTrackIndices: MutableIntList,
outSizes: IntArray,
maxSizes: IntArray,
availableSpace: Int,
) {
if (autoTrackIndices.isEmpty()) return
// 1. Calculate how much space is currently used by all tracks (Fixed + MinContent + etc)
var usedSpace = 0
for (size in outSizes) {
usedSpace += size
}
val remainingSpace = availableSpace - usedSpace
if (remainingSpace <= 0) return
// 2. Calculate the "Growth Potential" for each auto track (Max - Min)
// We also sum the total potential to determine distribution shares.
val growthPotentials = IntArray(autoTrackIndices.size)
var totalGrowthPotential = 0
autoTrackIndices.forEachIndexed { i, trackIndex ->
val currentSize = outSizes[trackIndex]
val maxIntrinsicSize = maxSizes[trackIndex]
val potential = max(0, maxIntrinsicSize - currentSize)
growthPotentials[i] = potential
totalGrowthPotential += potential
}
// 3. If there is no potential to grow (all tracks are already at max content), exit.
if (totalGrowthPotential == 0) return
// 4. Distribute space
// If we have enough space to satisfy everyone's max potential, just set them all to max.
if (remainingSpace >= totalGrowthPotential) {
for (i in autoTrackIndices.indices) {
val trackIndex = autoTrackIndices[i]
outSizes[trackIndex] += growthPotentials[i]
}
} else {
// Otherwise, distribute proportionally based on how much each track WANTS to grow.
// This ensures a fair distribution where tracks with huge content get more space
// than tracks that only need a few more pixels.
for (i in autoTrackIndices.indices) {
val trackIndex = autoTrackIndices[i]
val share =
(growthPotentials[i].toFloat() / totalGrowthPotential * remainingSpace).roundToInt()
outSizes[trackIndex] += share
}
}
}
/**
* Measures the content of every grid item based on its resolved position and span.
*
* This function converts abstract grid coordinates (row/column indices) into concrete pixel
* constraints. It determines the exact width and height of the cell(s) an item spans and measures
* the child content against those bounds.
*
* This method calculates the span size in O(1) time using the pre-computed offset arrays: `Size =
* (End_Offset + End_Size) - Start_Offset`
*
* This function mutates the provided [gridItems] list, updating each item with its measured
* [Placeable] and calculated (x, y) offsets.
*
* @param gridItems The list of all grid items.
* @param trackSizes The calculated pixel sizes for every row and column track.
* @param layoutDirection The current layout direction.
*/
private fun measureItems(
gridItems: MutableObjectList<GridItem>,
trackSizes: GridTrackSizes,
layoutDirection: LayoutDirection,
) {
val rowCount = trackSizes.rowHeights.size
val colCount = trackSizes.columnWidths.size
gridItems.forEach { item ->
val row = item.row
val col = item.column
if (row < rowCount && col < colCount) {
var width = 0
val colLimit = (col + item.columnSpan).fastCoerceAtMost(colCount)
for (i in col until colLimit) {
width += trackSizes.columnWidths[i]
}
// Add gaps for spanned columns
val colSpanActual = colLimit - col
if (colSpanActual > 1) {
width += (colSpanActual - 1) * trackSizes.columnGapPx
}
var height = 0
val rowLimit = (row + item.rowSpan).fastCoerceAtMost(rowCount)
for (i in row until rowLimit) {
height += trackSizes.rowHeights[i]
}
// Add gaps for spanned rows
val rowSpanActual = rowLimit - row
if (rowSpanActual > 1) {
height += (rowSpanActual - 1) * trackSizes.rowGapPx
}
// Use loose constraints to allow alignment to work.
// If strict fixed constraints are used, child size == cell size, so alignment is
// ignored.
val constraints = Constraints(maxWidth = width, maxHeight = height)
val placeable = item.measurable.measure(constraints)
// Calculate Alignment Offset
val containerSize = IntSize(width, height)
val contentSize = IntSize(placeable.width, placeable.height)
val alignmentOffset =
item.alignment.align(
size = contentSize,
space = containerSize,
layoutDirection = layoutDirection,
)
item.placeable = placeable
// Alignment.align already accounts for RTL (Start = right side) relative to 0,0.
item.offsetX = alignmentOffset.x
item.offsetY = alignmentOffset.y
}
}
}
/**
* Computes the cumulative starting position (offset) for each track.
*
* This function converts a list of track sizes (e.g., column widths or row heights) into absolute
* coordinates by accumulating the size of previous tracks and the specified [gapPx] between them.
*
* Example logic:
* - Offset[0] = 0
* - Offset[1] = Size[0] + Gap
* - Offset[2] = Size[0] + Gap + Size[1] + Gap
*
* @param sizes An array containing the size of each individual track.
* @param gapPx The spacing in pixels to insert between consecutive tracks.
* @return An [IntArray] of the same length as [sizes], where index `i` contains the starting
* coordinate of that track.
*/
private fun calculateTrackOffsets(sizes: IntArray, gapPx: Int): IntArray {
val offsets = IntArray(sizes.size)
var current = 0
for (i in sizes.indices) {
offsets[i] = current
current += sizes[i] + gapPx
}
return offsets
}