From 80de923cab7fd684f83bf09908e1490e529ca39d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 02:42:25 +0000 Subject: [PATCH] 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 --- .../amethyst/ui/layouts/NoteComposeLayout.kt | 207 ++++++++++++++++++ .../amethyst/ui/note/NoteCompose.kt | 121 +++++----- 2 files changed, 274 insertions(+), 54 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt new file mode 100644 index 000000000..f44dcf2bb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt @@ -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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 320033df7..4559985e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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