[优化] 优化了大部分Compose界面的动画与逻辑(正文选字搜索、部分界面的动画效果等)

This commit is contained in:
HapeLee
2026-01-02 05:49:22 +08:00
parent ce0fc89ba0
commit 8362a0fe8b
11 changed files with 323 additions and 469 deletions
@@ -1,246 +0,0 @@
package io.legado.app.ui.about
import android.content.Context
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.ViewGroup
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope
import io.legado.app.R
import io.legado.app.base.BaseActivity
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.base.adapter.RecyclerAdapter
import io.legado.app.data.appDb
import io.legado.app.data.entities.ReadRecordShow
import io.legado.app.databinding.ActivityReadRecordBinding
import io.legado.app.databinding.ItemReadRecordBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.LocalConfig
import io.legado.app.lib.dialogs.alert
import io.legado.app.model.BookCover
//import io.legado.app.lib.theme.primaryTextColor
import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.utils.applyNavigationBarPadding
import io.legado.app.utils.cnCompare
import io.legado.app.utils.getInt
import io.legado.app.utils.putInt
import io.legado.app.utils.startActivityForBook
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Locale
class ReadRecordActivity : BaseActivity<ActivityReadRecordBinding>() {
private val adapter by lazy { RecordAdapter(this) }
private var sortMode
get() = LocalConfig.getInt("readRecordSort")
set(value) {
LocalConfig.putInt("readRecordSort", value)
}
private val searchView: SearchView by lazy {
binding.titleBar.findViewById(R.id.search_view)
}
override val binding by viewBinding(ActivityReadRecordBinding::inflate)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
initView()
initAllTime()
initData()
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.book_read_record, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
menu.findItem(R.id.menu_enable_record)?.isChecked = AppConfig.enableReadRecord
when (sortMode) {
1 -> menu.findItem(R.id.menu_sort_read_long)?.isChecked = true
2 -> menu.findItem(R.id.menu_sort_read_time)?.isChecked = true
else -> menu.findItem(R.id.menu_sort_name)?.isChecked = true
}
return super.onMenuOpened(featureId, menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_sort_name -> {
sortMode = 0
item.isChecked = true
initData()
}
R.id.menu_sort_read_long -> {
sortMode = 1
item.isChecked = true
initData()
}
R.id.menu_sort_read_time -> {
sortMode = 2
item.isChecked = true
initData()
}
R.id.menu_enable_record -> {
AppConfig.enableReadRecord = !item.isChecked
}
}
return super.onCompatOptionsItemSelected(item)
}
private fun initView() {
initSearchView()
binding.tvBookName.setText(R.string.all_read_time)
binding.tvRemove.setOnClickListener {
alert(R.string.delete, R.string.sure_del) {
yesButton {
appDb.readRecordDao.clear()
initData()
}
noButton()
}
}
binding.recyclerView.adapter = adapter
binding.recyclerView.applyNavigationBarPadding()
}
private fun initSearchView() {
//searchView.applyTint(primaryTextColor)
searchView.isSubmitButtonEnabled = true
searchView.queryHint = getString(R.string.search)
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean {
searchView.clearFocus()
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
initData(newText)
return false
}
})
}
private fun initAllTime() {
lifecycleScope.launch {
val allTime = withContext(IO) {
appDb.readRecordDao.getTotalReadTime()
}
binding.tvReadingTime.text = formatDuring(allTime)
}
}
private fun initData(searchKey: String? = null) {
lifecycleScope.launch {
val readRecords = withContext(IO) {
appDb.readRecordDao.search(searchKey ?: "").let { records ->
when (sortMode) {
1 -> records.sortedByDescending { it.readTime }
2 -> records.sortedByDescending { it.lastRead }
else -> records.sortedWith { o1, o2 ->
o1.bookName.cnCompare(o2.bookName)
}
}
}
}
adapter.setItems(readRecords)
}
}
inner class RecordAdapter(context: Context) :
RecyclerAdapter<ReadRecordShow, ItemReadRecordBinding>(context) {
private val dateFormat = SimpleDateFormat("yyyy年MM月dd日", Locale.getDefault())
override fun getViewBinding(parent: ViewGroup): ItemReadRecordBinding {
return ItemReadRecordBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemReadRecordBinding,
item: ReadRecordShow,
payloads: MutableList<Any>,
) {
binding.apply {
tvBookName.text = item.bookName
tvReadingTime.text = formatDuring(item.readTime)
if (item.lastRead > 0) {
tvLastReadTime.text = dateFormat.format(item.lastRead)
} else {
tvLastReadTime.text = "时间未知"
}
lifecycleScope.launch {
val book = withContext(IO) {
appDb.bookDao.findByName(item.bookName).firstOrNull()
}
if (book != null) {
BookCover.load(root.context, book.getDisplayCover(), false, book.origin)
.into(ivCover)
}
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemReadRecordBinding) {
binding.apply {
root.setOnClickListener {
val item = getItem(holder.layoutPosition) ?: return@setOnClickListener
lifecycleScope.launch {
val book = withContext(IO) {
appDb.bookDao.findByName(item.bookName).firstOrNull()
}
if (book == null) {
SearchActivity.start(this@ReadRecordActivity, item.bookName)
} else {
startActivityForBook(book)
}
}
}
tvRemove.setOnClickListener {
getItem(holder.layoutPosition)?.let { item ->
sureDelAlert(item)
}
}
}
}
private fun sureDelAlert(item: ReadRecordShow) {
alert(R.string.delete) {
setMessage(getString(R.string.sure_del_any, item.bookName))
yesButton {
appDb.readRecordDao.deleteByName(item.bookName)
initData()
}
noButton()
}
}
}
fun formatDuring(mss: Long): String {
val days = mss / (1000 * 60 * 60 * 24)
val hours = mss % (1000 * 60 * 60 * 24) / (1000 * 60 * 60)
val minutes = mss % (1000 * 60 * 60) / (1000 * 60)
val seconds = mss % (1000 * 60) / 1000
val d = if (days > 0) "${days}" else ""
val h = if (hours > 0) "${hours}小时" else ""
val m = if (minutes > 0) "${minutes}分钟" else ""
val s = if (seconds > 0) "${seconds}" else ""
var time = "$d$h$m$s"
if (time.isBlank()) {
time = "0秒"
}
return time
}
}
@@ -4,6 +4,7 @@ import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState
@@ -33,12 +34,15 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.legado.app.data.entities.Bookmark
import io.legado.app.ui.widget.components.EmptyMessageView
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
import io.legado.app.ui.widget.components.lazylist.Scroller
import io.legado.app.ui.widget.components.SearchBarSection
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class,
ExperimentalMaterial3ExpressiveApi::class
)
@Composable
fun AllBookmarkScreen(
viewModel: AllBookmarkViewModel = koinViewModel(),
@@ -46,7 +50,7 @@ fun AllBookmarkScreen(
) {
val context = LocalContext.current
val searchText by viewModel.searchQuery.collectAsState()
val bookmarksGrouped by viewModel.bookmarksState.collectAsState()
val uiState by viewModel.bookmarksState.collectAsState()
val collapsedGroups by viewModel.collapsedGroups.collectAsState()
var showMenu by remember { mutableStateOf(false) }
@@ -56,6 +60,7 @@ fun AllBookmarkScreen(
var pendingExportIsMd by remember { mutableStateOf(false) }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val bookmarksGrouped = (uiState as? BookmarkUiState.Success)?.bookmarks ?: emptyMap()
val allKeys = bookmarksGrouped.keys
val isAllCollapsed = allKeys.isNotEmpty() && allKeys.all { collapsedGroups.contains(it.toString()) }
@@ -137,54 +142,89 @@ fun AllBookmarkScreen(
SearchBarSection(
query = searchText,
onQueryChange = { viewModel.onSearchQueryChanged(it) },
placeholder = "模糊搜索"
placeholder = "搜索..."
)
}
}
}
) { paddingValues ->
FastScrollLazyColumn(
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
bookmarksGrouped.forEach { (headerKey, bookmarks) ->
val isCollapsed = collapsedGroups.contains(headerKey.toString())
stickyHeader(key = "${Scroller.STICKY_HEADER_KEY_PREFIX}${headerKey}") {
BookAuthorHeader(
bookTitle = headerKey.bookName,
bookAuthor = headerKey.bookAuthor,
isCollapsed = isCollapsed,
onToggle = { viewModel.toggleGroupCollapse(headerKey) }
)
}
item(key = "content_${headerKey}") {
AnimatedVisibility(
visible = !isCollapsed,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
Column(
AnimatedContent(
targetState = uiState,
label = "bookmarkTransition"
) { state ->
when (state) {
BookmarkUiState.Loading -> {
EmptyMessageView(
message = "加载中...",
isLoading = true,
modifier = Modifier
.animateContentSize()
) {
bookmarks.forEach { bookmark ->
BookmarkItem(
bookmark = bookmark,
modifier = Modifier
.animateItem()
.fillMaxWidth(),
onClick = {
editingBookmark = bookmark
showBottomSheet = true
.fillMaxSize()
)
}
is BookmarkUiState.Success -> {
if (state.bookmarks.isEmpty()) {
EmptyMessageView(
message = "没有书签!",
modifier = Modifier
.fillMaxSize()
)
} else {
FastScrollLazyColumn(
modifier = Modifier.fillMaxSize()
) {
state.bookmarks.forEach { (headerKey, bookmarks) ->
val isCollapsed = collapsedGroups.contains(headerKey.toString())
stickyHeader(key = "${Scroller.STICKY_HEADER_KEY_PREFIX}${headerKey}") {
BookAuthorHeader(
bookTitle = headerKey.bookName,
bookAuthor = headerKey.bookAuthor,
isCollapsed = isCollapsed,
onToggle = { viewModel.toggleGroupCollapse(headerKey) }
)
}
)
item(key = "content_${headerKey}") {
AnimatedVisibility(
visible = !isCollapsed,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
Column(
modifier = Modifier.animateContentSize()
) {
bookmarks.forEach { bookmark ->
BookmarkItem(
bookmark = bookmark,
modifier = Modifier
.animateItem()
.fillMaxWidth(),
onClick = {
editingBookmark = bookmark
showBottomSheet = true
}
)
}
}
}
}
}
}
}
}
is BookmarkUiState.Error -> {
EmptyMessageView(
message = state.throwable.localizedMessage ?: "发生错误",
)
}
}
}
}
@@ -407,4 +447,4 @@ fun BookmarkEditSheet(
}
)
}
}
}
@@ -15,14 +15,11 @@ import io.legado.app.utils.writeToOutputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
@@ -39,7 +36,13 @@ data class BookmarkGroupHeader(
val bookName: String,
val bookAuthor: String
) {
override fun toString(): String = "$bookName|${bookAuthor}"
override fun toString(): String = "$bookName|$bookAuthor"
}
sealed class BookmarkUiState {
object Loading : BookmarkUiState()
data class Success(val bookmarks: Map<BookmarkGroupHeader, List<Bookmark>>) : BookmarkUiState()
data class Error(val throwable: Throwable) : BookmarkUiState()
}
class AllBookmarkViewModel(
@@ -55,24 +58,28 @@ class AllBookmarkViewModel(
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
val bookmarksState: StateFlow<Map<BookmarkGroupHeader, List<Bookmark>>> = _searchQuery
val bookmarksState: StateFlow<BookmarkUiState> = _searchQuery
.debounce(300L)
.flatMapLatest { query ->
if (query.isBlank()) {
val flow = if (query.isBlank()) {
bookmarkDao.flowAll()
} else {
bookmarkDao.flowSearchAll(query)
}
flow.map<List<Bookmark>, BookmarkUiState> { list ->
BookmarkUiState.Success(list.groupBy { BookmarkGroupHeader(it.bookName, it.bookAuthor) })
}
.onStart { emit(BookmarkUiState.Loading) }
.catch { e ->
e.printStackTrace()
emit(BookmarkUiState.Error(e))
}
}
.map { list ->
list.groupBy { BookmarkGroupHeader(it.bookName, it.bookAuthor) }
}
.catch { e -> e.printStackTrace() }
.flowOn(Dispatchers.IO)
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
emptyMap()
BookmarkUiState.Loading
)
fun toggleGroupCollapse(groupKey: BookmarkGroupHeader) {
@@ -1318,7 +1318,7 @@ class ReadBookActivity : BaseReadBookActivity(),
val book = ReadBook.book ?: return
searchContentActivity.launch {
putExtra("bookUrl", book.bookUrl)
putExtra("searchWord", searchWord ?: viewModel.searchContentQuery)
putExtra("searchWord", searchWord)
putExtra("searchResultIndex", viewModel.searchResultIndex)
viewModel.searchResultList?.first()?.let {
if (it.query == viewModel.searchContentQuery) {
@@ -1,8 +1,7 @@
package io.legado.app.ui.book.readRecord
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -15,6 +14,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -182,128 +182,134 @@ fun ReadRecordScreen(
}
) { padding ->
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
val isEmpty = when (displayMode) {
DisplayMode.AGGREGATE -> state.groupedRecords.isEmpty()
DisplayMode.TIMELINE -> state.timelineRecords.isEmpty()
DisplayMode.LATEST -> state.latestRecords.isEmpty()
val contentState = when {
state.isLoading -> "LOADING"
(displayMode == DisplayMode.AGGREGATE && state.groupedRecords.isEmpty()) ||
(displayMode == DisplayMode.TIMELINE && state.timelineRecords.isEmpty()) ||
(displayMode == DisplayMode.LATEST && state.latestRecords.isEmpty()) -> "EMPTY"
else -> "CONTENT"
}
Crossfade(
targetState = isEmpty,
animationSpec = tween(durationMillis = 500),
label = "ContentCrossfade"
) { isListEmpty ->
LazyColumn(
modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection)
) {
item {
val selectedDate = state.selectedDate
if (selectedDate != null) {
val dateKey = selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE)
val dailyDetails = state.groupedRecords[dateKey] ?: emptyList()
if (dailyDetails.isNotEmpty()) {
val distinctBooks = dailyDetails.map { it.bookName }.distinct()
val dailyTime = dailyDetails.sumOf { it.readTime }
ReadingSummaryCard(
title = selectedDate.format(DateTimeFormatter.ofPattern("M月d日阅读概览")),
bookCount = distinctBooks.size,
totalTimeMillis = dailyTime,
bookNamesForCover = distinctBooks.take(3),
viewModel = viewModel,
onClick = { }
)
}
} else {
val allBooksCount = state.latestRecords.size
val totalTime = state.totalReadTime
if (allBooksCount > 0) {
ReadingSummaryCard(
title = "累计阅读成就",
bookCount = allBooksCount,
totalTimeMillis = totalTime,
bookNamesForCover = state.latestRecords.take(5).map { it.bookName },
viewModel = viewModel,
onClick = { }
)
}
}
AnimatedContent(
targetState = contentState,
label = "MainContentAnimation"
) { targetState ->
when (targetState) {
"LOADING" -> {
EmptyMessageView(
modifier = Modifier.fillMaxSize(),
message = "加载中"
)
}
item {
if (isListEmpty){
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
EmptyMessageView(
message = "没有记录"
)
}
}
"EMPTY" -> {
EmptyMessageView(
modifier = Modifier.fillMaxSize(),
message = "没有记录"
)
}
when(displayMode){
DisplayMode.AGGREGATE -> {
state.groupedRecords.forEach { (date, details) ->
val dailyTotalTime = details.sumOf { it.readTime }
stickyHeader {
DateHeader(date, dailyTotalTime)
}
items(
items = details,
key = { it.bookName + it.readTime.toString() }
) { detail ->
ReadRecordItem(
detail = detail,
viewModel = viewModel,
onClick = { onBookClick(detail.bookName) },
onDelete = { viewModel.deleteDetail(detail) },
modifier = Modifier.animateItem()
)
}
"CONTENT" -> {
LazyColumn(
modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection)
) {
item(key = "summary_card") {
SummarySection(state, viewModel)
}
}
DisplayMode.TIMELINE -> {
state.timelineRecords.forEach { (date, sessions) ->
stickyHeader { DateHeader(date) }
val timelineItems = sessions.mapIndexed { index, session ->
val showHeader = true
TimelineItem(session, showHeader)
}
items(items = timelineItems, key = { it.session.id }) { item ->
TimelineSessionItem(
item = item,
onBookClick = onBookClick,
viewModel = viewModel
)
}
}
}
DisplayMode.LATEST -> {
items(items = state.latestRecords, key = { it.bookName + it.deviceId }) { record ->
LatestReadItem(
record = record,
viewModel = viewModel,
onClick = { onBookClick(record.bookName) },
modifier = Modifier.animateItem()
)
}
renderListByMode(displayMode, state, viewModel, onBookClick)
}
}
}
}
}
}
}
@Composable
fun SummarySection(
state: ReadRecordUiState,
viewModel: ReadRecordViewModel
) {
val selectedDate = state.selectedDate
if (selectedDate != null) {
val dateKey = selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE)
val dailyDetails = state.groupedRecords[dateKey] ?: emptyList()
if (dailyDetails.isNotEmpty()) {
val distinctBooks = dailyDetails.map { it.bookName }.distinct()
val dailyTime = dailyDetails.sumOf { it.readTime }
ReadingSummaryCard(
title = selectedDate.format(DateTimeFormatter.ofPattern("M月d日阅读概览")),
bookCount = distinctBooks.size,
totalTimeMillis = dailyTime,
bookNamesForCover = distinctBooks.take(3),
viewModel = viewModel,
onClick = { }
)
}
} else {
val allBooksCount = state.latestRecords.size
val totalTime = state.totalReadTime
if (allBooksCount > 0) {
ReadingSummaryCard(
title = "累计阅读成就",
bookCount = allBooksCount,
totalTimeMillis = totalTime,
bookNamesForCover = state.latestRecords.take(5).map { it.bookName },
viewModel = viewModel,
onClick = { }
)
}
}
}
fun LazyListScope.renderListByMode(
displayMode: DisplayMode,
state: ReadRecordUiState,
viewModel: ReadRecordViewModel,
onBookClick: (String) -> Unit
) {
when (displayMode) {
DisplayMode.AGGREGATE -> {
state.groupedRecords.forEach { (date, details) ->
stickyHeader(key = "header_$date") {
DateHeader(date, details.sumOf { it.readTime })
}
items(items = details, key = { "${it.bookName}_${it.readTime}_$date" }) { detail ->
ReadRecordItem(
detail = detail,
viewModel = viewModel,
onClick = { onBookClick(detail.bookName) },
onDelete = { viewModel.deleteDetail(detail) },
modifier = Modifier.animateItem()
)
}
}
}
DisplayMode.TIMELINE -> {
state.timelineRecords.forEach { (date, sessions) ->
stickyHeader(key = "timeline_header_$date") { DateHeader(date) }
items(items = sessions, key = { it.id }) { session ->
TimelineSessionItem(
item = TimelineItem(session, true),
onBookClick = onBookClick,
viewModel = viewModel
)
}
}
}
DisplayMode.LATEST -> {
items(items = state.latestRecords, key = { it.bookName }) { record ->
LatestReadItem(
record = record,
viewModel = viewModel,
onClick = { onBookClick(record.bookName) },
modifier = Modifier.animateItem()
)
}
}
}
@@ -8,10 +8,14 @@ import io.legado.app.base.BaseComposeActivity
class SearchContentActivity : BaseComposeActivity() {
private var bookUrl: String? = null
private var searchWord: String? = null
private var searchResultIndex: Int = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
bookUrl = intent.getStringExtra("bookUrl")
searchWord = intent.getStringExtra("searchWord")
searchResultIndex = intent.getIntExtra("searchResultIndex", 0)
}
@Composable
@@ -20,6 +24,7 @@ class SearchContentActivity : BaseComposeActivity() {
bookUrl?.let {
SearchContentScreen(
bookUrl = it,
searchWord = searchWord,
onBack = { finish() }
)
}
@@ -23,6 +23,7 @@ import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
@@ -31,9 +32,15 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -48,17 +55,17 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.unit.dp
import io.legado.app.ui.widget.components.AnimatedText
import io.legado.app.ui.widget.components.AnimatedTextLine
import io.legado.app.ui.widget.components.EmptyMessageView
import io.legado.app.ui.widget.components.SearchBarSection
import io.legado.app.ui.widget.components.TextCard
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SearchContentScreen(
bookUrl: String,
searchWord: String?,
onBack: () -> Unit,
viewModel: SearchContentViewModel = koinViewModel()
) {
@@ -73,8 +80,15 @@ fun SearchContentScreen(
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
LaunchedEffect(bookUrl) {
var searchQuery by remember(searchWord) { mutableStateOf(searchWord ?: "") }
var replaceEnabled by remember { mutableStateOf(false) }
var regexReplace by remember { mutableStateOf(false) }
LaunchedEffect(bookUrl, searchWord) {
viewModel.initBook(bookUrl)
if (!searchWord.isNullOrBlank()) {
viewModel.startSearch(searchWord, replaceEnabled, regexReplace)
}
}
/*
@@ -98,9 +112,6 @@ fun SearchContentScreen(
}
}*/
var searchQuery by remember { mutableStateOf("") }
var replaceEnabled by remember { mutableStateOf(false) }
var regexReplace by remember { mutableStateOf(false) }
val contentState = when {
error != null -> SearchContentState.Error(error)
isSearching -> SearchContentState.Loading
@@ -150,12 +161,22 @@ fun SearchContentScreen(
) {
FilterChip(
selected = replaceEnabled,
onClick = { replaceEnabled = !replaceEnabled },
onClick = {
replaceEnabled = !replaceEnabled
if (searchQuery.isNotBlank()) {
viewModel.startSearch(searchQuery, replaceEnabled, regexReplace)
}
},
label = { Text("启用替换") }
)
FilterChip(
selected = regexReplace,
onClick = { regexReplace = !regexReplace },
onClick = {
regexReplace = !regexReplace
if (searchQuery.isNotBlank()) {
viewModel.startSearch(searchQuery, replaceEnabled, regexReplace)
}
},
label = { Text("正则匹配") }
)
}
@@ -168,8 +189,19 @@ fun SearchContentScreen(
}
},
floatingActionButton = {
if (isSearching) {
FloatingActionButton(onClick = { viewModel.stopSearch() }) {
TooltipBox(
positionProvider =
TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = { PlainTooltip { Text("Localized description") } },
state = rememberTooltipState(),
) {
FloatingActionButton(
modifier = Modifier.animateFloatingActionButton(
visible = isSearching,
alignment = Alignment.BottomEnd,
),
onClick = { viewModel.stopSearch() }
) {
Icon(Icons.Default.Stop, contentDescription = "停止搜索")
}
}
@@ -206,7 +238,7 @@ fun SearchContentScreen(
SearchContentState.EmptyResult -> {
EmptyMessageView(
message = "没有找到相关内容",
message = "没有找到相关内容",
modifier = Modifier
.fillMaxSize()
.wrapContentSize()
@@ -323,5 +355,3 @@ fun SearchResultItem(
}
}
}
@@ -12,12 +12,9 @@ import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -30,10 +27,12 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
@@ -332,10 +331,10 @@ fun ReplaceRuleScreen(
Column {
MediumTopAppBar(
title = {
val titleText = remember(isUploading, inSelectionMode, selectedRuleIds.size, rules.size) {
val titleText = remember(isUploading, inSelectionMode, selectedRuleIds, rules) {
when {
isUploading -> "正在上传..."
inSelectionMode -> "已选择 ${selectedRuleIds.size}/${rules.size}"
inSelectionMode -> "已选择 ${rules.count { it.id in selectedRuleIds }}/${rules.size}"
else -> "替换规则"
}
}
@@ -434,8 +433,8 @@ fun ReplaceRuleScreen(
placeholder = stringResource(id = R.string.replace_purify_search)
)
}
AnimatedVisibility(visible = !inSelectionMode) {
val allString = stringResource(R.string.all)
val allString = stringResource(R.string.all)
AnimatedVisibility(visible = groups.isNotEmpty()) {
PrimaryScrollableTabRow(
selectedTabIndex = selectedTabIndex,
edgePadding = 0.dp,
@@ -470,14 +469,21 @@ fun ReplaceRuleScreen(
}
},
floatingActionButton = {
AnimatedVisibility(
visible = !inSelectionMode,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut()
TooltipBox(
positionProvider =
TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = { PlainTooltip { Text("Localized description") } },
state = rememberTooltipState(),
) {
FloatingActionButton(onClick = {
context.startActivity(ReplaceEditActivity.startIntent(context))
}) {
FloatingActionButton(
modifier = Modifier.animateFloatingActionButton(
visible = !inSelectionMode,
alignment = Alignment.BottomEnd,
),
onClick = {
context.startActivity(ReplaceEditActivity.startIntent(context))
}
) {
Icon(Icons.Default.Add, contentDescription = "Add Rule")
}
}
@@ -568,9 +574,8 @@ fun ReplaceRuleScreen(
}
AnimatedVisibility(
visible = inSelectionMode,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 24.dp),
modifier =
Modifier.align(Alignment.BottomCenter).offset(y = -ScreenOffset).zIndex(1f),
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut()
) {
@@ -842,13 +847,9 @@ fun ReplaceRuleItem(
)
Card(
onClick = { onToggleSelection() },
modifier = modifier
.fillMaxWidth()
.combinedClickable(
onClick = { onToggleSelection() },
interactionSource = remember { MutableInteractionSource() },
indication = null
),
.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = containerColor
@@ -900,26 +901,26 @@ fun ReplaceRuleItem(
onDismissRequest = { showRuleMenu = false }
) {
DropdownMenuItem(
text = { Text("删除") },
onClick = {
onDelete()
showRuleMenu = false
}
)
DropdownMenuItem(
text = { Text("置顶") },
text = { Text("移至顶部") },
onClick = {
onToTop()
showRuleMenu = false
}
)
DropdownMenuItem(
text = { Text("置底") },
text = { Text("移至底部") },
onClick = {
onToBottom()
showRuleMenu = false
}
)
DropdownMenuItem(
text = { Text("删除") },
onClick = {
onDelete()
showRuleMenu = false
}
)
}
}
}
@@ -1,6 +1,7 @@
package io.legado.app.ui.widget.components
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -8,6 +9,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -21,10 +24,12 @@ import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun EmptyMessageView(
message: String,
modifier: Modifier = Modifier,
isLoading: Boolean = false,
faces: List<String> = listOf(
"(;′⌒`)", "(つ﹏⊂)", "(•̀ᴗ•́)و", "(๑•́ ₃ •̀๑)",
"(눈‸눈)", "(ಥ﹏ಥ)", "(。•́︿•̀。)"
@@ -40,16 +45,25 @@ fun EmptyMessageView(
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
AnimatedTextLine(
text = currentFace,
fontSize = faceTextSize,
textAlign = TextAlign.Center,
modifier = Modifier
.clickable {
currentFace = faces.random()
onFaceClick?.invoke()
}
)
AnimatedContent(
targetState = isLoading,
label = "LoadingStateAnimation"
) { loading ->
if (loading) {
LoadingIndicator()
} else {
AnimatedTextLine(
text = currentFace,
fontSize = faceTextSize,
textAlign = TextAlign.Center,
modifier = Modifier
.clickable {
currentFace = faces.random()
onFaceClick?.invoke()
}
)
}
}
Spacer(modifier = Modifier.height(8.dp))
@@ -67,6 +81,7 @@ fun EmptyMessageView(
fun EmptyMessageView(
@StringRes messageResId: Int,
modifier: Modifier = Modifier,
isLoading: Boolean = false,
faces: List<String> = listOf(
"(;′⌒`)", "(つ﹏⊂)", "(•̀ᴗ•́)و", "(๑•́ ₃ •̀๑)",
"(눈‸눈)", "(ಥ﹏ಥ)", "(。•́︿•̀。)"
@@ -78,6 +93,7 @@ fun EmptyMessageView(
EmptyMessageView(
message = message,
modifier = modifier,
isLoading = isLoading,
faces = faces,
faceTextSize = faceTextSize,
onFaceClick = onFaceClick
@@ -103,7 +103,7 @@ fun SourceInputDialog(
}
},
confirmButton = {
TextButton(
OutlinedButton(
onClick = { if (text.isNotBlank()) onConfirm(text) }
) { Text(stringResource(android.R.string.ok)) }
},