共享元素动画优化

This commit is contained in:
HapeLee committed 2026-05-10 13:23:07 +08:00
1 parent 0bbb7f1fa0
commit cc673eeac0
10 files changed
+297 -261

No files matched your search

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