From 3fd9e021fa84557fc1681685ee1a3e8b6678e7ba Mon Sep 17 00:00:00 2001 From: Andreas Date: Sat, 18 Jun 2022 20:55:58 +0200 Subject: [PATCH] Use custom QueryPagingSource (#7321) * Use custom QueryPagingSource - Adds placeholder to make the list jump around less - Fixes issue where SQLDelight QueryPagingSource would throw IndexOutOfBounds * Review Changes --- .../eu/kanade/data/AndroidDatabaseHandler.kt | 8 +- .../java/eu/kanade/data/DatabaseHandler.kt | 2 - .../java/eu/kanade/data/QueryPagingSource.kt | 72 +++++++++ .../data/history/HistoryRepositoryImpl.kt | 1 - .../presentation/components/MangaCover.kt | 2 +- .../presentation/history/HistoryScreen.kt | 147 +++++------------- .../history/components/HistoryHeader.kt | 36 +++++ .../history/components/HistoryItem.kt | 143 +++++++++++++++++ .../eu/kanade/presentation/util/Shimmer.kt | 9 ++ 9 files changed, 301 insertions(+), 119 deletions(-) create mode 100644 app/src/main/java/eu/kanade/data/QueryPagingSource.kt create mode 100644 app/src/main/java/eu/kanade/presentation/history/components/HistoryHeader.kt create mode 100644 app/src/main/java/eu/kanade/presentation/history/components/HistoryItem.kt create mode 100644 app/src/main/java/eu/kanade/presentation/util/Shimmer.kt diff --git a/app/src/main/java/eu/kanade/data/AndroidDatabaseHandler.kt b/app/src/main/java/eu/kanade/data/AndroidDatabaseHandler.kt index fe579888f0..9f4f804c3a 100644 --- a/app/src/main/java/eu/kanade/data/AndroidDatabaseHandler.kt +++ b/app/src/main/java/eu/kanade/data/AndroidDatabaseHandler.kt @@ -2,8 +2,6 @@ package eu.kanade.data import androidx.paging.PagingSource import com.squareup.sqldelight.Query -import com.squareup.sqldelight.Transacter -import com.squareup.sqldelight.android.paging3.QueryPagingSource import com.squareup.sqldelight.db.SqlDriver import com.squareup.sqldelight.runtime.coroutines.asFlow import com.squareup.sqldelight.runtime.coroutines.mapToList @@ -63,13 +61,11 @@ class AndroidDatabaseHandler( override fun subscribeToPagingSource( countQuery: Database.() -> Query, - transacter: Database.() -> Transacter, queryProvider: Database.(Long, Long) -> Query, ): PagingSource { return QueryPagingSource( - countQuery = countQuery(db), - transacter = transacter(db), - dispatcher = queryDispatcher, + handler = this, + countQuery = countQuery, queryProvider = { limit, offset -> queryProvider.invoke(db, limit, offset) }, diff --git a/app/src/main/java/eu/kanade/data/DatabaseHandler.kt b/app/src/main/java/eu/kanade/data/DatabaseHandler.kt index c0cb676be1..99da8474db 100644 --- a/app/src/main/java/eu/kanade/data/DatabaseHandler.kt +++ b/app/src/main/java/eu/kanade/data/DatabaseHandler.kt @@ -2,7 +2,6 @@ package eu.kanade.data import androidx.paging.PagingSource import com.squareup.sqldelight.Query -import com.squareup.sqldelight.Transacter import eu.kanade.tachiyomi.Database import kotlinx.coroutines.flow.Flow @@ -33,7 +32,6 @@ interface DatabaseHandler { fun subscribeToPagingSource( countQuery: Database.() -> Query, - transacter: Database.() -> Transacter, queryProvider: Database.(Long, Long) -> Query, ): PagingSource } diff --git a/app/src/main/java/eu/kanade/data/QueryPagingSource.kt b/app/src/main/java/eu/kanade/data/QueryPagingSource.kt new file mode 100644 index 0000000000..cd80ace101 --- /dev/null +++ b/app/src/main/java/eu/kanade/data/QueryPagingSource.kt @@ -0,0 +1,72 @@ +package eu.kanade.data + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.squareup.sqldelight.Query +import eu.kanade.tachiyomi.Database +import kotlin.properties.Delegates + +class QueryPagingSource( + val handler: DatabaseHandler, + val countQuery: Database.() -> Query, + val queryProvider: Database.(Long, Long) -> Query, +) : PagingSource(), Query.Listener { + + override val jumpingSupported: Boolean = true + + private var currentQuery: Query? by Delegates.observable(null) { _, old, new -> + old?.removeListener(this) + new?.addListener(this) + } + + init { + registerInvalidatedCallback { + currentQuery?.removeListener(this) + currentQuery = null + } + } + + override suspend fun load(params: LoadParams): LoadResult { + try { + val key = params.key ?: 0L + val loadSize = params.loadSize + val count = handler.awaitOne { countQuery() } + + val (offset, limit) = when (params) { + is LoadParams.Prepend -> key - loadSize to loadSize.toLong() + else -> key to loadSize.toLong() + } + + val data = handler.awaitList { + queryProvider(limit, offset) + .also { currentQuery = it } + } + + val (prevKey, nextKey) = when (params) { + is LoadParams.Append -> { offset - loadSize to offset + loadSize } + else -> { offset to offset + loadSize } + } + + return LoadResult.Page( + data = data, + prevKey = if (offset <= 0L || prevKey < 0L) null else prevKey, + nextKey = if (offset + loadSize >= count) null else nextKey, + itemsBefore = maxOf(0L, offset).toInt(), + itemsAfter = maxOf(0L, count - (offset + loadSize)).toInt(), + ) + } catch (e: Exception) { + return LoadResult.Error(throwable = e) + } + } + + override fun getRefreshKey(state: PagingState): Long? { + return state.anchorPosition?.let { anchorPosition -> + val anchorPage = state.closestPageToPosition(anchorPosition) + anchorPage?.prevKey ?: anchorPage?.nextKey + } + } + + override fun queryResultsChanged() { + invalidate() + } +} diff --git a/app/src/main/java/eu/kanade/data/history/HistoryRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/history/HistoryRepositoryImpl.kt index 4fa59901f3..808aeee653 100644 --- a/app/src/main/java/eu/kanade/data/history/HistoryRepositoryImpl.kt +++ b/app/src/main/java/eu/kanade/data/history/HistoryRepositoryImpl.kt @@ -19,7 +19,6 @@ class HistoryRepositoryImpl( override fun getHistory(query: String): PagingSource { return handler.subscribeToPagingSource( countQuery = { historyViewQueries.countHistory(query) }, - transacter = { historyViewQueries }, queryProvider = { limit, offset -> historyViewQueries.history(query, limit, offset, historyWithRelationsMapper) }, diff --git a/app/src/main/java/eu/kanade/presentation/components/MangaCover.kt b/app/src/main/java/eu/kanade/presentation/components/MangaCover.kt index cd5cdb60de..0940d0a5a6 100644 --- a/app/src/main/java/eu/kanade/presentation/components/MangaCover.kt +++ b/app/src/main/java/eu/kanade/presentation/components/MangaCover.kt @@ -14,7 +14,7 @@ import coil.compose.AsyncImage import eu.kanade.presentation.util.rememberResourceBitmapPainter import eu.kanade.tachiyomi.R -enum class MangaCover(private val ratio: Float) { +enum class MangaCover(val ratio: Float) { Square(1f / 1f), Book(2f / 3f); diff --git a/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt b/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt index 27c93c5ca9..40588cdec0 100644 --- a/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt @@ -1,24 +1,21 @@ package eu.kanade.presentation.history -import androidx.compose.foundation.clickable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.toggleable -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -29,12 +26,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush.Companion.linearGradient import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems @@ -43,22 +39,20 @@ import androidx.paging.compose.items import eu.kanade.domain.history.model.HistoryWithRelations import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.LoadingScreen -import eu.kanade.presentation.components.MangaCover import eu.kanade.presentation.components.ScrollbarLazyColumn -import eu.kanade.presentation.util.horizontalPadding +import eu.kanade.presentation.history.components.HistoryHeader +import eu.kanade.presentation.history.components.HistoryItem +import eu.kanade.presentation.history.components.HistoryItemShimmer import eu.kanade.presentation.util.plus +import eu.kanade.presentation.util.shimmerGradient import eu.kanade.presentation.util.topPaddingValues import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.ui.recent.history.HistoryPresenter import eu.kanade.tachiyomi.ui.recent.history.HistoryState -import eu.kanade.tachiyomi.util.lang.toRelativeString -import eu.kanade.tachiyomi.util.lang.toTimestampString import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.text.DateFormat -import java.text.DecimalFormat -import java.text.DecimalFormatSymbols import java.util.Date @Composable @@ -93,10 +87,7 @@ fun HistoryContent( preferences: PreferencesHelper = Injekt.get(), nestedScroll: NestedScrollConnection, ) { - if (history.loadState.refresh is LoadState.Loading) { - LoadingScreen() - return - } else if (history.loadState.refresh is LoadState.NotLoading && history.itemCount == 0) { + if (history.loadState.refresh is LoadState.NotLoading && history.itemCount == 0) { EmptyScreen(textResource = R.string.information_no_recent_manga) return } @@ -107,6 +98,29 @@ fun HistoryContent( var removeState by remember { mutableStateOf(null) } val scrollState = rememberLazyListState() + + val transition = rememberInfiniteTransition() + + val translateAnimation = transition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1000, + easing = LinearEasing, + ), + ), + ) + + val brush = linearGradient( + colors = shimmerGradient, + start = Offset(0f, 0f), + end = Offset( + x = translateAnimation.value, + y = 00f, + ), + ) + ScrollbarLazyColumn( modifier = Modifier .nestedScroll(nestedScroll), @@ -134,7 +148,9 @@ fun HistoryContent( onClickDelete = { removeState = value }, ) } - null -> {} + null -> { + HistoryItemShimmer(brush = brush) + } } } } @@ -150,88 +166,6 @@ fun HistoryContent( } } -@Composable -fun HistoryHeader( - modifier: Modifier = Modifier, - date: Date, - relativeTime: Int, - dateFormat: DateFormat, -) { - Text( - modifier = modifier - .padding(horizontal = horizontalPadding, vertical = 8.dp), - text = date.toRelativeString( - LocalContext.current, - relativeTime, - dateFormat, - ), - style = MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.SemiBold, - ), - ) -} - -@Composable -fun HistoryItem( - modifier: Modifier = Modifier, - history: HistoryWithRelations, - onClickCover: () -> Unit, - onClickResume: () -> Unit, - onClickDelete: () -> Unit, -) { - Row( - modifier = modifier - .clickable(onClick = onClickResume) - .height(96.dp) - .padding(horizontal = horizontalPadding, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - MangaCover.Book( - modifier = Modifier - .fillMaxHeight() - .clickable(onClick = onClickCover), - data = history.coverData, - ) - Column( - modifier = Modifier - .weight(1f) - .padding(start = horizontalPadding, end = 8.dp), - ) { - val textStyle = MaterialTheme.typography.bodyMedium - Text( - text = history.title, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = textStyle.copy(fontWeight = FontWeight.SemiBold), - ) - Row { - Text( - text = if (history.chapterNumber > -1) { - stringResource( - R.string.recent_manga_time, - chapterFormatter.format(history.chapterNumber), - history.readAt?.toTimestampString() ?: "", - ) - } else { - history.readAt?.toTimestampString() ?: "" - }, - modifier = Modifier.padding(top = 4.dp), - style = textStyle, - ) - } - } - - IconButton(onClick = onClickDelete) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = stringResource(R.string.action_delete), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } -} - @Composable fun RemoveHistoryDialog( onPositive: (Boolean) -> Unit, @@ -282,11 +216,6 @@ fun RemoveHistoryDialog( ) } -private val chapterFormatter = DecimalFormat( - "#.###", - DecimalFormatSymbols().apply { decimalSeparator = '.' }, -) - sealed class HistoryUiModel { data class Header(val date: Date) : HistoryUiModel() data class Item(val item: HistoryWithRelations) : HistoryUiModel() diff --git a/app/src/main/java/eu/kanade/presentation/history/components/HistoryHeader.kt b/app/src/main/java/eu/kanade/presentation/history/components/HistoryHeader.kt new file mode 100644 index 0000000000..18f5b5f64d --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/history/components/HistoryHeader.kt @@ -0,0 +1,36 @@ +package eu.kanade.presentation.history.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import eu.kanade.presentation.util.horizontalPadding +import eu.kanade.tachiyomi.util.lang.toRelativeString +import java.text.DateFormat +import java.util.Date + +@Composable +fun HistoryHeader( + modifier: Modifier = Modifier, + date: Date, + relativeTime: Int, + dateFormat: DateFormat, +) { + Text( + modifier = modifier + .padding(horizontal = horizontalPadding, vertical = 8.dp), + text = date.toRelativeString( + LocalContext.current, + relativeTime, + dateFormat, + ), + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold, + ), + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/history/components/HistoryItem.kt b/app/src/main/java/eu/kanade/presentation/history/components/HistoryItem.kt new file mode 100644 index 0000000000..23bcb71dd7 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/history/components/HistoryItem.kt @@ -0,0 +1,143 @@ +package eu.kanade.presentation.history.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import eu.kanade.domain.history.model.HistoryWithRelations +import eu.kanade.presentation.components.MangaCover +import eu.kanade.presentation.util.horizontalPadding +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.lang.toTimestampString +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols + +private val HISTORY_ITEM_HEIGHT = 96.dp + +@Composable +fun HistoryItem( + modifier: Modifier = Modifier, + history: HistoryWithRelations, + onClickCover: () -> Unit, + onClickResume: () -> Unit, + onClickDelete: () -> Unit, +) { + Row( + modifier = modifier + .clickable(onClick = onClickResume) + .height(HISTORY_ITEM_HEIGHT) + .padding(horizontal = horizontalPadding, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MangaCover.Book( + modifier = Modifier + .fillMaxHeight() + .clickable(onClick = onClickCover), + data = history.coverData, + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = horizontalPadding, end = 8.dp), + ) { + val textStyle = MaterialTheme.typography.bodyMedium + Text( + text = history.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = textStyle.copy(fontWeight = FontWeight.SemiBold), + ) + Text( + text = if (history.chapterNumber > -1) { + stringResource( + R.string.recent_manga_time, + chapterFormatter.format(history.chapterNumber), + history.readAt?.toTimestampString() ?: "", + ) + } else { + history.readAt?.toTimestampString() ?: "" + }, + modifier = Modifier.padding(top = 4.dp), + style = textStyle, + ) + } + + IconButton(onClick = onClickDelete) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.action_delete), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@Composable +fun HistoryItemShimmer(brush: Brush) { + Row( + modifier = Modifier + .height(HISTORY_ITEM_HEIGHT) + .padding(horizontal = horizontalPadding, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .aspectRatio(MangaCover.Book.ratio) + .clip(RoundedCornerShape(4.dp)) + .drawBehind { + drawRect(brush = brush) + }, + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = horizontalPadding, end = 8.dp), + ) { + Box( + modifier = Modifier + .drawBehind { + drawRect(brush = brush) + } + .height(14.dp) + .fillMaxWidth(0.70f), + ) + Box( + modifier = Modifier + .padding(top = 4.dp) + .height(14.dp) + .fillMaxWidth(0.45f) + .drawBehind { + drawRect(brush = brush) + }, + ) + } + } +} + +private val chapterFormatter = DecimalFormat( + "#.###", + DecimalFormatSymbols().apply { decimalSeparator = '.' }, +) diff --git a/app/src/main/java/eu/kanade/presentation/util/Shimmer.kt b/app/src/main/java/eu/kanade/presentation/util/Shimmer.kt new file mode 100644 index 0000000000..4afeff9408 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/util/Shimmer.kt @@ -0,0 +1,9 @@ +package eu.kanade.presentation.util + +import androidx.compose.ui.graphics.Color + +val shimmerGradient = listOf( + Color.LightGray.copy(alpha = 0.8f), + Color.LightGray.copy(alpha = 0.2f), + Color.LightGray.copy(alpha = 0.8f), +)