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
This commit is contained in:
Ray 2026-02-11 16:07:22 -05:00 committed by GitHub
parent 6e676b9474
commit 83e5a44f37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 236 additions and 152 deletions

View file

@ -1,6 +1,8 @@
package com.github.damontecres.wholphin.ui.discover package com.github.damontecres.wholphin.ui.discover
import android.content.Context 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues 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.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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.main.HomePageHeader
import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DataLoadingState
import com.google.common.cache.CacheBuilder import com.google.common.cache.CacheBuilder
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -186,6 +191,7 @@ data class DiscoverState(
val upcomingTv: DiscoverRowData = DiscoverRowData.EMPTY, val upcomingTv: DiscoverRowData = DiscoverRowData.EMPTY,
) )
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun SeerrDiscoverPage( fun SeerrDiscoverPage(
preferences: UserPreferences, preferences: UserPreferences,
@ -249,28 +255,41 @@ fun SeerrDiscoverPage(
.padding(top = 24.dp, bottom = 16.dp, start = 32.dp) .padding(top = 24.dp, bottom = 16.dp, start = 32.dp)
.fillMaxHeight(.25f), .fillMaxHeight(.25f),
) )
LazyColumn( val density = LocalDensity.current
verticalArrangement = Arrangement.spacedBy(8.dp), val spaceAbovePx =
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp), with(density) {
modifier = // The size of the row titles & spacing
Modifier 50.dp.toPx()
.focusRestorer() }
.fillMaxSize(), val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
CompositionLocalProvider(
LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx),
) { ) {
itemsIndexed(rows) { rowIndex, row -> LazyColumn(
DiscoverRow( verticalArrangement = Arrangement.spacedBy(8.dp),
row = row, contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp),
onClickItem = { index, item -> modifier =
position = RowColumn(rowIndex, index) Modifier
viewModel.navigationManager.navigateTo(item.destination) .focusRestorer()
}, .fillMaxSize(),
onLongClickItem = { index, item -> }, ) {
onCardFocus = { index -> position = RowColumn(rowIndex, index) }, itemsIndexed(rows) { rowIndex, row ->
focusRequester = focusRequesters[rowIndex], CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) {
modifier = DiscoverRow(
Modifier row = row,
.fillMaxWidth(), 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(),
)
}
}
} }
} }
} }

View file

@ -1,8 +1,10 @@
package com.github.damontecres.wholphin.ui.main package com.github.damontecres.wholphin.ui.main
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState 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.graphics.Color
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight 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.playback.playable
import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus 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.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -183,6 +188,7 @@ fun HomePage(
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun HomePageContent( fun HomePageContent(
homeRows: List<HomeRowLoadingState>, homeRows: List<HomeRowLoadingState>,
@ -223,14 +229,15 @@ fun HomePageContent(
} }
} }
} }
LaunchedEffect(position) {
if (position.row >= 0) {
listState.animateScrollToItem(position.row)
}
}
LaunchedEffect(onUpdateBackdrop, focusedItem) { LaunchedEffect(onUpdateBackdrop, focusedItem) {
focusedItem?.let { onUpdateBackdrop.invoke(it) } 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) { Box(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
HomePageHeader( HomePageHeader(
@ -240,134 +247,148 @@ fun HomePageContent(
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp) .padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
.fillMaxHeight(.33f), .fillMaxHeight(.33f),
) )
LazyColumn( val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
state = listState, CompositionLocalProvider(
verticalArrangement = Arrangement.spacedBy(8.dp), LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx),
contentPadding =
PaddingValues(
bottom = Cards.height2x3,
),
modifier =
Modifier
.focusRestorer(),
) { ) {
itemsIndexed(homeRows) { rowIndex, row -> LazyColumn(
when (val r = row) { state = listState,
is HomeRowLoadingState.Loading, verticalArrangement = Arrangement.spacedBy(8.dp),
is HomeRowLoadingState.Pending, contentPadding =
-> { PaddingValues(
Column( bottom = Cards.height2x3,
verticalArrangement = Arrangement.spacedBy(8.dp), ),
modifier = Modifier.animateItem(), modifier =
) { Modifier
Text( .focusRestorer(),
text = r.title, ) {
style = MaterialTheme.typography.titleLarge, itemsIndexed(homeRows) { rowIndex, row ->
color = MaterialTheme.colorScheme.onBackground, CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) {
) when (val r = row) {
Text( is HomeRowLoadingState.Loading,
text = stringResource(R.string.loading), is HomeRowLoadingState.Pending,
style = MaterialTheme.typography.titleMedium, -> {
color = MaterialTheme.colorScheme.onBackground, Column(
) verticalArrangement = Arrangement.spacedBy(8.dp),
} modifier = Modifier.animateItem(),
} ) {
Text(
is HomeRowLoadingState.Error -> { text = r.title,
var focused by remember { mutableStateOf(false) } style = MaterialTheme.typography.titleLarge,
Column( color = MaterialTheme.colorScheme.onBackground,
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,
) )
}, 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,
)
},
)
}
}
} }
} }
} }

View file

@ -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
}
}