From 5ab4bb712bebbced124abb69c6b6e471ab313179 Mon Sep 17 00:00:00 2001 From: Leon Omelan Date: Mon, 6 Apr 2026 16:11:01 +0200 Subject: [PATCH] performance: reduce recompositons in PersonCard and SeasonCard (#1184) ## Description related docs: https://developer.android.com/develop/ui/compose/performance/bestpractices#defer-reads Removed setting padding with animated value, instead moved to lambda offset and layout modifiers to increase performance and reduce recompositions when focusing those cards Split from #1152 ### Related issues ### Testing Tested on a emulato ## Screenshots |Component | Pre | Post | |---|--------|--------| |Person Card|Screenshot 2026-04-02 at
08 19 18 | Screenshot 2026-04-02 at 08 22
06 | |Season Card| Screenshot 2026-04-02
at 08 18 31 |Screenshot 2026-04-02 at 08 24
06 | ## AI or LLM usage Gemini in AS was used for repetitive code completion --- .../wholphin/ui/cards/PersonCard.kt | 26 ++++++++++++++----- .../wholphin/ui/cards/SeasonCard.kt | 26 ++++++++++++++----- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt index 2ce33e09..4e146134 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape @@ -25,9 +26,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Border @@ -80,7 +83,7 @@ fun PersonCard( ) { val hideOverlayDelay = 1_000L - val focused = interactionSource.collectIsFocusedAsState().value + val focused by interactionSource.collectIsFocusedAsState() var focusedAfterDelay by remember { mutableStateOf(false) } if (focused) { @@ -95,10 +98,13 @@ fun PersonCard( } else { focusedAfterDelay = false } - val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) - val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) + + // Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates + val spaceBetweenState = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween") + val spaceBelowState = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow") + Column( - verticalArrangement = Arrangement.spacedBy(spaceBetween), + verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing modifier = modifier, ) { Card( @@ -168,8 +174,16 @@ fun PersonCard( verticalArrangement = Arrangement.spacedBy(0.dp), modifier = Modifier - .padding(bottom = spaceBelow) - .fillMaxWidth(), + // Optimization: move animation reads to layout/draw phase + .offset { + IntOffset(0, (spaceBetweenState.value - 4.dp).roundToPx()) + }.layout { measurable, constraints -> + val paddingPx = spaceBelowState.value.roundToPx() + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height + paddingPx) { + placeable.placeRelative(0, 0) + } + }.fillMaxWidth(), ) { Text( text = name ?: "", diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt index b066d372..962ab727 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable @@ -18,10 +19,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults @@ -52,7 +55,7 @@ fun SeasonCard( val imageUrlService = LocalImageUrlService.current val density = LocalDensity.current val imageUrl = - remember(item, imageHeight, imageWidth) { + remember(item, imageHeight, imageWidth, density) { if (item != null) { val fillHeight = if (imageHeight != Dp.Unspecified) { @@ -125,14 +128,17 @@ fun SeasonCard( aspectRatio: Float = AspectRatios.TALL, ) { val focused by interactionSource.collectIsFocusedAsState() - val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) - val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) + // Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates + val spaceBetween = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween") + val spaceBelow = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow") + val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource) val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) val width = imageHeight * aspectRationToUse val height = imageWidth * (1f / aspectRationToUse) + Column( - verticalArrangement = Arrangement.spacedBy(spaceBetween), + verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing modifier = modifier.size(width, height), ) { Card( @@ -173,8 +179,16 @@ fun SeasonCard( verticalArrangement = Arrangement.spacedBy(0.dp), modifier = Modifier - .padding(bottom = spaceBelow) - .fillMaxWidth(), + // Optimization: move animation reads to layout/draw phase + .offset { + IntOffset(0, (spaceBetween.value - 4.dp).roundToPx()) + }.layout { measurable, constraints -> + val paddingPx = spaceBelow.value.roundToPx() + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height + paddingPx) { + placeable.placeRelative(0, 0) + } + }.fillMaxWidth(), ) { Text( text = title ?: "",