perf: use multi-content Layout to eliminate all wrapper nodes

Replace Box/Column wrappers with the multi-content Layout overload
(contents: List) which makes each slot's composables direct
measurables. This removes 6 intermediate layout nodes per note item.

Multi-child slots (noteContent, reactionsRow) are now measured and
stacked inline using arrayOfNulls<Placeable> with zero-allocation
iteration, avoiding Column's internal measure pass entirely.

https://claude.ai/code/session_01Fam5VHNaNkKBBafg1gRfFS
This commit is contained in:
Claude
2026-04-05 02:49:42 +00:00
parent 80de923cab
commit 350873b7eb
@@ -20,14 +20,12 @@
*/ */
package com.vitorpamplona.amethyst.ui.layouts package com.vitorpamplona.amethyst.ui.layouts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -47,8 +45,10 @@ private data class NoteLayoutPx(
) )
/** /**
* Custom single-pass layout for note items that replaces nested Row/Column * Custom zero-overhead layout for note items. Uses the multi-content [Layout]
* measurements with direct constraint calculation using known fixed dimensions. * overload to eliminate ALL intermediate wrapper nodes (Box/Column). Each slot's
* composables become direct measurables measured with pre-computed constraints
* in a single pass.
* *
* Layout structure (when [showAuthorColumn] is true): * Layout structure (when [showAuthorColumn] is true):
* ``` * ```
@@ -67,11 +67,12 @@ private data class NoteLayoutPx(
* └───────────────────────────────────────────────┘ * └───────────────────────────────────────────────┘
* ``` * ```
* *
* Performance benefits over nested Row/Column: * Performance characteristics:
* - Eliminates Row's two-pass measurement (measure author, then content with remaining width) * - Zero intermediate layout nodes (no Box/Column wrappers)
* - Uses pre-computed pixel dimensions for author column width (55dp) and gaps * - Pre-computed pixel dimensions cached across recompositions
* - Reduces content Column children from ~6 to ~3 * - Single measure pass with known constraints for all 6 slots
* - Single measure pass positions all sections with known constraints * - Multi-child slots (noteContent, reactionsRow) stacked inline
* - Hidden slots skip measurement entirely (empty measurable lists)
* *
* @param addPadding Whether to add standard note padding (12dp sides, 10dp top) * @param addPadding Whether to add standard note padding (12dp sides, 10dp top)
* @param showAuthorColumn Whether to reserve space for the author picture column * @param showAuthorColumn Whether to reserve space for the author picture column
@@ -109,22 +110,17 @@ fun NoteComposeLayout(
} }
Layout( Layout(
content = { contents =
// Slot 0: Author picture (55x55 area) listOf(
Box { authorPicture() } authorPicture,
// Slot 1: Relay badges (55dp wide, below author) relayBadges,
Box { relayBadges() } firstRow,
// Slot 2: First row (author name, time, options) secondRow,
Box { firstRow() } noteContent,
// Slot 3: Second row (NIP05, location, PoW) reactionsRow,
Box { secondRow() } ),
// Slot 4: Note content (event-specific + zap splits + approval)
Column(Modifier.fillMaxWidth()) { noteContent() }
// Slot 5: Reactions row (full width)
Column(Modifier.fillMaxWidth()) { reactionsRow() }
},
modifier = modifier, modifier = modifier,
) { measurables, constraints -> ) { allMeasurables, constraints ->
val maxWidth = constraints.maxWidth val maxWidth = constraints.maxWidth
// Compute padding offsets // Compute padding offsets
@@ -138,70 +134,94 @@ fun NoteComposeLayout(
val gap = if (showAuthorColumn) px.authorContentGap else 0 val gap = if (showAuthorColumn) px.authorContentGap else 0
val contentWidth = (innerWidth - authorW - gap).coerceAtLeast(0) val contentWidth = (innerWidth - authorW - gap).coerceAtLeast(0)
// Build constraints for each section // Pre-compute constraint objects once
val authorConstraints = val authorConstraints = Constraints(maxWidth = px.authorWidth)
if (showAuthorColumn) Constraints(maxWidth = px.authorWidth) else ZeroConstraints
val contentConstraints = Constraints(maxWidth = contentWidth) val contentConstraints = Constraints(maxWidth = contentWidth)
val fullWidthConstraints = Constraints(maxWidth = maxWidth)
// Measure all children in a single pass with pre-computed constraints // Measure author column (single child per slot, skip if empty)
val authorPlaceable = measurables[0].measure(authorConstraints) val authorPlaceable = allMeasurables[0].firstOrNull()?.measure(authorConstraints)
val relayPlaceable = measurables[1].measure(authorConstraints) val relayPlaceable = allMeasurables[1].firstOrNull()?.measure(authorConstraints)
val firstRowPlaceable = measurables[2].measure(contentConstraints)
// Measure header rows
val firstRowPlaceable = allMeasurables[2].firstOrNull()?.measure(contentConstraints)
val secondRowPlaceable = val secondRowPlaceable =
measurables[3].measure( if (showSecondRow) allMeasurables[3].firstOrNull()?.measure(contentConstraints) else null
if (showSecondRow) contentConstraints else ZeroConstraints,
) // Measure note content children directly (no Column wrapper)
val contentPlaceable = measurables[4].measure(contentConstraints) val contentMeasurables = allMeasurables[4]
val reactionsPlaceable = val contentPlaceables = arrayOfNulls<Placeable>(contentMeasurables.size)
measurables[5].measure(Constraints(maxWidth = maxWidth)) var contentStackHeight = 0
for (i in contentMeasurables.indices) {
val placeable = contentMeasurables[i].measure(contentConstraints)
contentPlaceables[i] = placeable
contentStackHeight += placeable.height
}
// Measure reactions row children directly (no Column wrapper)
val reactionsMeasurables = allMeasurables[5]
val reactionsPlaceables = arrayOfNulls<Placeable>(reactionsMeasurables.size)
var reactionsHeight = 0
for (i in reactionsMeasurables.indices) {
val placeable = reactionsMeasurables[i].measure(fullWidthConstraints)
reactionsPlaceables[i] = placeable
reactionsHeight += placeable.height
}
// Calculate section heights // Calculate section heights
val spacer = if (showContentSpacer) px.contentSpacer else 0 val spacer = if (showContentSpacer) px.contentSpacer else 0
val contentColumnHeight = val contentColumnHeight =
firstRowPlaceable.height + (firstRowPlaceable?.height ?: 0) +
(if (showSecondRow) secondRowPlaceable.height else 0) + (secondRowPlaceable?.height ?: 0) +
spacer + spacer +
contentPlaceable.height contentStackHeight
val authorColumnHeight = val authorColumnHeight =
if (showAuthorColumn) { if (showAuthorColumn && authorPlaceable != null) {
authorPlaceable.height + px.authorBadgeGap + relayPlaceable.height authorPlaceable.height + px.authorBadgeGap + (relayPlaceable?.height ?: 0)
} else { } else {
0 0
} }
val mainAreaHeight = maxOf(authorColumnHeight, contentColumnHeight) val mainAreaHeight = maxOf(authorColumnHeight, contentColumnHeight)
val totalHeight = padTop + mainAreaHeight + reactionsPlaceable.height val totalHeight = padTop + mainAreaHeight + reactionsHeight
layout(maxWidth, totalHeight.coerceAtLeast(constraints.minHeight)) { layout(maxWidth, totalHeight.coerceAtLeast(constraints.minHeight)) {
// Place author column (inside padding) // Place author column
if (showAuthorColumn) { if (showAuthorColumn) {
authorPlaceable.placeRelative(padStart, padTop) authorPlaceable?.placeRelative(padStart, padTop)
relayPlaceable.placeRelative( relayPlaceable?.placeRelative(
padStart, padStart,
padTop + authorPlaceable.height + px.authorBadgeGap, padTop + (authorPlaceable?.height ?: 0) + px.authorBadgeGap,
) )
} }
// Place content column (to the right of author, inside padding) // Place content column
val contentX = padStart + authorW + gap val contentX = padStart + authorW + gap
var y = padTop var y = padTop
firstRowPlaceable.placeRelative(contentX, y) firstRowPlaceable?.placeRelative(contentX, y)
y += firstRowPlaceable.height y += firstRowPlaceable?.height ?: 0
if (showSecondRow) { if (showSecondRow) {
secondRowPlaceable.placeRelative(contentX, y) secondRowPlaceable?.placeRelative(contentX, y)
y += secondRowPlaceable.height y += secondRowPlaceable?.height ?: 0
} }
y += spacer y += spacer
contentPlaceable.placeRelative(contentX, y) for (placeable in contentPlaceables) {
placeable?.placeRelative(contentX, y)
y += placeable?.height ?: 0
}
// Place reactions row at full width, below main area // Place reactions row at full width, below main area
reactionsPlaceable.placeRelative(0, padTop + mainAreaHeight) var reactionsY = padTop + mainAreaHeight
for (placeable in reactionsPlaceables) {
placeable?.placeRelative(0, reactionsY)
reactionsY += placeable?.height ?: 0
}
} }
} }
} }