perf: replace nested Row/Column with custom NoteComposeLayout

Create a single-pass custom Layout for NoteCompose that replaces
the nested Row > Column > Column structure with direct constraint
calculation using known fixed dimensions (55dp author column, 10dp
gap, 12dp padding).

This eliminates Row's two-pass measurement (measure author first,
then content with remaining width), reduces content Column children
from ~6 to ~3, and pre-computes all pixel dimensions once.

https://claude.ai/code/session_01Fam5VHNaNkKBBafg1gRfFS
This commit is contained in:
Claude
2026-04-05 02:42:25 +00:00
parent 5349121443
commit 80de923cab
2 changed files with 274 additions and 54 deletions
@@ -0,0 +1,207 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
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.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.theme.Size55dp
private val ZeroConstraints = Constraints.fixed(0, 0)
@Immutable
private data class NoteLayoutPx(
val authorWidth: Int,
val authorContentGap: Int,
val authorBadgeGap: Int,
val contentSpacer: Int,
val padStart: Int,
val padEnd: Int,
val padTop: Int,
)
/**
* Custom single-pass layout for note items that replaces nested Row/Column
* measurements with direct constraint calculation using known fixed dimensions.
*
* Layout structure (when [showAuthorColumn] is true):
* ```
* ┌──────────────────────────────────────────────┐
* │ padding (start=12, end=12, top=10) │
* │ ┌──────┐ 10dp ┌────────────────────────────┐ │
* │ │author│ gap │ firstRow │ │
* │ │ 55dp │ │ secondRow (optional) │ │
* │ │ │ │ 4dp spacer (optional) │ │
* │ ├──────┤ 5dp │ noteContent │ │
* │ │relay │ gap │ │ │
* │ │badges│ │ │ │
* │ └──────┘ └────────────────────────────┘ │
* ├───────────────────────────────────────────────┤
* │ reactionsRow (full width, no side padding) │
* └───────────────────────────────────────────────┘
* ```
*
* Performance benefits over nested Row/Column:
* - Eliminates Row's two-pass measurement (measure author, then content with remaining width)
* - Uses pre-computed pixel dimensions for author column width (55dp) and gaps
* - Reduces content Column children from ~6 to ~3
* - Single measure pass positions all sections with known constraints
*
* @param addPadding Whether to add standard note padding (12dp sides, 10dp top)
* @param showAuthorColumn Whether to reserve space for the author picture column
* @param showSecondRow Whether to measure and place the second info row
* @param showContentSpacer Whether to add a 4dp spacer between header rows and content
*/
@Composable
fun NoteComposeLayout(
modifier: Modifier = Modifier,
addPadding: Boolean = true,
showAuthorColumn: Boolean = true,
showSecondRow: Boolean = false,
showContentSpacer: Boolean = true,
authorPicture: @Composable () -> Unit,
relayBadges: @Composable () -> Unit,
firstRow: @Composable () -> Unit,
secondRow: @Composable () -> Unit,
noteContent: @Composable () -> Unit,
reactionsRow: @Composable () -> Unit,
) {
val density = LocalDensity.current
val px =
remember(density) {
with(density) {
NoteLayoutPx(
authorWidth = Size55dp.roundToPx(),
authorContentGap = 10.dp.roundToPx(),
authorBadgeGap = 5.dp.roundToPx(),
contentSpacer = 4.dp.roundToPx(),
padStart = 12.dp.roundToPx(),
padEnd = 12.dp.roundToPx(),
padTop = 10.dp.roundToPx(),
)
}
}
Layout(
content = {
// Slot 0: Author picture (55x55 area)
Box { authorPicture() }
// Slot 1: Relay badges (55dp wide, below author)
Box { relayBadges() }
// Slot 2: First row (author name, time, options)
Box { firstRow() }
// Slot 3: Second row (NIP05, location, PoW)
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,
) { measurables, constraints ->
val maxWidth = constraints.maxWidth
// Compute padding offsets
val padStart = if (addPadding) px.padStart else 0
val padEnd = if (addPadding) px.padEnd else 0
val padTop = if (addPadding) px.padTop else 0
// Compute column widths from known dimensions
val innerWidth = maxWidth - padStart - padEnd
val authorW = if (showAuthorColumn) px.authorWidth else 0
val gap = if (showAuthorColumn) px.authorContentGap else 0
val contentWidth = (innerWidth - authorW - gap).coerceAtLeast(0)
// Build constraints for each section
val authorConstraints =
if (showAuthorColumn) Constraints(maxWidth = px.authorWidth) else ZeroConstraints
val contentConstraints = Constraints(maxWidth = contentWidth)
// Measure all children in a single pass with pre-computed constraints
val authorPlaceable = measurables[0].measure(authorConstraints)
val relayPlaceable = measurables[1].measure(authorConstraints)
val firstRowPlaceable = measurables[2].measure(contentConstraints)
val secondRowPlaceable =
measurables[3].measure(
if (showSecondRow) contentConstraints else ZeroConstraints,
)
val contentPlaceable = measurables[4].measure(contentConstraints)
val reactionsPlaceable =
measurables[5].measure(Constraints(maxWidth = maxWidth))
// Calculate section heights
val spacer = if (showContentSpacer) px.contentSpacer else 0
val contentColumnHeight =
firstRowPlaceable.height +
(if (showSecondRow) secondRowPlaceable.height else 0) +
spacer +
contentPlaceable.height
val authorColumnHeight =
if (showAuthorColumn) {
authorPlaceable.height + px.authorBadgeGap + relayPlaceable.height
} else {
0
}
val mainAreaHeight = maxOf(authorColumnHeight, contentColumnHeight)
val totalHeight = padTop + mainAreaHeight + reactionsPlaceable.height
layout(maxWidth, totalHeight.coerceAtLeast(constraints.minHeight)) {
// Place author column (inside padding)
if (showAuthorColumn) {
authorPlaceable.placeRelative(padStart, padTop)
relayPlaceable.placeRelative(
padStart,
padTop + authorPlaceable.height + px.authorBadgeGap,
)
}
// Place content column (to the right of author, inside padding)
val contentX = padStart + authorW + gap
var y = padTop
firstRowPlaceable.placeRelative(contentX, y)
y += firstRowPlaceable.height
if (showSecondRow) {
secondRowPlaceable.placeRelative(contentX, y)
y += secondRowPlaceable.height
}
y += spacer
contentPlaceable.placeRelative(contentX, y)
// Place reactions row at full width, below main area
reactionsPlaceable.placeRelative(0, padTop + mainAreaHeight)
}
}
}
@@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.layouts.GenericRepostLayout
import com.vitorpamplona.amethyst.ui.layouts.NoteComposeLayout
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
@@ -178,8 +179,6 @@ import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing10dp
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size30dp
@@ -189,12 +188,9 @@ import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.UserNameMaxRowHeight
import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight
import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier
import com.vitorpamplona.amethyst.ui.theme.boostedNoteModifier
import com.vitorpamplona.amethyst.ui.theme.channelNotePictureModifier
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.normalWithTopMarginNoteModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
@@ -632,74 +628,91 @@ fun InnerNoteWithReactions(
) {
val notBoostedNorQuote = !isBoostedNote && !isQuotedNote
val editState = observeEdits(baseNote = baseNote, accountViewModel = accountViewModel)
val isNotRepost = baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent
val showSecondRow = isNotRepost && notBoostedNorQuote && accountViewModel.settings.isCompleteUIMode()
Row(
modifier =
if (!isBoostedNote) {
normalWithTopMarginNoteModifier
} else {
boostedNoteModifier
},
horizontalArrangement = RowColSpacing10dp,
) {
if (notBoostedNorQuote) {
Column(WidthAuthorPictureModifier, verticalArrangement = RowColSpacing5dp) {
// Draws the boosted picture outside the boosted card.
NoteComposeLayout(
modifier = Modifier.fillMaxWidth(),
addPadding = !isBoostedNote,
showAuthorColumn = notBoostedNorQuote,
showSecondRow = showSecondRow,
showContentSpacer = isNotRepost,
authorPicture = {
if (notBoostedNorQuote) {
Box(modifier = Size55Modifier, contentAlignment = Alignment.BottomEnd) {
RenderAuthorImages(baseNote, nav, accountViewModel)
}
}
},
relayBadges = {
if (notBoostedNorQuote) {
BadgeBox(baseNote, accountViewModel, nav)
}
}
Column(Modifier.fillMaxWidth()) {
val showSecondRow =
baseNote.event !is RepostEvent &&
baseNote.event !is GenericRepostEvent &&
!isBoostedNote &&
!isQuotedNote &&
accountViewModel.settings.isCompleteUIMode()
NoteBody(
},
firstRow = {
FirstUserInfoRow(
baseNote = baseNote,
showAuthorPicture = isQuotedNote,
unPackReply = unPackReply,
makeItShort = makeItShort,
canPreview = canPreview,
showSecondRow = showSecondRow,
isPinned = isPinned,
quotesLeft = quotesLeft,
backgroundColor = backgroundColor,
editState = editState,
accountViewModel = accountViewModel,
nav = nav,
moreOptions = moreOptions,
)
RenderApprovalIfNeeded(baseNote, accountViewModel, nav)
}
}
val isNotRepost = baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent && baseNote.event !is DraftWrapEvent
if (isNotRepost) {
if (makeItShort) {
Spacer(modifier = DoubleVertSpacer)
} else {
ReactionsRow(
},
secondRow = {
if (showSecondRow) {
SecondUserInfoRow(
baseNote,
editState,
accountViewModel,
nav,
)
}
},
noteContent = {
RenderNoteRow(
baseNote = baseNote,
showReactionDetail = notBoostedNorQuote,
addPadding = !isBoostedNote,
backgroundColor = backgroundColor,
makeItShort = makeItShort,
canPreview = canPreview,
editState = editState,
quotesLeft = quotesLeft,
unPackReply = unPackReply,
accountViewModel = accountViewModel,
nav = nav,
)
}
} else {
if (baseNote.event is DraftWrapEvent) {
Spacer(modifier = DoubleVertSpacer)
}
}
if (!makeItShort) {
val noteEvent = baseNote.event
val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false }
if (zapSplits && noteEvent != null) {
Spacer(modifier = HalfDoubleVertSpacer)
DisplayZapSplits(noteEvent, false, accountViewModel, nav)
}
}
RenderApprovalIfNeeded(baseNote, accountViewModel, nav)
},
reactionsRow = {
if (isNotRepost && baseNote.event !is DraftWrapEvent) {
if (makeItShort) {
Spacer(modifier = DoubleVertSpacer)
} else {
ReactionsRow(
baseNote = baseNote,
showReactionDetail = notBoostedNorQuote,
addPadding = !isBoostedNote,
editState = editState,
accountViewModel = accountViewModel,
nav = nav,
)
}
} else if (baseNote.event is DraftWrapEvent) {
Spacer(modifier = DoubleVertSpacer)
}
},
)
}
@Composable