Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion androidApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ dependencies {
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.tooling)
implementation(libs.androidx.compose.toolingPreview)
implementation(libs.ktor.client.cio)
debugImplementation(libs.androidx.compose.tooling)
}

Large diffs are not rendered by default.

44 changes: 35 additions & 9 deletions aspecto/src/commonMain/kotlin/com/vipulasri/aspecto/AspectoGrid.kt
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,19 @@ import androidx.compose.ui.unit.dp
* padding value is used (consistent with [Arrangement.spacedBy]). For vertical spacing between
* rows, only the top padding value is used. Asymmetric start/end or top/bottom values are
* supported but only the start/top side is applied to inter-item and inter-row spacing.
* @param decorations Full-width row decorations to insert at specific row positions. Each
* decoration is rendered at the full available width before the regular row at its specified index.
* Useful for inserting ads, headers, footers, or loaders between grid rows.
* @param content The grid content using [AspectoLayoutScope]
*
* Example usage:
* ```
* AspectoGrid(
* modifier = Modifier.fillMaxWidth(),
* contentPadding = PaddingValues(8.dp)
* contentPadding = PaddingValues(8.dp),
* decorations = listOf(
* RowDecoration(index = 3, key = "ad-1") { AdBanner() }
* )
* ) {
* items(
* items = imageList,
Expand All @@ -89,6 +95,7 @@ fun AspectoGrid(
contentPadding: PaddingValues = PaddingValues(0.dp),
maxRowHeight: Dp = DEFAULT_MAX_ROW_HEIGHT_PX.dp,
itemPadding: PaddingValues = PaddingValues(0.dp),
decorations: List<RowDecoration> = emptyList(),
content: AspectoLayoutScope.() -> Unit
) {
val scope = AspectoLayoutScope().apply(content)
Expand Down Expand Up @@ -118,9 +125,13 @@ fun AspectoGrid(
items = scope.items,
availableWidth = availableWidth,
maxRowHeight = maxRowHeightPx,
horizontalPadding = horizontalPaddingPx
horizontalPadding = horizontalPaddingPx,
decorations = decorations
)

// Build a lookup map for decoration content by key
val decorationMap = decorations.associateBy { it.key }

LazyColumn(
state = state,
contentPadding = contentPadding,
Expand All @@ -129,14 +140,29 @@ fun AspectoGrid(
items(
items = rows,
key = { row -> row.key },
contentType = { row -> row.items.firstOrNull()?.contentType }
contentType = { row ->
if (row.isFullWidth) {
decorationMap[row.key]?.contentType
} else {
row.items.firstOrNull()?.contentType
}
}
) { row ->
AspectoRow(
row = row,
density = density,
itemPadding = itemPadding,
layoutDirection = layoutDirection
)
if (row.isFullWidth) {
val decoration = decorationMap[row.key]
if (decoration != null) {
Box(modifier = Modifier.fillMaxWidth()) {
decoration.content()
}
}
} else {
AspectoRow(
row = row,
density = density,
itemPadding = itemPadding,
layoutDirection = layoutDirection
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,26 @@ data class AspectoLayoutInfo(
val height: Int = 0
)

/**
* Represents a full-width row decoration inserted at a specific row position in the grid.
*
* Decorations are inserted before the regular row at the given [index]. For example, a decoration
* at `index = 3` appears between regular row 2 and regular row 3.
*
* @param index Row position (0-based) to insert this decoration before
* @param key Unique identifier for this decoration row, used for lazy list identity.
* Must not collide with keys from grid items.
* @param contentType Type of content for recomposition optimization (optional)
* @param content Composable content to be displayed at full width
*/
@Stable
data class RowDecoration(
val index: Int,
val key: Any,
val contentType: Any? = null,
val content: @Composable () -> Unit
)

/**
* Scope for building grid content with aspect ratio-based items.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,12 @@ import androidx.compose.runtime.Stable
* @param key Stable identity for the row, derived from the first item's [AspectoLayoutInfo.key]
* (or its index when no key is provided). Used as the lazy list item key so that appended items do
* not invalidate existing rows.
* @param isFullWidth Whether this row is a full-width decoration row (not part of the
* aspect-ratio grid). Full-width rows render their content at the full available width.
*/
@Stable
internal data class AspectoRow(
val items: List<AspectoLayoutInfo> = emptyList(),
val key: Any
val key: Any,
val isFullWidth: Boolean = false
)
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,16 @@ internal const val DEFAULT_MAX_ROW_HEIGHT_PX = 600
* @param availableWidth Available width for the rows, in pixels.
* @param maxRowHeight Maximum allowed height for any row, in pixels.
* @param horizontalPadding Spacing between items within a row, in pixels.
* @param decorations Full-width row decorations to insert at specific row positions.
* Each decoration is inserted before the regular row at its [RowDecoration.index].
* Sorted by index internally; empty list means no decorations.
*/
internal fun calculateRows(
items: List<AspectoLayoutInfo>,
availableWidth: Int,
maxRowHeight: Int = DEFAULT_MAX_ROW_HEIGHT_PX,
horizontalPadding: Int = 0
horizontalPadding: Int = 0,
decorations: List<RowDecoration> = emptyList()
): List<AspectoRow> {
val minRowHeight = (maxRowHeight * 0.5f).toInt()
val rows = ArrayList<AspectoRow>(items.size / 2 + 1)
Expand Down Expand Up @@ -76,7 +80,7 @@ internal fun calculateRows(
currentIndex = rowConfig.endIndex
}

return rows
return spliceDecorations(rows, decorations, rowKeys)
}

private fun rowKey(
Expand Down Expand Up @@ -221,4 +225,69 @@ private fun calculateRowHeight(
}

return rowHeight.coerceIn(minRowHeight.toFloat(), maxRowHeight.toFloat())
}

/**
* Inserts [RowDecoration] rows into the computed regular rows at the specified positions.
*
* Each decoration is placed before the regular row at its [RowDecoration.index]. Decorations
* with indices beyond the last regular row are appended at the end.
*
* @param rows The computed regular rows.
* @param decorations The decorations to splice in, sorted by [RowDecoration.index].
* @param existingKeys The set of keys already used by regular rows, used to detect collisions.
* @return A new list with decorations inserted at the correct positions.
*/
private fun spliceDecorations(
rows: List<AspectoRow>,
decorations: List<RowDecoration>,
existingKeys: HashSet<Any>
): List<AspectoRow> {
if (decorations.isEmpty()) return rows

val sorted = decorations.sortedBy { it.index }
val result = ArrayList<AspectoRow>(rows.size + sorted.size)
var regularRowCounter = 0
var decorationIdx = 0

for (row in rows) {
while (decorationIdx < sorted.size && sorted[decorationIdx].index == regularRowCounter) {
val decKey = sorted[decorationIdx].key
check(existingKeys.add(decKey)) {
"Duplicate row key '$decKey' detected for decoration at index " +
"${sorted[decorationIdx].index}. Decoration keys must be unique and must " +
"not collide with item-derived row keys."
}
result.add(
AspectoRow(
items = emptyList(),
key = decKey,
isFullWidth = true
)
)
decorationIdx++
}
result.add(row)
regularRowCounter++
}

// Append remaining decorations (index >= regular row count)
while (decorationIdx < sorted.size) {
val decKey = sorted[decorationIdx].key
check(existingKeys.add(decKey)) {
"Duplicate row key '$decKey' detected for decoration at index " +
"${sorted[decorationIdx].index}. Decoration keys must be unique and must " +
"not collide with item-derived row keys."
}
result.add(
AspectoRow(
items = emptyList(),
key = decKey,
isFullWidth = true
)
)
decorationIdx++
}

return result
}
Loading