From 83e5a44f37ef8ff2e3767b108332b82b27b81936 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:07:22 -0500 Subject: [PATCH] Fixes glitches when scrolling on the home page (#882) ## Description Fixes the possible glitch on some devices when scrolling left-right on rows on the home page ### Related issues Fixes #367 Fixes #872 ### Testing Emulator & shield 2019 ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/discover/SeerrDiscoverPage.kt | 61 ++-- .../damontecres/wholphin/ui/main/HomePage.kt | 283 ++++++++++-------- .../ui/util/ScrollToTopBringIntoViewSpec.kt | 44 +++ 3 files changed, 236 insertions(+), 152 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/util/ScrollToTopBringIntoViewSpec.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index 691b00cb..97b974ff 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -1,6 +1,8 @@ package com.github.damontecres.wholphin.ui.discover import android.content.Context +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -11,6 +13,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -23,6 +26,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -47,6 +51,7 @@ import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.util.DataLoadingState import com.google.common.cache.CacheBuilder import dagger.hilt.android.lifecycle.HiltViewModel @@ -186,6 +191,7 @@ data class DiscoverState( val upcomingTv: DiscoverRowData = DiscoverRowData.EMPTY, ) +@OptIn(ExperimentalFoundationApi::class) @Composable fun SeerrDiscoverPage( preferences: UserPreferences, @@ -249,28 +255,41 @@ fun SeerrDiscoverPage( .padding(top = 24.dp, bottom = 16.dp, start = 32.dp) .fillMaxHeight(.25f), ) - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp), - modifier = - Modifier - .focusRestorer() - .fillMaxSize(), + val density = LocalDensity.current + val spaceAbovePx = + with(density) { + // The size of the row titles & spacing + 50.dp.toPx() + } + val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current + CompositionLocalProvider( + LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx), ) { - itemsIndexed(rows) { rowIndex, row -> - DiscoverRow( - row = row, - onClickItem = { index, item -> - position = RowColumn(rowIndex, index) - viewModel.navigationManager.navigateTo(item.destination) - }, - onLongClickItem = { index, item -> }, - onCardFocus = { index -> position = RowColumn(rowIndex, index) }, - focusRequester = focusRequesters[rowIndex], - modifier = - Modifier - .fillMaxWidth(), - ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp), + modifier = + Modifier + .focusRestorer() + .fillMaxSize(), + ) { + itemsIndexed(rows) { rowIndex, row -> + CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { + DiscoverRow( + row = row, + onClickItem = { index, item -> + position = RowColumn(rowIndex, index) + viewModel.navigationManager.navigateTo(item.destination) + }, + onLongClickItem = { index, item -> }, + onCardFocus = { index -> position = RowColumn(rowIndex, index) }, + focusRequester = focusRequesters[rowIndex], + modifier = + Modifier + .fillMaxWidth(), + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index cfb13ff7..e41acd1b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -1,8 +1,10 @@ package com.github.damontecres.wholphin.ui.main +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,6 +21,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -34,6 +37,7 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -69,6 +73,7 @@ import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.delay @@ -183,6 +188,7 @@ fun HomePage( } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun HomePageContent( homeRows: List, @@ -223,14 +229,15 @@ fun HomePageContent( } } } - LaunchedEffect(position) { - if (position.row >= 0) { - listState.animateScrollToItem(position.row) - } - } LaunchedEffect(onUpdateBackdrop, focusedItem) { focusedItem?.let { onUpdateBackdrop.invoke(it) } } + val density = LocalDensity.current + val spaceAbovePx = + with(density) { + // The size of the row titles & spacing + 50.dp.toPx() + } Box(modifier = modifier) { Column(modifier = Modifier.fillMaxSize()) { HomePageHeader( @@ -240,134 +247,148 @@ fun HomePageContent( .padding(top = 48.dp, bottom = 32.dp, start = 8.dp) .fillMaxHeight(.33f), ) - LazyColumn( - state = listState, - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = - PaddingValues( - bottom = Cards.height2x3, - ), - modifier = - Modifier - .focusRestorer(), + val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current + CompositionLocalProvider( + LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx), ) { - itemsIndexed(homeRows) { rowIndex, row -> - when (val r = row) { - is HomeRowLoadingState.Loading, - is HomeRowLoadingState.Pending, - -> { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.animateItem(), - ) { - Text( - text = r.title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = stringResource(R.string.loading), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - } - } - - is HomeRowLoadingState.Error -> { - var focused by remember { mutableStateOf(false) } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .onFocusChanged { - focused = it.isFocused - }.focusable() - .background( - if (focused) { - // Just so the user can tell it has focus - MaterialTheme.colorScheme.border.copy(alpha = .25f) - } else { - Color.Unspecified - }, - ).animateItem(), - ) { - Text( - text = r.title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = r.localizedMessage, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.error, - ) - } - } - - is HomeRowLoadingState.Success -> { - if (row.items.isNotEmpty()) { - ItemRow( - title = row.title, - items = row.items, - onClickItem = { index, item -> - onClickItem.invoke(RowColumn(rowIndex, index), item) - }, - onLongClickItem = { index, item -> - onLongClickItem.invoke(RowColumn(rowIndex, index), item) - }, - modifier = - Modifier - .fillMaxWidth() - .focusGroup() - .focusRequester(rowFocusRequesters[rowIndex]) - .animateItem(), - cardContent = { index, item, cardModifier, onClick, onLongClick -> - BannerCard( - name = item?.data?.seriesName ?: item?.name, - item = item, - aspectRatio = AspectRatios.TALL, - cornerText = item?.ui?.episodeUnplayedCornerText, - played = item?.data?.userData?.played ?: false, - favorite = item?.favorite ?: false, - playPercent = - item?.data?.userData?.playedPercentage - ?: 0.0, - onClick = onClick, - onLongClick = onLongClick, - modifier = - cardModifier - .onFocusChanged { - if (it.isFocused) { - position = RowColumn(rowIndex, index) -// item?.let(onUpdateBackdrop) - } - if (it.isFocused && onFocusPosition != null) { - val nonEmptyRowBefore = - homeRows - .subList(0, rowIndex) - .count { - it is HomeRowLoadingState.Success && it.items.isEmpty() - } - onFocusPosition.invoke( - RowColumn( - rowIndex - nonEmptyRowBefore, - index, - ), - ) - } - }.onKeyEvent { - if (isPlayKeyUp(it) && item?.type?.playable == true) { - Timber.v("Clicked play on ${item.id}") - onClickPlay.invoke(position, item) - return@onKeyEvent true - } - return@onKeyEvent false - }, - interactionSource = null, - cardHeight = Cards.height2x3, + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = + PaddingValues( + bottom = Cards.height2x3, + ), + modifier = + Modifier + .focusRestorer(), + ) { + itemsIndexed(homeRows) { rowIndex, row -> + CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { + when (val r = row) { + is HomeRowLoadingState.Loading, + is HomeRowLoadingState.Pending, + -> { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.animateItem(), + ) { + Text( + text = r.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, ) - }, - ) + Text( + text = stringResource(R.string.loading), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + + is HomeRowLoadingState.Error -> { + var focused by remember { mutableStateOf(false) } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .onFocusChanged { + focused = it.isFocused + }.focusable() + .background( + if (focused) { + // Just so the user can tell it has focus + MaterialTheme.colorScheme.border.copy(alpha = .25f) + } else { + Color.Unspecified + }, + ).animateItem(), + ) { + Text( + text = r.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = r.localizedMessage, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + + is HomeRowLoadingState.Success -> { + if (row.items.isNotEmpty()) { + ItemRow( + title = row.title, + items = row.items, + onClickItem = { index, item -> + onClickItem.invoke(RowColumn(rowIndex, index), item) + }, + onLongClickItem = { index, item -> + onLongClickItem.invoke( + RowColumn(rowIndex, index), + item, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .focusGroup() + .focusRequester(rowFocusRequesters[rowIndex]) + .animateItem(), + cardContent = { index, item, cardModifier, onClick, onLongClick -> + BannerCard( + name = item?.data?.seriesName ?: item?.name, + item = item, + aspectRatio = AspectRatios.TALL, + cornerText = item?.ui?.episodeUnplayedCornerText, + played = item?.data?.userData?.played ?: false, + favorite = item?.favorite ?: false, + playPercent = + item?.data?.userData?.playedPercentage + ?: 0.0, + onClick = onClick, + onLongClick = onLongClick, + modifier = + cardModifier + .onFocusChanged { + if (it.isFocused) { + position = + RowColumn(rowIndex, index) +// item?.let(onUpdateBackdrop) + } + if (it.isFocused && onFocusPosition != null) { + val nonEmptyRowBefore = + homeRows + .subList(0, rowIndex) + .count { + it is HomeRowLoadingState.Success && it.items.isEmpty() + } + onFocusPosition.invoke( + RowColumn( + rowIndex - nonEmptyRowBefore, + index, + ), + ) + } + }.onKeyEvent { + if (isPlayKeyUp(it) && item?.type?.playable == true) { + Timber.v("Clicked play on ${item.id}") + onClickPlay.invoke( + position, + item, + ) + return@onKeyEvent true + } + return@onKeyEvent false + }, + interactionSource = null, + cardHeight = Cards.height2x3, + ) + }, + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/ScrollToTopBringIntoViewSpec.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/ScrollToTopBringIntoViewSpec.kt new file mode 100644 index 00000000..73f22de6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/ScrollToTopBringIntoViewSpec.kt @@ -0,0 +1,44 @@ +package com.github.damontecres.wholphin.ui.util + +import androidx.compose.foundation.gestures.BringIntoViewSpec +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec +import androidx.compose.foundation.lazy.LazyColumn + +/** + * Overrides scrolling so that the item being scrolled to is at the top of the view offset by the provided pixels + * + * Note: the offset is necessary for anything that is focuseable, but has content before (eg a title) that needs to be displayed too + * + * Note: this applies to ALL scrollable composables within its scope, so a [LazyColumn] of [androidx.compose.foundation.lazy.LazyRow]s likely needs nested [LocalBringIntoViewSpec] overrides + * + * Example: + * ```kotlin + * val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current + * CompositionLocalProvider(LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx)) { + * LazyColumn{ + * items(list){ + * CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { + * // Content + * } + * } + * } + * } + * ``` + */ +class ScrollToTopBringIntoViewSpec( + val spaceAbovePx: Float = 100f, +) : BringIntoViewSpec { + override fun calculateScrollDistance( + offset: Float, + size: Float, + containerSize: Float, + ): Float { +// Timber.v( +// "calculateScrollDistance: offset=%s, size=%s, containerSize=%s", +// offset, +// size, +// containerSize, +// ) + return offset - spaceAbovePx + } +}