fix(sticky-header): Fix sticky-header and scrolling issues in source screens (#1315)

* Fix padding in MigrateSourceScreen

* Fix scrolling bar glitch due to sticky headers in RelatedMangasList

* Dynamically measure source search's height

* sticky header in source screen

* fix(sticky-header): update sticky header keys for improved uniqueness

* Fix padding on related-manga-grid

* Avoid mixing dp and px units when initializing searchBoxHeight

* extract `hasKeyword`

* fillMaxWidth
This commit is contained in:
Cuong-Tran 2025-12-09 13:19:54 +07:00 committed by GitHub
parent b24b14c73d
commit b648afb889
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 296 additions and 200 deletions

View file

@ -30,13 +30,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.kanade.domain.source.interactor.SetMigrateSorting
import eu.kanade.domain.source.model.installedExtension
import eu.kanade.presentation.browse.components.BaseSourceItem
import eu.kanade.presentation.browse.components.SourceIcon
import eu.kanade.presentation.components.AnimatedFloatingSearchBox
import eu.kanade.presentation.components.SOURCE_SEARCH_BOX_HEIGHT
import eu.kanade.presentation.util.animateItemFastScroll
import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrateSourceScreenModel
import eu.kanade.tachiyomi.util.system.copyToClipboard
@ -180,9 +181,12 @@ private fun MigrateSourceList(
// KMK -->
Box {
val density = LocalDensity.current
var searchBoxHeight by remember { mutableStateOf(SOURCE_SEARCH_BOX_HEIGHT) }
FastScrollLazyColumn(
state = lazyListState,
contentPadding = PaddingValues(top = 65.dp),
contentPadding = PaddingValues(top = searchBoxHeight),
// KMK <--
) {
items(
@ -201,7 +205,8 @@ private fun MigrateSourceList(
MigrateSourceItem(
// KMK -->
// modifier = Modifier.animateItem(),
modifier = Modifier.animateItemFastScroll(),
modifier = Modifier.animateItemFastScroll()
.padding(end = MaterialTheme.padding.small),
// KMK <--
source = source,
count = count,
@ -221,9 +226,11 @@ private fun MigrateSourceList(
.background(MaterialTheme.colorScheme.background)
.padding(
horizontal = MaterialTheme.padding.medium,
vertical = MaterialTheme.padding.small,
)
.align(Alignment.TopCenter),
onGloballyPositioned = { layoutCoordinates ->
searchBoxHeight = with(density) { layoutCoordinates.size.height.toDp() }
},
)
// KMK <--
}

View file

@ -86,7 +86,7 @@ private fun SourcesFilterContent(
// KMK -->
stickyHeader(
// KMK <--
key = language,
key = "$STICKY_HEADER_KEY_PREFIX-$language",
contentType = "source-filter-header",
) {
SourcesFilterHeader(

View file

@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@ -23,15 +22,21 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times
import eu.kanade.domain.source.model.installedExtension
import eu.kanade.presentation.browse.components.BaseSourceItem
import eu.kanade.presentation.components.AnimatedFloatingSearchBox
import eu.kanade.presentation.components.SOURCE_SEARCH_BOX_HEIGHT
import eu.kanade.presentation.util.animateItemFastScroll
import eu.kanade.tachiyomi.ui.browse.source.SourcesScreenModel
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel.Listing
@ -46,6 +51,7 @@ import tachiyomi.i18n.kmk.KMR
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.components.FastScrollLazyColumn
import tachiyomi.presentation.core.components.LabeledCheckbox
import tachiyomi.presentation.core.components.Scroller.STICKY_HEADER_KEY_PREFIX
import tachiyomi.presentation.core.components.material.SECONDARY_ALPHA
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
@ -88,37 +94,39 @@ fun SourcesScreen(
else -> Box(
modifier = Modifier.padding(contentPadding),
) {
val density = LocalDensity.current
var searchBoxHeight by remember { mutableStateOf(SOURCE_SEARCH_BOX_HEIGHT) }
FastScrollLazyColumn(
state = lazyListState,
contentPadding = PaddingValues(top = 65.dp),
contentPadding = PaddingValues(top = searchBoxHeight),
// KMK <--
) {
items(
items = state.items,
contentType = {
when (it) {
is SourceUiModel.Header -> "header"
is SourceUiModel.Item -> "item"
}
},
key = {
when (it) {
is SourceUiModel.Header -> "header-${it.hashCode()}"
is SourceUiModel.Item -> "source-${it.source.key()}"
}
},
) { model ->
state.items.forEach { model ->
when (model) {
is SourceUiModel.Header -> {
stickyHeader(
key = "$STICKY_HEADER_KEY_PREFIX-header-${model.hashCode()}",
contentType = "header",
) {
SourceHeader(
modifier = Modifier.animateItemFastScroll(),
modifier = Modifier
.animateItemFastScroll()
.background(MaterialTheme.colorScheme.background)
.fillMaxWidth(),
language = model.language,
// SY -->
isCategory = model.isCategory,
// SY <--
)
}
is SourceUiModel.Item -> SourceItem(
}
is SourceUiModel.Item -> {
item(
key = "source-${model.source.key()}",
contentType = "item",
) {
SourceItem(
modifier = Modifier.animateItemFastScroll(),
source = model.source,
// SY -->
@ -132,6 +140,8 @@ fun SourcesScreen(
}
}
}
}
}
// KMK -->
AnimatedFloatingSearchBox(
@ -146,6 +156,9 @@ fun SourcesScreen(
vertical = MaterialTheme.padding.small,
)
.align(Alignment.TopCenter),
onGloballyPositioned = { layoutCoordinates ->
searchBoxHeight = with(density) { layoutCoordinates.size.height.toDp() + 2 * MaterialTheme.padding.small }
},
)
// KMK <--
}

View file

@ -2,7 +2,10 @@ package eu.kanade.presentation.browse.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@ -39,46 +42,67 @@ fun RelatedMangasComfortableGrid(
) {
FastScrollLazyVerticalGrid(
columns = columns,
contentPadding = contentPadding + PaddingValues(horizontal = MaterialTheme.padding.small),
// padding for scrollbar
contentPadding = contentPadding + PaddingValues(horizontal = MaterialTheme.padding.small), // padding for scrollbar
topContentPadding = contentPadding.calculateTopPadding(),
verticalArrangement = Arrangement.spacedBy(CommonMangaItemDefaults.GridVerticalSpacer),
horizontalArrangement = Arrangement.spacedBy(CommonMangaItemDefaults.GridHorizontalSpacer),
) {
relatedMangas.forEach { related ->
val isLoading = related is RelatedManga.Loading
if (isLoading) {
header(key = "${related.hashCode()}#header") {
relatedMangas.forEach { relatedManga ->
when (relatedManga) {
is RelatedManga.Loading -> {
header(key = "${relatedManga.hashCode()}#header") {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background),
) {
HorizontalDivider()
RelatedMangaTitle(
title = stringResource(MR.strings.loading),
subtitle = null,
onClick = {},
onLongClick = null,
modifier = Modifier.background(MaterialTheme.colorScheme.background),
modifier = Modifier
.padding(
start = MaterialTheme.padding.small,
end = MaterialTheme.padding.small,
),
)
}
header(key = "${related.hashCode()}#content") { RelatedMangasLoadingItem() }
} else {
val relatedManga = related as RelatedManga.Success
header(key = "${related.hashCode()}#divider") { HorizontalDivider() }
header(key = "${related.hashCode()}#header") {
}
header(key = "${relatedManga.hashCode()}#loading") { RelatedMangasLoadingItem() }
}
is RelatedManga.Success -> {
val hasKeyword = relatedManga.keyword.isNotBlank()
header(key = "${relatedManga.hashCode()}#header") {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background),
) {
HorizontalDivider()
RelatedMangaTitle(
title = if (relatedManga.keyword.isNotBlank()) {
title = if (hasKeyword) {
stringResource(KMR.strings.related_mangas_more)
} else {
stringResource(KMR.strings.related_mangas_website_suggestions)
},
showArrow = relatedManga.keyword.isNotBlank(),
showArrow = hasKeyword,
subtitle = null,
onClick = {
if (relatedManga.keyword.isNotBlank()) onKeywordClick(relatedManga.keyword)
if (hasKeyword) onKeywordClick(relatedManga.keyword)
},
onLongClick = {
if (relatedManga.keyword.isNotBlank()) onKeywordLongClick(relatedManga.keyword)
if (hasKeyword) onKeywordLongClick(relatedManga.keyword)
},
modifier = Modifier.background(MaterialTheme.colorScheme.background),
modifier = Modifier
.padding(
start = MaterialTheme.padding.small,
end = MaterialTheme.padding.small,
),
)
}
}
items(
key = { "related-comfort-${relatedManga.mangaList[it].id}" },
count = relatedManga.mangaList.size,
@ -96,4 +120,5 @@ fun RelatedMangasComfortableGrid(
}
}
}
}
}

View file

@ -2,7 +2,10 @@ package eu.kanade.presentation.browse.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@ -38,46 +41,67 @@ fun RelatedMangasCompactGrid(
) {
FastScrollLazyVerticalGrid(
columns = columns,
contentPadding = contentPadding + PaddingValues(horizontal = MaterialTheme.padding.small),
// padding for scrollbar
contentPadding = contentPadding + PaddingValues(horizontal = MaterialTheme.padding.small), // padding for scrollbar
topContentPadding = contentPadding.calculateTopPadding(),
verticalArrangement = Arrangement.spacedBy(CommonMangaItemDefaults.GridVerticalSpacer),
horizontalArrangement = Arrangement.spacedBy(CommonMangaItemDefaults.GridHorizontalSpacer),
) {
relatedMangas.forEach { related ->
val isLoading = related is RelatedManga.Loading
if (isLoading) {
header(key = "${related.hashCode()}#header") {
relatedMangas.forEach { relatedManga ->
when (relatedManga) {
is RelatedManga.Loading -> {
header(key = "${relatedManga.hashCode()}#header") {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background),
) {
HorizontalDivider()
RelatedMangaTitle(
title = stringResource(MR.strings.loading),
subtitle = null,
onClick = {},
onLongClick = null,
modifier = Modifier.background(MaterialTheme.colorScheme.background),
modifier = Modifier
.padding(
start = MaterialTheme.padding.small,
end = MaterialTheme.padding.small,
),
)
}
header(key = "${related.hashCode()}#content") { RelatedMangasLoadingItem() }
} else {
val relatedManga = related as RelatedManga.Success
header(key = "${related.hashCode()}#divider") { HorizontalDivider() }
header(key = "${related.hashCode()}#header") {
}
header(key = "${relatedManga.hashCode()}#loading") { RelatedMangasLoadingItem() }
}
is RelatedManga.Success -> {
val hasKeyword = relatedManga.keyword.isNotBlank()
header(key = "${relatedManga.hashCode()}#header") {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background),
) {
HorizontalDivider()
RelatedMangaTitle(
title = if (relatedManga.keyword.isNotBlank()) {
title = if (hasKeyword) {
stringResource(KMR.strings.related_mangas_more)
} else {
stringResource(KMR.strings.related_mangas_website_suggestions)
},
showArrow = relatedManga.keyword.isNotBlank(),
showArrow = hasKeyword,
subtitle = null,
onClick = {
if (relatedManga.keyword.isNotBlank()) onKeywordClick(relatedManga.keyword)
if (hasKeyword) onKeywordClick(relatedManga.keyword)
},
onLongClick = {
if (relatedManga.keyword.isNotBlank()) onKeywordLongClick(relatedManga.keyword)
if (hasKeyword) onKeywordLongClick(relatedManga.keyword)
},
modifier = Modifier.background(MaterialTheme.colorScheme.background),
modifier = Modifier
.padding(
start = MaterialTheme.padding.small,
end = MaterialTheme.padding.small,
),
)
}
}
items(
key = { "related-compact-${relatedManga.mangaList[it].id}" },
count = relatedManga.mangaList.size,
@ -94,4 +118,5 @@ fun RelatedMangasCompactGrid(
}
}
}
}
}

View file

@ -1,7 +1,9 @@
package eu.kanade.presentation.browse.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@ -36,11 +38,16 @@ fun RelatedMangasList(
// Using modifier instead of contentPadding so we can use stickyHeader
modifier = Modifier.padding(contentPadding),
) {
relatedMangas.forEach { related ->
val isLoading = related is RelatedManga.Loading
if (isLoading) {
item(key = "${related.hashCode()}#divider") { HorizontalDivider() }
stickyHeader(key = "$STICKY_HEADER_KEY_PREFIX${related.hashCode()}#header") {
relatedMangas.forEach { relatedManga ->
when (relatedManga) {
is RelatedManga.Loading -> {
stickyHeader(key = "$STICKY_HEADER_KEY_PREFIX-${relatedManga.hashCode()}#header") {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background),
) {
HorizontalDivider()
RelatedMangaTitle(
title = stringResource(MR.strings.loading),
subtitle = null,
@ -49,38 +56,44 @@ fun RelatedMangasList(
modifier = Modifier
.padding(
start = MaterialTheme.padding.small,
end = MaterialTheme.padding.medium,
)
.background(MaterialTheme.colorScheme.background),
end = MaterialTheme.padding.small,
),
)
}
item(key = "${related.hashCode()}#content") { RelatedMangasLoadingItem() }
} else {
val relatedManga = related as RelatedManga.Success
item(key = "${related.hashCode()}#divider") { HorizontalDivider() }
stickyHeader(key = "$STICKY_HEADER_KEY_PREFIX${related.hashCode()}#header") {
}
item(key = "${relatedManga.hashCode()}#loading") { RelatedMangasLoadingItem() }
}
is RelatedManga.Success -> {
val hasKeyword = relatedManga.keyword.isNotBlank()
stickyHeader(key = "$STICKY_HEADER_KEY_PREFIX-${relatedManga.hashCode()}#header") {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background),
) {
HorizontalDivider()
RelatedMangaTitle(
title = if (relatedManga.keyword.isNotBlank()) {
title = if (hasKeyword) {
stringResource(KMR.strings.related_mangas_more)
} else {
stringResource(KMR.strings.related_mangas_website_suggestions)
},
showArrow = relatedManga.keyword.isNotBlank(),
showArrow = hasKeyword,
subtitle = null,
onClick = {
if (relatedManga.keyword.isNotBlank()) onKeywordClick(relatedManga.keyword)
if (hasKeyword) onKeywordClick(relatedManga.keyword)
},
onLongClick = {
if (relatedManga.keyword.isNotBlank()) onKeywordLongClick(relatedManga.keyword)
if (hasKeyword) onKeywordLongClick(relatedManga.keyword)
},
modifier = Modifier
.padding(
start = MaterialTheme.padding.small,
end = MaterialTheme.padding.medium,
)
.background(MaterialTheme.colorScheme.background),
end = MaterialTheme.padding.small,
),
)
}
}
items(
key = { "related-list-${relatedManga.mangaList[it].id}" },
count = relatedManga.mangaList.size,
@ -97,4 +110,5 @@ fun RelatedMangasList(
}
}
}
}
}

View file

@ -32,6 +32,8 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.SoftwareKeyboardController
@ -54,6 +56,7 @@ fun AnimatedFloatingSearchBox(
searchQuery: String?,
onChangeSearchQuery: (String?) -> Unit,
modifier: Modifier = Modifier,
onGloballyPositioned: (LayoutCoordinates) -> Unit = { },
placeholderText: String? = null,
focusManager: FocusManager = LocalFocusManager.current,
focusRequester: FocusRequester = remember { FocusRequester() },
@ -68,6 +71,7 @@ fun AnimatedFloatingSearchBox(
SourcesSearchBox(
searchQuery = searchQuery,
onChangeSearchQuery = onChangeSearchQuery,
onGloballyPositioned = onGloballyPositioned,
placeholderText = placeholderText,
focusManager = focusManager,
focusRequester = focusRequester,
@ -81,6 +85,7 @@ fun SourcesSearchBox(
searchQuery: String?,
onChangeSearchQuery: (String?) -> Unit,
modifier: Modifier = Modifier,
onGloballyPositioned: (LayoutCoordinates) -> Unit = { },
placeholderText: String? = null,
focusManager: FocusManager = LocalFocusManager.current,
focusRequester: FocusRequester = remember { FocusRequester() },
@ -108,6 +113,7 @@ fun SourcesSearchBox(
value = searchQuery ?: "",
onValueChange = onChangeSearchQuery,
modifier = modifier
.onGloballyPositioned(onGloballyPositioned)
.fillMaxWidth()
.focusRequester(focusRequester)
.onFocusChanged { isFocused = it.isFocused }
@ -207,6 +213,8 @@ fun SearchBoxTrailingIcon(
}
}
internal val SOURCE_SEARCH_BOX_HEIGHT = 65.dp
@Preview
@Composable
fun PreviewClearFocusOnKeyboardDismissExample() {

View file

@ -57,7 +57,6 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLayoutDirection
@ -74,6 +73,7 @@ import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.SOURCE_SEARCH_BOX_HEIGHT
import eu.kanade.presentation.components.SourcesSearchBox
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.databinding.PreMigrationListBinding
@ -194,10 +194,10 @@ class PreMigrationScreen(val migration: MigrationType) : Screen() {
},
) { contentPadding ->
// KMK -->
Box(modifier = Modifier.padding(top = contentPadding.calculateTopPadding())) {
var searchBoxHeight by remember { mutableIntStateOf(40) }
// KMK <--
val density = LocalDensity.current
Box(modifier = Modifier.padding(top = contentPadding.calculateTopPadding())) {
var searchBoxHeight by remember { mutableIntStateOf(with(density) { SOURCE_SEARCH_BOX_HEIGHT.roundToPx() }) }
// KMK <--
val layoutDirection = LocalLayoutDirection.current
val left = with(density) { contentPadding.calculateLeftPadding(layoutDirection).toPx().roundToInt() }
// KMK -->
@ -234,12 +234,14 @@ class PreMigrationScreen(val migration: MigrationType) : Screen() {
}
// KMK -->
SourcesSearchBox(
modifier = Modifier
.onSizeChanged { searchBoxHeight = it.height }
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = MaterialTheme.padding.small),
searchQuery = searchQuery,
onChangeSearchQuery = { searchQuery = it ?: "" },
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = MaterialTheme.padding.small),
onGloballyPositioned = { layoutCoordinates ->
searchBoxHeight = layoutCoordinates.size.height
},
placeholderText = stringResource(KMR.strings.action_search_for_source),
)
// KMK <--

View file

@ -30,6 +30,7 @@ import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.CheckboxItem
import tachiyomi.presentation.core.components.CollapsibleBox
import tachiyomi.presentation.core.components.HeadingItem
import tachiyomi.presentation.core.components.Scroller.STICKY_HEADER_KEY_PREFIX
import tachiyomi.presentation.core.components.SelectItem
import tachiyomi.presentation.core.components.SortItem
import tachiyomi.presentation.core.components.TextItem
@ -62,7 +63,9 @@ fun SourceFilterDialog(
AdaptiveSheet(onDismissRequest = onDismissRequest) {
LazyColumn {
stickyHeader {
stickyHeader(
key = "$STICKY_HEADER_KEY_PREFIX-title",
) {
Row(
modifier = Modifier
.background(MaterialTheme.colorScheme.background)

View file

@ -28,7 +28,7 @@ import uy.kohesive.injekt.api.get
* A screen that displays a colors palette of current theme.
*/
class PaletteScreen(
@ColorInt private val seedColor: Int?,
@param:ColorInt private val seedColor: Int?,
) : Screen() {
@Composable
@ -59,8 +59,7 @@ class PaletteScreen(
},
) { contentPadding ->
ScrollbarLazyColumn(
// Using modifier instead of contentPadding so we can use stickyHeader
modifier = Modifier.padding(contentPadding),
contentPadding = contentPadding,
) {
item {
ButtonsColor(