performance: reduce recompositons in PersonCard and SeasonCard (#1184)

<!-- By submitting this pull request, you acknowledge that you have read
the [contributing
guide](https://github.com/damontecres/Wholphin/blob/main/CONTRIBUTING.md,
including the AI/LLM policy, and [developer's
guide](https://github.com/damontecres/Wholphin/blob/main/DEVELOPMENT.md)
-->

## Description
<!-- Describe the changes in detail -->
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
<!-- If this is a new feature or a change, there must be a discussion in
an issue first, reference the issue here -->
<!-- If fixing a bug, reference the bug issue here, or describe the bug,
including steps to reproduce -->

### Testing
<!-- Describe how this change was tested and on what device(s) -->
Tested on a emulato
## Screenshots
<!-- Please include screenshots if the PR alters any UI elements -->
|Component | Pre | Post |
|---|--------|--------|
|Person Card|<img width="404" height="366" alt="Screenshot 2026-04-02 at
08 19 18"
src="https://github.com/user-attachments/assets/73b38c75-040b-401e-88d6-4bfc9ffc0cc9"
/> | <img width="407" height="346" alt="Screenshot 2026-04-02 at 08 22
06"
src="https://github.com/user-attachments/assets/add473f7-dea6-4e33-97b8-6954149a81e2"
/> |
|Season Card| <img width="404" height="341" alt="Screenshot 2026-04-02
at 08 18 31"
src="https://github.com/user-attachments/assets/eec594ee-6701-4f99-8fb3-339ed8559ec4"
/> |<img width="408" height="299" alt="Screenshot 2026-04-02 at 08 24
06"
src="https://github.com/user-attachments/assets/8de445a6-dcb8-4c7e-a5f6-a25bf822c822"
/> |



## AI or LLM usage
<!-- If you used any AI or LLM assistance, please list where in the code
and how you tested it -->
Gemini in AS was used for repetitive code completion
This commit is contained in:
Leon Omelan 2026-04-06 16:11:01 +02:00 committed by GitHub
parent 44d1ac484f
commit 5ab4bb712b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 40 additions and 12 deletions

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape 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.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Border import androidx.tv.material3.Border
@ -80,7 +83,7 @@ fun PersonCard(
) { ) {
val hideOverlayDelay = 1_000L val hideOverlayDelay = 1_000L
val focused = interactionSource.collectIsFocusedAsState().value val focused by interactionSource.collectIsFocusedAsState()
var focusedAfterDelay by remember { mutableStateOf(false) } var focusedAfterDelay by remember { mutableStateOf(false) }
if (focused) { if (focused) {
@ -95,10 +98,13 @@ fun PersonCard(
} else { } else {
focusedAfterDelay = false 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( Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween), verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing
modifier = modifier, modifier = modifier,
) { ) {
Card( Card(
@ -168,8 +174,16 @@ fun PersonCard(
verticalArrangement = Arrangement.spacedBy(0.dp), verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = modifier =
Modifier Modifier
.padding(bottom = spaceBelow) // Optimization: move animation reads to layout/draw phase
.fillMaxWidth(), .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(
text = name ?: "", text = name ?: "",

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -18,10 +19,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults import androidx.tv.material3.CardDefaults
@ -52,7 +55,7 @@ fun SeasonCard(
val imageUrlService = LocalImageUrlService.current val imageUrlService = LocalImageUrlService.current
val density = LocalDensity.current val density = LocalDensity.current
val imageUrl = val imageUrl =
remember(item, imageHeight, imageWidth) { remember(item, imageHeight, imageWidth, density) {
if (item != null) { if (item != null) {
val fillHeight = val fillHeight =
if (imageHeight != Dp.Unspecified) { if (imageHeight != Dp.Unspecified) {
@ -125,14 +128,17 @@ fun SeasonCard(
aspectRatio: Float = AspectRatios.TALL, aspectRatio: Float = AspectRatios.TALL,
) { ) {
val focused by interactionSource.collectIsFocusedAsState() val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) // Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) 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 focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
val width = imageHeight * aspectRationToUse val width = imageHeight * aspectRationToUse
val height = imageWidth * (1f / aspectRationToUse) val height = imageWidth * (1f / aspectRationToUse)
Column( Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween), verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing
modifier = modifier.size(width, height), modifier = modifier.size(width, height),
) { ) {
Card( Card(
@ -173,8 +179,16 @@ fun SeasonCard(
verticalArrangement = Arrangement.spacedBy(0.dp), verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = modifier =
Modifier Modifier
.padding(bottom = spaceBelow) // Optimization: move animation reads to layout/draw phase
.fillMaxWidth(), .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(
text = title ?: "", text = title ?: "",