共享元素动画优化

This commit is contained in:
HapeLee
2026-05-10 13:23:07 +08:00
parent 0bbb7f1fa0
commit cc673eeac0
10 changed files with 298 additions and 262 deletions
@@ -5,6 +5,9 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@@ -82,6 +85,7 @@ import io.legado.app.ui.widget.components.book.SearchBookListItem
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.ui.main.bookCoverSharedElementKey
import kotlinx.coroutines.delay
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@@ -89,7 +93,7 @@ import org.koin.compose.koinInject
@SuppressLint("LocalContextConfigurationRead", "ConfigurationScreenWidthHeight")
@OptIn(
ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class,
ExperimentalMaterial3ExpressiveApi::class
ExperimentalMaterial3ExpressiveApi::class, ExperimentalSharedTransitionApi::class
)
@Composable
fun ExploreShowScreen(
@@ -98,7 +102,9 @@ fun ExploreShowScreen(
exploreUrl: String?,
onBack: () -> Unit,
onBookClick: (SearchBook) -> Unit,
viewModel: ExploreShowViewModel = koinViewModel()
viewModel: ExploreShowViewModel = koinViewModel(),
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
LaunchedEffect(sourceUrl, exploreUrl, viewModel) {
@@ -448,7 +454,9 @@ fun ExploreShowScreen(
book = item.book,
shelfState = item.shelfState,
onClick = { onBookClick(item.book) },
modifier = Modifier.animateItem()
modifier = Modifier.animateItem(),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
}
@@ -480,7 +488,9 @@ fun ExploreShowScreen(
book = item.book,
shelfState = item.shelfState,
onClick = { onBookClick(item.book) },
modifier = Modifier.animateItem()
modifier = Modifier.animateItem(),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
}
@@ -500,33 +510,45 @@ fun ExploreShowScreen(
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun ExploreBookItem(
book: SearchBook,
shelfState: BookShelfState,
onClick: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
SearchBookListItem(
book = book,
shelfState = shelfState,
onClick = onClick,
modifier = modifier
modifier = modifier,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
)
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun ExploreBookGridItem(
book: SearchBook,
onClick: () -> Unit,
shelfState: BookShelfState,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
SearchBookGridItem(
book = book,
shelfState = shelfState,
onClick = onClick,
modifier = modifier
modifier = modifier,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
)
}
@@ -3,6 +3,8 @@ package io.legado.app.ui.book.info
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
@@ -12,6 +14,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -54,6 +57,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.blur
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
@@ -501,6 +505,12 @@ private fun BookInfoBackdrop(book: Book) {
}
}
val backdropAlpha by animateFloatAsState(
targetValue = if (showBackdropImage) 1f else 0f,
animationSpec = tween(800),
label = "BackdropFade"
)
val backdropRequest = remember(cover, sourceOrigin, loadOnlyWifi, context) {
buildCoverImageRequest(
context = context,
@@ -516,15 +526,16 @@ private fun BookInfoBackdrop(book: Book) {
0.42f
)
Box(modifier = Modifier.fillMaxSize()) {
if (!cover.isNullOrBlank() && showBackdropImage) {
if (!cover.isNullOrBlank()) {
AsyncImage(
model = backdropRequest,
imageLoader = imageLoader,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(360.dp)
.blur(24.dp),
.height(480.dp)
.blur(24.dp)
.alpha(backdropAlpha),
contentScale = ContentScale.Crop,
)
}
@@ -540,10 +551,10 @@ private fun BookInfoBackdrop(book: Book) {
Brush.verticalGradient(
colorStops = arrayOf(
0f to Color.Transparent,
0.18f to seedOverlay.copy(alpha = 0.10f),
0.34f to seedOverlay.copy(alpha = 0.18f),
0.52f to LegadoTheme.colorScheme.surface.copy(alpha = 0.82f),
0.72f to LegadoTheme.colorScheme.surface,
0.20f to seedOverlay.copy(alpha = 0.10f),
0.40f to seedOverlay.copy(alpha = 0.18f),
0.60f to LegadoTheme.colorScheme.surface.copy(alpha = 0.85f),
0.80f to LegadoTheme.colorScheme.surface,
1f to LegadoTheme.colorScheme.surface,
)
)
@@ -687,25 +698,16 @@ private fun BookInfoHeader(
.width(112.dp)
.combinedClickable(onClick = onCoverClick, onLongClick = onCoverLongClick)
) {
val coverModifier = with(sharedTransitionScope) {
if (this != null && animatedVisibilityScope != null && sharedCoverKey != null) {
Modifier
.width(112.dp)
.sharedElement(
sharedContentState = rememberSharedContentState(sharedCoverKey),
animatedVisibilityScope = animatedVisibilityScope,
)
} else {
Modifier.width(112.dp)
}
}
CoilBookCover(
name = book.name,
author = book.author,
path = book.getDisplayCover(),
sourceOrigin = book.origin,
modifier = coverModifier,
modifier = Modifier.width(112.dp).aspectRatio(5f / 7f),
showLoadingPlaceholder = sharedCoverKey == null,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey,
)
}
Column(
@@ -1,6 +1,9 @@
package io.legado.app.ui.book.search
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -91,18 +94,21 @@ import io.legado.app.ui.widget.components.icon.AppIcons
import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
import io.legado.app.ui.widget.components.tabRow.AppTabRow
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.main.bookCoverSharedElementKey
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
@OptIn(FlowPreview::class, ExperimentalMaterial3Api::class)
@OptIn(FlowPreview::class, ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
@Composable
fun SearchScreen(
viewModel: SearchViewModel,
onBack: () -> Unit,
onOpenBookInfo: (name: String, author: String, bookUrl: String) -> Unit,
onOpenSourceManage: () -> Unit,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
val context = LocalContext.current
val state by viewModel.uiState.collectAsStateWithLifecycle()
@@ -362,7 +368,10 @@ fun SearchScreen(
shelfState = item.shelfState,
onClick = {
viewModel.onIntent(SearchIntent.OpenSearchBook(item.book))
}
},
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(item.book.bookUrl)
)
}
@@ -609,7 +609,18 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
)
}
entry<MainRouteSearch> { route ->
entry<MainRouteSearch>(
metadata = NavDisplay.transitionSpec {
fadeIn(animationSpec = tween(300)) togetherWith
fadeOut(animationSpec = tween(300))
} + NavDisplay.popTransitionSpec {
fadeIn(animationSpec = tween(300)) togetherWith
fadeOut(animationSpec = tween(300))
} + NavDisplay.predictivePopTransitionSpec { _ ->
fadeIn(animationSpec = tween(300)) togetherWith
fadeOut(animationSpec = tween(300))
}
) { route ->
val searchViewModel = koinViewModel<SearchViewModel>()
val lifecycleOwner = LocalLifecycleOwner.current
@@ -657,7 +668,9 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
},
onOpenSourceManage = {
this@MainActivity.startActivity<BookSourceActivity>()
}
},
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = LocalNavAnimatedContentScope.current,
)
}
@@ -730,7 +743,18 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
)
}
entry<MainRouteExploreShow> { route ->
entry<MainRouteExploreShow>(
metadata = NavDisplay.transitionSpec {
fadeIn(animationSpec = tween(300)) togetherWith
fadeOut(animationSpec = tween(300))
} + NavDisplay.popTransitionSpec {
fadeIn(animationSpec = tween(300)) togetherWith
fadeOut(animationSpec = tween(300))
} + NavDisplay.predictivePopTransitionSpec { _ ->
fadeIn(animationSpec = tween(300)) togetherWith
fadeOut(animationSpec = tween(300))
}
) { route ->
ExploreShowScreen(
title = route.title ?: "探索",
sourceUrl = route.sourceUrl,
@@ -745,7 +769,9 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
bookUrl = book.bookUrl
)
)
}
},
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = LocalNavAnimatedContentScope.current,
)
}
}
@@ -349,7 +349,13 @@ fun MainScreen(
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.then(
with(sharedTransitionScope) {
if (this != null) Modifier.skipToLookaheadSize() else Modifier
}
),
userScrollEnabled = true,
beyondViewportPageCount = 1
) { page ->
@@ -536,28 +536,21 @@ fun BookItem(
}
} else null,
cover = { modifier ->
val coverModifier = with(sharedTransitionScope) {
if (this != null && animatedVisibilityScope != null && sharedCoverKey != null) {
Modifier.fillMaxWidth().sharedElement(
sharedContentState = rememberSharedContentState(sharedCoverKey),
animatedVisibilityScope = animatedVisibilityScope,
)
} else {
Modifier.fillMaxWidth()
}
}
BookshelfCover(
name = book.name,
author = book.author,
path = book.getDisplayCover(),
isUpdating = isUpdating,
modifier = modifier,
coverModifier = coverModifier,
coverModifier = Modifier.fillMaxWidth().aspectRatio(5f / 7f),
sourceOrigin = book.origin,
badgeText = if (layoutMode != 0) unreadText else null,
showBadgeDot = BookshelfConfig.showUnread && BookshelfConfig.showUnreadNew && book.isNew,
leftBottomText = matchedSourceLabel ?: bookTypeLabel,
showLoadingPlaceholder = sharedCoverKey == null,
showLoadingPlaceholder = true,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey,
)
},
title = book.name,
@@ -632,7 +632,12 @@ fun BookshelfScreen(
FastScrollLazyVerticalGrid(
columns = GridCells.Fixed(folderColumns.coerceAtLeast(1)),
modifier = Modifier
.fillMaxSize(),
.fillMaxSize()
.then(
with(sharedTransitionScope) {
if (this != null) Modifier.skipToLookaheadSize() else Modifier
}
),
contentPadding = adaptiveContentPaddingBookshelf(
top = paddingValues.calculateTopPadding(),
bottom = 120.dp,
@@ -717,7 +722,13 @@ fun BookshelfScreen(
} else {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.then(
with(sharedTransitionScope) {
if (this != null) Modifier.skipToLookaheadSize() else Modifier
}
),
beyondViewportPageCount = 2,
key = { if (it < uiState.groups.size) uiState.groups[it].groupId else it }
) { pageIndex ->
@@ -1078,82 +1089,98 @@ fun BookshelfPage(
onDragFinished()
}
}
FastScrollLazyVerticalGrid(
columns = GridCells.Fixed(columns.coerceAtLeast(1)),
state = gridState,
Box(
modifier = Modifier
.fillMaxSize(),
contentPadding = adaptiveContentPaddingBookshelf(
top = paddingValues.calculateTopPadding(),
bottom = 120.dp,
horizontal = if (isGridMode) 8.dp else 4.dp
),
verticalArrangement = Arrangement.spacedBy(if (isGridMode) 8.dp else 0.dp),
horizontalArrangement = Arrangement.spacedBy(if (isGridMode) 8.dp else 0.dp),
showFastScroll = BookshelfConfig.showBookshelfFastScroller
.fillMaxSize()
.then(
with(sharedTransitionScope) {
if (this != null) Modifier.skipToLookaheadSize() else Modifier
}
)
) {
items(displayBooks, key = { it.bookUrl }) { book ->
val isSelected = selectedBookUrls.contains(book.bookUrl)
ReorderableItem(
state = reorderableState,
key = book.bookUrl,
enabled = canReorderBooks
) {
BookItem(
book = book,
modifier = Modifier.then(
if (canReorderBooks) {
Modifier.longPressDraggableHandle(
onDragStarted = {
onDragStarted(displayBooks)
hapticFeedback.performHapticFeedback(
HapticFeedbackType.GestureThresholdActivate
)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(
HapticFeedbackType.GestureEnd
)
}
)
} else {
Modifier
}
),
layoutMode = bookshelfLayoutMode,
isSelected = isSelected,
gridStyle = BookshelfConfig.bookshelfGridLayout,
isCompact = BookshelfConfig.bookshelfLayoutCompact,
isUpdating = uiState.updatingBooks.contains(book.bookUrl),
titleSmallFont = BookshelfConfig.bookshelfTitleSmallFont,
titleCenter = BookshelfConfig.bookshelfTitleCenter,
titleMaxLines = BookshelfConfig.bookshelfTitleMaxLines,
coverShadow = BookshelfConfig.bookshelfCoverShadow,
isSearchMode = uiState.isSearch,
searchKey = uiState.searchKey,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl),
onClick = {
if (uiState.isEditMode) {
onToggleBookSelection(book)
} else {
onBookClick(book)
}
},
onLongClick = if (canReorderBooks) {
null
} else {
{
FastScrollLazyVerticalGrid(
columns = GridCells.Fixed(columns.coerceAtLeast(1)),
state = gridState,
modifier = Modifier
.fillMaxSize()
.then(
with(sharedTransitionScope) {
if (this != null) Modifier.skipToLookaheadSize() else Modifier
}
),
contentPadding = adaptiveContentPaddingBookshelf(
top = paddingValues.calculateTopPadding(),
bottom = 120.dp,
horizontal = if (isGridMode) 8.dp else 4.dp
),
verticalArrangement = Arrangement.spacedBy(if (isGridMode) 8.dp else 0.dp),
horizontalArrangement = Arrangement.spacedBy(if (isGridMode) 8.dp else 0.dp),
showFastScroll = BookshelfConfig.showBookshelfFastScroller
) {
items(displayBooks, key = { it.bookUrl }) { book ->
val isSelected = selectedBookUrls.contains(book.bookUrl)
ReorderableItem(
state = reorderableState,
key = book.bookUrl,
enabled = canReorderBooks
) {
BookItem(
book = book,
modifier = Modifier.then(
if (canReorderBooks) {
Modifier.longPressDraggableHandle(
onDragStarted = {
onDragStarted(displayBooks)
hapticFeedback.performHapticFeedback(
HapticFeedbackType.GestureThresholdActivate
)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(
HapticFeedbackType.GestureEnd
)
}
)
} else {
Modifier
}
),
layoutMode = bookshelfLayoutMode,
isSelected = isSelected,
gridStyle = BookshelfConfig.bookshelfGridLayout,
isCompact = BookshelfConfig.bookshelfLayoutCompact,
isUpdating = uiState.updatingBooks.contains(book.bookUrl),
titleSmallFont = BookshelfConfig.bookshelfTitleSmallFont,
titleCenter = BookshelfConfig.bookshelfTitleCenter,
titleMaxLines = BookshelfConfig.bookshelfTitleMaxLines,
coverShadow = BookshelfConfig.bookshelfCoverShadow,
isSearchMode = uiState.isSearch,
searchKey = uiState.searchKey,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = if (isCurrentPage) bookCoverSharedElementKey(book.bookUrl) else null,
onClick = {
if (uiState.isEditMode) {
onToggleBookSelection(book)
} else {
onBookLongClick(book)
onBookClick(book)
}
},
onLongClick = if (canReorderBooks) {
null
} else {
{
if (uiState.isEditMode) {
onToggleBookSelection(book)
} else {
onBookLongClick(book)
}
}
}
}
)
)
}
}
}
}
}
}
@@ -1,5 +1,8 @@
package io.legado.app.ui.widget.components.book
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
@@ -18,8 +21,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -34,7 +35,7 @@ import androidx.compose.ui.unit.sp
import io.legado.app.data.entities.SearchBook
import io.legado.app.domain.model.BookShelfState
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.cover.Cover
import io.legado.app.ui.widget.components.cover.CoilBookCover
import io.legado.app.ui.widget.components.text.AppText
import kotlinx.coroutines.flow.Flow
@@ -54,47 +55,33 @@ fun SearchBookListItem(
)
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun SearchBookListItem(
book: SearchBook,
shelfState: BookShelfState,
onClick: () -> Unit,
modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKey: String? = null,
) {
val badgeContent: (@Composable RowScope.() -> Unit)? = when (shelfState) {
BookShelfState.IN_SHELF -> {
{
androidx.compose.material3.Icon(
imageVector = Icons.Default.Check,
contentDescription = "已在书架",
modifier = Modifier.size(12.dp),
)
}
}
BookShelfState.SAME_NAME_AUTHOR -> {
{
androidx.compose.material3.Icon(
imageVector = Icons.Default.Shuffle,
contentDescription = "同名书籍",
modifier = Modifier.size(12.dp),
)
}
}
BookShelfState.NOT_IN_SHELF -> null
}
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Cover(
CoilBookCover(
name = book.name,
author = book.author,
path = book.coverUrl,
modifier = Modifier.width(72.dp),
badgeContent = badgeContent,
modifier = Modifier.width(72.dp).aspectRatio(5f / 7f),
sourceOrigin = book.origin,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey,
showLoadingPlaceholder = sharedCoverKey == null
)
Spacer(modifier = Modifier.width(8.dp))
@@ -180,45 +167,35 @@ fun SearchBookGridItem(
)
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun SearchBookGridItem(
book: SearchBook,
shelfState: BookShelfState,
onClick: () -> Unit,
modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKey: String? = null,
) {
val badgeText: String? = when (shelfState) {
BookShelfState.IN_SHELF -> "已在书架"
BookShelfState.SAME_NAME_AUTHOR -> "同名书籍"
BookShelfState.NOT_IN_SHELF -> null
}
val badgeContent: (@Composable RowScope.() -> Unit)? = if (!badgeText.isNullOrBlank()) {
{
AppText(
text = badgeText,
style = LegadoTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,
fontSize = 9.sp,
)
)
}
} else {
null
}
Column(
modifier = modifier
.width(IntrinsicSize.Min)
.clickable(onClick = onClick)
.padding(4.dp)
) {
Cover(
CoilBookCover(
name = book.name,
author = book.author,
path = book.coverUrl,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(12 / 17f),
badgeContent = badgeContent,
.aspectRatio(5f / 7f),
sourceOrigin = book.origin,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey,
showLoadingPlaceholder = sharedCoverKey == null
)
Spacer(modifier = Modifier.height(4.dp))
@@ -1,5 +1,8 @@
package io.legado.app.ui.widget.components.cover
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -14,6 +17,7 @@ import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.AppLinearProgressIndicator
import io.legado.app.ui.widget.components.card.TextCard
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun BookshelfCover(
name: String?,
@@ -28,6 +32,9 @@ fun BookshelfCover(
sourceOrigin: String? = null,
onLoadFinish: (() -> Unit)? = null,
showLoadingPlaceholder: Boolean = true,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKey: String? = null,
) {
Box(modifier = modifier) {
CoilBookCover(
@@ -38,6 +45,9 @@ fun BookshelfCover(
sourceOrigin = sourceOrigin,
onLoadFinish = onLoadFinish,
showLoadingPlaceholder = showLoadingPlaceholder,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey,
)
if (!badgeText.isNullOrEmpty()) {
@@ -6,11 +6,13 @@ import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import android.text.TextUtils
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -27,6 +29,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
@@ -34,17 +37,15 @@ import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.core.graphics.withSave
import coil.compose.AsyncImage
import io.legado.app.ui.config.coverConfig.CoverConfig
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.card.NormalCard
import kotlinx.coroutines.delay
import org.koin.compose.koinInject
import io.legado.app.model.BookCover as BookCoverModel
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun CoilBookCover(
name: String?,
@@ -55,6 +56,9 @@ fun CoilBookCover(
onLoadFinish: (() -> Unit)? = null,
ignoreUseDefaultCover: Boolean = false,
showLoadingPlaceholder: Boolean = true,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKey: String? = null,
) {
val context = LocalContext.current
val isNight = isSystemInDarkTheme()
@@ -62,11 +66,7 @@ fun CoilBookCover(
val useDefault = !ignoreUseDefaultCover && CoverConfig.useDefaultCover
val finalPath = if (useDefault) null else path
val randomPath = remember(
name, author, path, isNight,
CoverConfig.defaultCover,
CoverConfig.defaultCoverDark
) {
val randomPath = remember(name, author, path, isNight) {
BookCoverModel.getRandomDefaultPath(
seed = name ?: author ?: path ?: "",
isNight = isNight
@@ -74,57 +74,46 @@ fun CoilBookCover(
}
val hasCustomDefault = !randomPath.isNullOrBlank()
var isOnlineCoverLoaded by remember(path) { mutableStateOf(false) }
var isFinalPathLoaded by remember(path) { mutableStateOf(false) }
var isFinalPathLoadFinished by remember(path) { mutableStateOf(false) }
var isPlaceholderAllowed by remember(path, showLoadingPlaceholder) {
mutableStateOf(showLoadingPlaceholder)
}
LaunchedEffect(finalPath) {
isOnlineCoverLoaded = false
isFinalPathLoaded = false
isFinalPathLoadFinished = finalPath == null
}
LaunchedEffect(finalPath, showLoadingPlaceholder) {
if (showLoadingPlaceholder) {
isPlaceholderAllowed = true
} else {
isPlaceholderAllowed = false
delay(600)
isPlaceholderAllowed = true
}
}
NormalCard(
cornerRadius = 4.dp,
containerColor = if (!hasCustomDefault && !isOnlineCoverLoaded) {
LegadoTheme.colorScheme.surfaceContainerLow
} else
LegadoTheme.colorScheme.surfaceContainerLow.copy(alpha = 0f)
){
BoxWithConstraints(
modifier = modifier
.aspectRatio(5f / 7f)
.then(
if (CoverConfig.coverShowShadow) {
Modifier.shadow(4.dp, RoundedCornerShape(4.dp))
Box(
modifier = modifier
.then(
if (CoverConfig.coverShowShadow) {
Modifier.shadow(4.dp, RoundedCornerShape(4.dp))
} else Modifier
)
.background(
if (!hasCustomDefault && !isOnlineCoverLoaded) {
LegadoTheme.colorScheme.surfaceContainerLow
} else Color.Transparent,
RoundedCornerShape(4.dp)
)
.clip(RoundedCornerShape(4.dp))
) {
val imageContentModifier = Modifier
.fillMaxSize()
.then(
with(sharedTransitionScope) {
if (this != null && animatedVisibilityScope != null && sharedCoverKey != null) {
Modifier.sharedElement(
sharedContentState = rememberSharedContentState(sharedCoverKey),
animatedVisibilityScope = animatedVisibilityScope,
renderInOverlayDuringTransition = true
)
} else Modifier
)
) {
val density = LocalDensity.current
val iconSizeDp = with(density) {
(minOf(maxWidth, maxHeight).toPx() / 3f).toDp()
}
}
)
if (hasCustomDefault && !isFinalPathLoaded) {
Box(modifier = imageContentModifier) {
if (hasCustomDefault && !isOnlineCoverLoaded) {
AsyncImage(
model = buildCoverImageRequest(
context = context,
data = randomPath,
sourceOrigin = null,
loadOnlyWifi = false,
crossfade = true,
crossfade = showLoadingPlaceholder,
),
contentDescription = null,
imageLoader = koinInject(),
@@ -140,7 +129,7 @@ fun CoilBookCover(
data = finalPath,
sourceOrigin = sourceOrigin,
loadOnlyWifi = CoverConfig.loadCoverOnlyWifi,
crossfade = true,
crossfade = showLoadingPlaceholder,
),
contentDescription = null,
imageLoader = koinInject(),
@@ -148,13 +137,10 @@ fun CoilBookCover(
modifier = Modifier.fillMaxSize(),
onSuccess = {
isOnlineCoverLoaded = true
isFinalPathLoaded = true
isFinalPathLoadFinished = true
onLoadFinish?.invoke()
},
onError = {
isOnlineCoverLoaded = false
isFinalPathLoadFinished = true
onLoadFinish?.invoke()
}
)
@@ -163,28 +149,24 @@ fun CoilBookCover(
onLoadFinish?.invoke()
}
}
}
val showPlaceholder = isPlaceholderAllowed && (showLoadingPlaceholder || isFinalPathLoadFinished)
if (!hasCustomDefault && !isOnlineCoverLoaded && showPlaceholder) {
if (showLoadingPlaceholder && !isOnlineCoverLoaded) {
if (!hasCustomDefault) {
Icon(
Icons.Default.Book,
contentDescription = null,
tint = LegadoTheme.colorScheme.secondary,
modifier = Modifier
.size(iconSizeDp)
.fillMaxSize(0.35f)
.align(Alignment.Center)
)
}
if (!isOnlineCoverLoaded && showPlaceholder) {
CoverTextOverlay(
name = name,
author = author,
isNight = isNight
)
}
CoverTextOverlay(
name = name,
author = author,
isNight = isNight
)
}
}
}
@@ -196,8 +178,7 @@ private fun CoverTextOverlay(
isNight: Boolean
) {
val showName = if (isNight) CoverConfig.coverShowNameN else CoverConfig.coverShowName
val showAuthor =
(if (isNight) CoverConfig.coverShowAuthorN else CoverConfig.coverShowAuthor) && showName
val showAuthor = (if (isNight) CoverConfig.coverShowAuthorN else CoverConfig.coverShowAuthor) && showName
if (!showName && !showAuthor) return
@@ -231,9 +212,7 @@ private fun CoverTextOverlay(
if (isHorizontal) {
val maxWidth = (viewWidth * 0.8f).toInt()
val textPaint = TextPaint(paint).apply {
textAlign = Paint.Align.LEFT
}
val textPaint = TextPaint(paint).apply { textAlign = Paint.Align.LEFT }
val layout = StaticLayout.Builder
.obtain(name, 0, name.length, textPaint, maxWidth)
.setAlignment(Layout.Alignment.ALIGN_CENTER)
@@ -296,12 +275,7 @@ private fun CoverTextOverlay(
}
}
if (isHorizontal) {
val authorText = TextUtils.ellipsize(
author,
TextPaint(paint),
viewWidth * 0.9f,
TextUtils.TruncateAt.END
)
val authorText = TextUtils.ellipsize(author, TextPaint(paint), viewWidth * 0.9f, TextUtils.TruncateAt.END)
if (CoverConfig.coverShowStroke) {
val strokePaint = Paint(paint).apply {
color = Color.White.toArgb()
@@ -309,19 +283,9 @@ private fun CoverTextOverlay(
strokeWidth = paint.textSize / 10
clearShadowLayer()
}
nativeCanvas.drawText(
authorText.toString(),
viewWidth / 2,
viewHeight * 0.75f,
strokePaint
)
nativeCanvas.drawText(authorText.toString(), viewWidth / 2, viewHeight * 0.75f, strokePaint)
}
nativeCanvas.drawText(
authorText.toString(),
viewWidth / 2,
viewHeight * 0.75f,
paint
)
nativeCanvas.drawText(authorText.toString(), viewWidth / 2, viewHeight * 0.75f, paint)
} else {
val startX = viewWidth * 0.84f
val fm = paint.fontMetrics