diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/const/component/DismissableItem.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/const/component/DismissableItem.kt new file mode 100644 index 00000000..016fef73 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/const/component/DismissableItem.kt @@ -0,0 +1,67 @@ +package com.slax.reader.const.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import kotlinx.coroutines.delay + +/** + * 带消失动画的容器:先变暗,再收缩淡出。 + * + * @param isDismissed 触发消失动画 + * @param durationMs 收缩+淡出阶段时长(毫秒) + * @param onDismissed 动画完全结束后的回调 + * @param content 被包裹的内容 + */ +@Composable +fun DismissableItem( + isDismissed: Boolean, + durationMs: Int = 600, + onDismissed: () -> Unit = {}, + content: @Composable () -> Unit, +) { + var itemVisible by remember { mutableStateOf(true) } + val overlayAlpha = remember { Animatable(0f) } + + LaunchedEffect(isDismissed) { + if (isDismissed && itemVisible) { + delay(50) + overlayAlpha.animateTo(0.08f, tween(durationMs / 2)) + delay(100) + itemVisible = false + } + } + + AnimatedVisibility( + visible = itemVisible, + exit = shrinkVertically( + animationSpec = tween(durationMs + 100, easing = FastOutSlowInEasing) + ) + fadeOut(animationSpec = tween(durationMs)), + ) { + Box { + content() + if (overlayAlpha.value > 0f) { + Box( + modifier = Modifier + .matchParentSize() + .background(Color.Black.copy(alpha = overlayAlpha.value)) + ) + } + } + } + + if (!itemVisible) { + LaunchedEffect(Unit) { + delay(durationMs + 150L) + onDismissed() + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/di/di.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/di/di.kt index 6fdeba14..af6c1804 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/di/di.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/di/di.kt @@ -13,6 +13,7 @@ import com.slax.reader.data.file.FileManager import com.slax.reader.data.network.ApiService import com.slax.reader.data.preferences.preferencesPlatformModule import com.slax.reader.domain.auth.AuthDomain +import com.slax.reader.domain.bookmark.BookmarkActionBus import com.slax.reader.domain.coordinator.CoordinatorDomain import com.slax.reader.domain.image.ImageDownloadManager import com.slax.reader.domain.sync.BackgroundDomain @@ -75,6 +76,7 @@ val domainModule = module { single { BackgroundDomain(get(), get(), get(), get(), get(), get()) } single { CoordinatorDomain(get(), get(), get()) } single { ImageDownloadManager(get(), get()) } + single { BookmarkActionBus() } } val appModule = module { diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/domain/bookmark/BookmarkActionBus.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/domain/bookmark/BookmarkActionBus.kt new file mode 100644 index 00000000..fdb6c760 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/domain/bookmark/BookmarkActionBus.kt @@ -0,0 +1,21 @@ +package com.slax.reader.domain.bookmark + +/** + * 跨页面书签操作事件总线(单例)。 + * 详情页发出删除事件后,由列表页在可见时消费并播放动画。 + */ +class BookmarkActionBus { + // 待消费的删除 ID,保证即使列表页 composable 不在组合树中也不会丢失事件 + var pendingDeleteId: String? = null + private set + + fun emitDelete(bookmarkId: String) { + pendingDeleteId = bookmarkId + } + + fun consumePendingDelete(): String? { + val id = pendingDeleteId + pendingDeleteId = null + return id + } +} diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt index 625ee2de..2f20d1e1 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/ViewModel.kt @@ -14,6 +14,7 @@ import com.slax.reader.data.network.ApiService import com.slax.reader.data.preferences.AppPreferences import com.slax.reader.data.preferences.ContinueReadingBookmark import com.slax.reader.domain.sync.BackgroundDomain +import com.slax.reader.domain.bookmark.BookmarkActionBus import com.slax.reader.ui.bookmark.states.BookmarkDelegate import com.slax.reader.ui.bookmark.states.BookmarkOverlay import com.slax.reader.ui.bookmark.states.CommentDelegate @@ -51,7 +52,6 @@ sealed interface BookmarkDetailEffect { data object NavigateBack : BookmarkDetailEffect data object NavigateToSubscription : BookmarkDetailEffect data class NavigateToFeedback(val params: FeedbackPageParams) : BookmarkDetailEffect - data class ScrollToAnchor(val anchor: String) : BookmarkDetailEffect } @@ -70,6 +70,7 @@ class BookmarkDetailViewModel( private val apiService: ApiService, private val appPreferences: AppPreferences, private val database: PowerSyncDatabase, + private val bookmarkActionBus: BookmarkActionBus, ) : ViewModel() { companion object { @@ -197,17 +198,13 @@ class BookmarkDetailViewModel( fun confirmDeleteBookmark() { viewModelScope.launch { - runCatching { bookmarkDelegate.deleteBookmark() } - .onSuccess { - bookmarkEvent.action("delete").send() - _deleteConfirmVisible.value = false - overlayDelegate.dismissOverlay(BookmarkOverlay.Toolbar) - requestNavigateBack() - } - .onFailure { - bookmarkEvent.action("delete_failed").send() - _deleteConfirmVisible.value = false - } + val id = _bookmarkId.value ?: return@launch + _deleteConfirmVisible.value = false + overlayDelegate.dismissOverlay(BookmarkOverlay.Toolbar) + // 只通知列表页播放删除动画,实际删除由列表页的 ViewModel 在动画结束后执行 + bookmarkActionBus.emitDelete(id) + bookmarkEvent.action("delete").send() + requestNavigateBack() } } diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt index e407a04c..7f52fbb5 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/bookmark/states/OutlineDelegate.kt @@ -72,6 +72,7 @@ class OutlineDelegate( val id = currentBookmarkId ?: return val position = currentScrollPosition if (position < 0) return + scope.launch { withContext(NonCancellable + Dispatchers.IO) { localBookmarkDao.updateLocalBookmarkOutlineScrollPosition(id, position) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/ViewModel.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/ViewModel.kt index f22d02de..90b12e5a 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/ViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/ViewModel.kt @@ -7,22 +7,29 @@ import com.slax.reader.data.database.dao.LocalBookmarkDao import com.slax.reader.data.database.dao.UserDao import com.slax.reader.data.database.model.InboxListBookmarkItem import com.slax.reader.domain.coordinator.CoordinatorDomain +import com.slax.reader.domain.bookmark.BookmarkActionBus import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class InboxListViewModel( private val userDao: UserDao, private val bookmarkDao: BookmarkDao, private val localBookmarkDao: LocalBookmarkDao, - private val coordinatorDomain: CoordinatorDomain + private val coordinatorDomain: CoordinatorDomain, + private val bookmarkActionBus: BookmarkActionBus, ) : ViewModel() { val userInfo = userDao.watchUserInfo() val syncState = coordinatorDomain.syncState @@ -45,13 +52,60 @@ class InboxListViewModel( private val _scrollToTopEvent = MutableSharedFlow(extraBufferCapacity = 1) val scrollToTopEvent: SharedFlow = _scrollToTopEvent.asSharedFlow() + private val _pendingDeleteId = MutableStateFlow(null) + val pendingDeleteId: StateFlow = _pendingDeleteId.asStateFlow() + private val _processingUrlEvent = MutableSharedFlow(extraBufferCapacity = 1) val processingUrlEvent: SharedFlow = _processingUrlEvent.asSharedFlow() + // 兜底删除定时器,确保动画被中断时也能执行删除 + private var pendingDeleteJob: Job? = null + fun scrollToTop() { _scrollToTopEvent.tryEmit(Unit) } + /** + * 当列表页 composable 进入组合树时调用, + * 从 BookmarkActionBus 消费待删除 ID 并触发动画。 + * 确保动画只在列表页可见时才播放。 + */ + fun activatePendingDelete() { + val id = bookmarkActionBus.consumePendingDelete() ?: return + _pendingDeleteId.value = id + startDeleteFallback(id) + } + + fun onDeleteAnimationFinished() { + val id = _pendingDeleteId.value + _pendingDeleteId.value = null + pendingDeleteJob?.cancel() + if (id != null) { + viewModelScope.launch { commitDelete(id) } + } + } + + private suspend fun commitDelete(bookmarkId: String) { + withContext(Dispatchers.IO) { bookmarkDao.deleteBookmark(bookmarkId) } + } + + private fun startDeleteFallback(id: String) { + pendingDeleteJob?.cancel() + pendingDeleteJob = viewModelScope.launch { + delay(3000L) + commitDelete(id) + } + } + + /** + * 列表页直接触发删除(长按菜单), + * 无需经过 ActionBus 缓冲,直接设置 pendingDeleteId 播放动画。 + */ + fun requestDeleteBookmark(bookmarkId: String) { + _pendingDeleteId.value = bookmarkId + startDeleteFallback(bookmarkId) + } + fun emitProcessingUrl(url: String) { _processingUrlEvent.tryEmit(url) } @@ -70,11 +124,7 @@ class InboxListViewModel( bookmarkDao.updateBookmarkArchive(bookmarkId, if (isArchive) 1 else 0) } - suspend fun deleteBookmark(bookmarkId: String) = withContext(Dispatchers.IO) { - bookmarkDao.deleteBookmark(bookmarkId) - } - suspend fun addLinkBookmark(url: String) = withContext(Dispatchers.IO) { return@withContext bookmarkDao.createBookmark(url) } -} +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/compenents/BookmarkItem.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/compenents/BookmarkItem.kt index ce75ae6f..7a9381a4 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/compenents/BookmarkItem.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/compenents/BookmarkItem.kt @@ -429,7 +429,7 @@ fun BookmarkItemRow( scope.launch { menuTriggerSource = MenuTriggerSource.NONE isLongPressed = false - viewModel.deleteBookmark(bookmark.id) + viewModel.requestDeleteBookmark(bookmark.id) } } ) diff --git a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/compenents/List.kt b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/compenents/List.kt index 7110c2f8..e70e037a 100644 --- a/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/compenents/List.kt +++ b/composeApp/src/commonMain/kotlin/com/slax/reader/ui/inbox/compenents/List.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController +import com.slax.reader.const.component.DismissableItem import com.slax.reader.data.database.model.InboxListBookmarkItem import com.slax.reader.ui.inbox.InboxListViewModel import com.slax.reader.utils.i18n @@ -33,6 +34,7 @@ fun ArticleList( println("[watch][UI] recomposition ArticleList") val bookmarks by viewModel.bookmarks.collectAsState() + val pendingDeleteId by viewModel.pendingDeleteId.collectAsState() val lazyListState = rememberLazyListState() val dividerLine: @Composable () -> Unit = remember { @@ -45,6 +47,11 @@ fun ArticleList( } } + // 列表页进入组合树时,消费详情页传来的待删除 ID 并触发动画 + LaunchedEffect(Unit) { + viewModel.activatePendingDelete() + } + if (bookmarks.isEmpty()) { val hasSynced by viewModel.hasSynced.collectAsState() Box( @@ -68,15 +75,21 @@ fun ArticleList( items = bookmarks, key = { _, bookmark -> bookmark.id }, contentType = { _, _ -> "bookmark" } - ) { index, bookmark -> - BookmarkItemRow( - navCtrl = navCtrl, - viewModel = viewModel, - bookmark = bookmark, - onEditTitle = onEditTitle - ) - - dividerLine() + ) { _, bookmark -> + DismissableItem( + isDismissed = bookmark.id == pendingDeleteId, + onDismissed = { viewModel.onDeleteAnimationFinished() }, + ) { + Column { + BookmarkItemRow( + navCtrl = navCtrl, + viewModel = viewModel, + bookmark = bookmark, + onEditTitle = onEditTitle + ) + dividerLine() + } + } } item {