Просмотр исходного кода

feat: 阅读页下滑手势切换书签并显示右上角角标

下滑手势在当前页无书签时直接存一条(不弹编辑器),已有则删除本页
范围内的全部书签,并 toast 提示。手势归属在 isMove 成立那一帧判定,
避免与横向翻页委托同时触发动画;滚动模式与选中文本时不启用。

渲染层需在主线程热路径同步判定「本页是否有书签」,不能起协程查库,
故新增 ReaderBookmarkState 缓存当前书的书签位置,由 ViewModel 收集
flowByBook 后整体替换写入。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wuying Created Local Users 3 месяцев назад
Родитель
Сommit
b161ea2303

+ 15 - 0
app/src/main/java/io/legado/app/data/dao/BookmarkDao.kt

@@ -59,6 +59,21 @@ interface BookmarkDao {
     """)
     """)
     fun flowSearchAll(query: String): Flow<List<Bookmark>>
     fun flowSearchAll(query: String): Flow<List<Bookmark>>
 
 
+    /** 落在某章某段位置区间内的书签,供「本页是否已有书签」判定使用。 */
+    @Query(
+        """select * from bookmarks
+        where bookName = :bookName and bookAuthor = :bookAuthor
+        and chapterIndex = :chapterIndex
+        and chapterPos >= :startPos and chapterPos < :endPos"""
+    )
+    fun getByChapterRange(
+        bookName: String,
+        bookAuthor: String,
+        chapterIndex: Int,
+        startPos: Int,
+        endPos: Int,
+    ): List<Bookmark>
+
     @Insert(onConflict = OnConflictStrategy.REPLACE)
     @Insert(onConflict = OnConflictStrategy.REPLACE)
     fun insert(vararg bookmark: Bookmark)
     fun insert(vararg bookmark: Bookmark)
 
 

+ 14 - 0
app/src/main/java/io/legado/app/data/repository/BookmarkRepository.kt

@@ -25,10 +25,24 @@ class BookmarkRepository(
             dao.getByBook(bookName, bookAuthor)
             dao.getByBook(bookName, bookAuthor)
         }
         }
 
 
+    suspend fun getByChapterRange(
+        bookName: String,
+        bookAuthor: String,
+        chapterIndex: Int,
+        startPos: Int,
+        endPos: Int,
+    ): List<Bookmark> = withContext(Dispatchers.IO) {
+        dao.getByChapterRange(bookName, bookAuthor, chapterIndex, startPos, endPos)
+    }
+
     suspend fun save(bookmark: Bookmark) = withContext(Dispatchers.IO) {
     suspend fun save(bookmark: Bookmark) = withContext(Dispatchers.IO) {
         dao.insert(bookmark)
         dao.insert(bookmark)
     }
     }
 
 
+    suspend fun deleteAll(bookmarks: List<Bookmark>) = withContext(Dispatchers.IO) {
+        dao.delete(*bookmarks.toTypedArray())
+    }
+
     suspend fun saveAll(bookmarks: List<Bookmark>) = withContext(Dispatchers.IO) {
     suspend fun saveAll(bookmarks: List<Bookmark>) = withContext(Dispatchers.IO) {
         dao.insert(*bookmarks.toTypedArray())
         dao.insert(*bookmarks.toTypedArray())
     }
     }

+ 36 - 0
app/src/main/java/io/legado/app/model/ReaderBookmarkState.kt

@@ -0,0 +1,36 @@
+package io.legado.app.model
+
+import io.legado.app.data.entities.Bookmark
+
+/**
+ * 当前阅读会话的书签位置快照,供渲染层同步判定「本页是否已有书签」(右上角角标)。
+ *
+ * 渲染层(`PageView.setProgress`)在主线程热路径上按页查询,不能起协程查库;
+ * 由 `ReadBookViewModel` 收集 `BookmarkRepository.flowByBook` 后写入。
+ * 只读缓存,不参与持久化——与 [ReadSessionState] 同层。
+ */
+object ReaderBookmarkState {
+
+    /** chapterIndex → 该章内所有书签的 chapterPos。整体替换,读侧无需加锁。 */
+    @Volatile
+    private var positionsByChapter: Map<Int, List<Int>> = emptyMap()
+
+    /**
+     * 判定 `[startPos, endPos)` 这段章节内位置区间是否落有书签。
+     *
+     * @param startPos 页首字符在章节内的位置(`TextPage.chapterPosition`)
+     * @param endPos 页尾之后一个字符的位置
+     */
+    fun hasBookmarkInRange(chapterIndex: Int, startPos: Int, endPos: Int): Boolean {
+        val positions = positionsByChapter[chapterIndex] ?: return false
+        return positions.any { it >= startPos && it < endPos }
+    }
+
+    fun update(bookmarks: List<Bookmark>) {
+        positionsByChapter = bookmarks.groupBy({ it.chapterIndex }, { it.chapterPos })
+    }
+
+    fun clear() {
+        positionsByChapter = emptyMap()
+    }
+}

+ 6 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -501,6 +501,9 @@ sealed interface ReadBookIntent {
 
 
     // Bookmark
     // Bookmark
     data object AddBookmark : ReadBookIntent
     data object AddBookmark : ReadBookIntent
+
+    /** 下滑手势:本页无书签则直接存,有则取消。 */
+    data object ToggleBookmark : ReadBookIntent
     data class SaveBookmark(val bookmark: io.legado.app.data.entities.Bookmark) : ReadBookIntent
     data class SaveBookmark(val bookmark: io.legado.app.data.entities.Bookmark) : ReadBookIntent
     data class DeleteBookmark(val bookmark: io.legado.app.data.entities.Bookmark) : ReadBookIntent
     data class DeleteBookmark(val bookmark: io.legado.app.data.entities.Bookmark) : ReadBookIntent
 
 
@@ -804,6 +807,9 @@ sealed interface ReadBookEffect {
 
 
     // Menu / UI actions
     // Menu / UI actions
     data object AddBookmark : ReadBookEffect
     data object AddBookmark : ReadBookEffect
+
+    /** 书签集合变化后刷新三页的右上角书签角标。 */
+    data object UpBookmarkBadge : ReadBookEffect
     data object CancelSelect : ReadBookEffect
     data object CancelSelect : ReadBookEffect
     data object UpSystemUiVisibility : ReadBookEffect
     data object UpSystemUiVisibility : ReadBookEffect
     data class SetBrightness(val value: Int) : ReadBookEffect
     data class SetBrightness(val value: Int) : ReadBookEffect

+ 5 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -461,6 +461,7 @@ class ReadBookController(
             ReaderEvent.OpenContentEdit -> openContentEdit()
             ReaderEvent.OpenContentEdit -> openContentEdit()
             ReaderEvent.OpenSearch -> openSearch(null)
             ReaderEvent.OpenSearch -> openSearch(null)
             ReaderEvent.AddBookmark -> addBookmark()
             ReaderEvent.AddBookmark -> addBookmark()
+            ReaderEvent.ToggleBookmark -> viewModel.onIntent(ReadBookIntent.ToggleBookmark)
             ReaderEvent.ChangeReplaceRuleState -> changeReplaceRuleState()
             ReaderEvent.ChangeReplaceRuleState -> changeReplaceRuleState()
             ReaderEvent.NextChapter -> viewModel.onIntent(ReadBookIntent.NextChapter)
             ReaderEvent.NextChapter -> viewModel.onIntent(ReadBookIntent.NextChapter)
             ReaderEvent.PrevChapter -> viewModel.onIntent(ReadBookIntent.PrevChapter)
             ReaderEvent.PrevChapter -> viewModel.onIntent(ReadBookIntent.PrevChapter)
@@ -1248,6 +1249,10 @@ class ReadBookController(
             is ReadBookEffect.AddBookmark -> {
             is ReadBookEffect.AddBookmark -> {
                 // Handled by route/ViewModel — no-op here
                 // Handled by route/ViewModel — no-op here
             }
             }
+
+            is ReadBookEffect.UpBookmarkBadge -> {
+                refs?.readView?.upBookmarkBadge()
+            }
         }
         }
     }
     }
 
 

+ 39 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -62,6 +62,7 @@ import io.legado.app.model.ImageProvider
 import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadAloudSessionStore
 import io.legado.app.model.ReadAloudSessionStore
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadBook
+import io.legado.app.model.ReaderBookmarkState
 import io.legado.app.model.ReaderSession
 import io.legado.app.model.ReaderSession
 import io.legado.app.model.ReaderSessionEvent
 import io.legado.app.model.ReaderSessionEvent
 import io.legado.app.model.SourceCallBack
 import io.legado.app.model.SourceCallBack
@@ -85,6 +86,7 @@ import io.legado.app.utils.toStringArray
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.collections.immutable.toImmutableMap
 import kotlinx.collections.immutable.toImmutableMap
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.channels.awaitClose
 import kotlinx.coroutines.channels.awaitClose
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.delay
@@ -94,7 +96,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.callbackFlow
 import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.takeWhile
 import kotlinx.coroutines.flow.takeWhile
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
@@ -340,6 +346,10 @@ class ReadBookViewModel(
             override fun setActiveSheet(sheet: ReadBookSheet?) {
             override fun setActiveSheet(sheet: ReadBookSheet?) {
                 _uiState.update { it.copy(menuState = ReadBookMenuState(), activeSheet = sheet) }
                 _uiState.update { it.copy(menuState = ReadBookMenuState(), activeSheet = sheet) }
             }
             }
+
+            override fun emitEffect(effect: ReadBookEffect) {
+                _effects.tryEmit(effect)
+            }
         },
         },
         bookmarkRepository = bookmarkRepository,
         bookmarkRepository = bookmarkRepository,
     )
     )
@@ -565,6 +575,7 @@ class ReadBookViewModel(
         collectEventBus()
         collectEventBus()
         collectReaderSession()
         collectReaderSession()
         collectReadStyle()
         collectReadStyle()
+        collectBookmarks()
         replaceRuleDelegate.start()
         replaceRuleDelegate.start()
         execute { readAloudDelegate.syncConfiguredTtsVoices() }
         execute { readAloudDelegate.syncConfiguredTtsVoices() }
     }
     }
@@ -600,6 +611,32 @@ class ReadBookViewModel(
         }
         }
     }
     }
 
 
+    /**
+     * 维护 [ReaderBookmarkState]:渲染层要在主线程热路径上同步判定「本页是否有书签」画角标,
+     * 不能起协程查库,所以把当前书的书签位置整份缓存进会话快照。
+     *
+     * 书名/作者是 `bookmarks` 表的关联键(无 bookUrl 列),故以它们为 flow 的切换键。
+     */
+    @OptIn(ExperimentalCoroutinesApi::class)
+    private fun collectBookmarks() {
+        viewModelScope.launch {
+            uiState
+                .map { it.book?.let { book -> book.name to book.author } }
+                .distinctUntilChanged()
+                .flatMapLatest { key ->
+                    if (key == null) {
+                        flowOf(emptyList())
+                    } else {
+                        bookmarkRepository.flowByBook(key.first, key.second)
+                    }
+                }
+                .collect { bookmarks ->
+                    ReaderBookmarkState.update(bookmarks)
+                    _effects.tryEmit(ReadBookEffect.UpBookmarkBadge)
+                }
+        }
+    }
+
     /**
     /**
      * 消费 [ReaderSession.state](Track A A5):会话快照在任意受控 mutator 完成后发射,
      * 消费 [ReaderSession.state](Track A A5):会话快照在任意受控 mutator 完成后发射,
      * 据此驱动 UiState 刷新。与遗留 CallBack 刷新路径叠加、幂等(相同结果 StateFlow 不再发),
      * 据此驱动 UiState 刷新。与遗留 CallBack 刷新路径叠加、幂等(相同结果 StateFlow 不再发),
@@ -861,6 +898,7 @@ class ReadBookViewModel(
             is ReadBookIntent.SureNewProgress -> ReadBook.setProgress(intent.progress)
             is ReadBookIntent.SureNewProgress -> ReadBook.setProgress(intent.progress)
             is ReadBookIntent.SureSyncProgress -> ReadBook.setProgress(intent.progress)
             is ReadBookIntent.SureSyncProgress -> ReadBook.setProgress(intent.progress)
             is ReadBookIntent.AddBookmark -> bookmarkDelegate.addForCurrentPage()
             is ReadBookIntent.AddBookmark -> bookmarkDelegate.addForCurrentPage()
+            is ReadBookIntent.ToggleBookmark -> bookmarkDelegate.toggleForCurrentPage()
             is ReadBookIntent.SaveBookmark -> bookmarkDelegate.save(intent.bookmark)
             is ReadBookIntent.SaveBookmark -> bookmarkDelegate.save(intent.bookmark)
             is ReadBookIntent.DeleteBookmark -> bookmarkDelegate.delete(intent.bookmark)
             is ReadBookIntent.DeleteBookmark -> bookmarkDelegate.delete(intent.bookmark)
             is ReadBookIntent.CancelSelect -> _effects.tryEmit(ReadBookEffect.CancelSelect)
             is ReadBookIntent.CancelSelect -> _effects.tryEmit(ReadBookEffect.CancelSelect)
@@ -2479,6 +2517,7 @@ class ReadBookViewModel(
 
 
     override fun onCleared() {
     override fun onCleared() {
         translationStatusJob?.cancel()
         translationStatusJob?.cancel()
+        ReaderBookmarkState.clear()
         super.onCleared()
         super.onCleared()
         if (BaseReadAloudService.isRun && BaseReadAloudService.pause) {
         if (BaseReadAloudService.isRun && BaseReadAloudService.pause) {
             ReadAloud.stop(context)
             ReadAloud.stop(context)

+ 55 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookmarkDelegate.kt

@@ -1,5 +1,6 @@
 package io.legado.app.ui.book.read
 package io.legado.app.ui.book.read
 
 
+import io.legado.app.R
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.data.repository.BookmarkRepository
 import io.legado.app.data.repository.BookmarkRepository
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadBook
@@ -8,6 +9,7 @@ import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withContext
+import splitties.init.appCtx
 
 
 /**
 /**
  * 书签域(R2.2 续批)。
  * 书签域(R2.2 续批)。
@@ -24,6 +26,54 @@ class ReadBookmarkDelegate(
     interface Host {
     interface Host {
         /** 打开/关闭书签弹层的同时收起阅读菜单。 */
         /** 打开/关闭书签弹层的同时收起阅读菜单。 */
         fun setActiveSheet(sheet: ReadBookSheet?)
         fun setActiveSheet(sheet: ReadBookSheet?)
+
+        fun emitEffect(effect: ReadBookEffect)
+    }
+
+    /**
+     * 下滑手势:本页无书签则直接存一条(不弹编辑器),已有则删掉本页范围内的全部书签。
+     *
+     * 页范围取 `[页首位置, 下一页页首位置)`——末页取到章节已排版长度,
+     * 与 [addForCurrentPage] 存入的 `ReadBook.durChapterPos` 落点一致。
+     */
+    fun toggleForCurrentPage() {
+        scope.launch(IO) {
+            val book = ReadBook.book ?: return@launch
+            val chapter = ReadBook.curTextChapter ?: return@launch
+            val pageIndex = ReadBook.durPageIndex
+            val page = chapter.getPage(pageIndex) ?: return@launch
+            val startPos = page.chapterPosition
+            val endPos = chapter.getPage(pageIndex + 1)?.chapterPosition
+                ?: (startPos + page.charSize)
+            val existing = bookmarkRepository.getByChapterRange(
+                bookName = book.name,
+                bookAuthor = book.author,
+                chapterIndex = chapter.chapter.index,
+                startPos = startPos,
+                endPos = endPos,
+            )
+            if (existing.isEmpty()) {
+                bookmarkRepository.save(
+                    Bookmark(
+                        bookName = book.name,
+                        bookAuthor = book.author,
+                        chapterIndex = chapter.chapter.index,
+                        chapterName = chapter.title,
+                        chapterPos = ReadBook.durChapterPos,
+                        bookText = page.text.replace(BOOK_TEXT_MARKS, "").trim(),
+                        content = "",
+                    )
+                )
+                host.emitEffect(
+                    ReadBookEffect.ShowToast(appCtx.getString(R.string.bookmark_added))
+                )
+            } else {
+                bookmarkRepository.deleteAll(existing)
+                host.emitEffect(
+                    ReadBookEffect.ShowToast(appCtx.getString(R.string.bookmark_removed))
+                )
+            }
+        }
     }
     }
 
 
     /** 从菜单「加书签」进入:以当前页正文预填草稿。 */
     /** 从菜单「加书签」进入:以当前页正文预填草稿。 */
@@ -65,4 +115,9 @@ class ReadBookmarkDelegate(
             host.setActiveSheet(null)
             host.setActiveSheet(null)
         }
         }
     }
     }
+
+    private companion object {
+        /** 与 ReadBookController.addBookmark 一致:剔除正文里的排版占位符。 */
+        val BOOK_TEXT_MARKS = Regex("[袮꧁]")
+    }
 }
 }

+ 24 - 0
app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt

@@ -24,6 +24,7 @@ import io.legado.app.databinding.ViewBookPageBinding
 import io.legado.app.help.config.CustomTipPlaceholder
 import io.legado.app.help.config.CustomTipPlaceholder
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadSessionState
 import io.legado.app.model.ReadSessionState
+import io.legado.app.model.ReaderBookmarkState
 import io.legado.app.ui.book.read.page.entities.TextLine
 import io.legado.app.ui.book.read.page.entities.TextLine
 import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.entities.TextPos
 import io.legado.app.ui.book.read.page.entities.TextPos
@@ -78,6 +79,26 @@ class PageView(
 
 
     private var currentTextPage: TextPage? = null
     private var currentTextPage: TextPage? = null
 
 
+    /**
+     * 右上角书签角标:本页范围内落有书签时显示。
+     *
+     * 滚动模式一屏可见多页,`currentTextPage` 不再唯一对应可见内容,故该模式下不显示——
+     * 与下滑手势在滚动模式下同样不启用保持一致。
+     */
+    fun upBookmarkBadge() {
+        val page = currentTextPage
+        val visible = page != null && !isScroll && !page.isMsgPage && page.lineSize > 0 &&
+                ReaderBookmarkState.hasBookmarkInRange(
+                    chapterIndex = page.chapterIndex,
+                    startPos = page.chapterPosition,
+                    endPos = page.chapterPosition + page.charSize,
+                )
+        binding.ivBookmarkBadge.isGone = !visible
+        if (visible) {
+            binding.ivBookmarkBadge.setColorFilter(TipStyleProvider.style.textColor)
+        }
+    }
+
     val headerHeight: Int
     val headerHeight: Int
         get() {
         get() {
             val h1 = if (binding.vwStatusBar.isGone) 0 else binding.vwStatusBar.height
             val h1 = if (binding.vwStatusBar.isGone) 0 else binding.vwStatusBar.height
@@ -174,6 +195,7 @@ class PageView(
     /** 仅更新日夜模式相关颜色,避免主题切换时重新加载字体和重排正文。 */
     /** 仅更新日夜模式相关颜色,避免主题切换时重新加载字体和重排正文。 */
     fun upThemeColors() = binding.run {
     fun upThemeColors() = binding.run {
         applyTipColors(TipStyleProvider.style)
         applyTipColors(TipStyleProvider.style)
+        upBookmarkBadge()
         contentTextView.invalidate()
         contentTextView.invalidate()
     }
     }
 
 
@@ -644,6 +666,7 @@ class PageView(
             "$wholeBookPageDisplay  $readProgress"
             "$wholeBookPageDisplay  $readProgress"
         )
         )
         upCustomTip(textPage)
         upCustomTip(textPage)
+        upBookmarkBadge()
         this@PageView.layoutSync()
         this@PageView.layoutSync()
     }
     }
 
 
@@ -682,6 +705,7 @@ class PageView(
     fun setIsScroll(value: Boolean) {
     fun setIsScroll(value: Boolean) {
         isScroll = value
         isScroll = value
         binding.contentTextView.setIsScroll(value)
         binding.contentTextView.setIsScroll(value)
+        upBookmarkBadge()
     }
     }
 
 
     /**
     /**

+ 41 - 2
app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt

@@ -42,6 +42,7 @@ import io.legado.app.ui.book.read.page.provider.LayoutProgressListener
 import io.legado.app.ui.book.read.page.provider.TextPageFactory
 import io.legado.app.ui.book.read.page.provider.TextPageFactory
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.utils.activity
 import io.legado.app.utils.activity
+import io.legado.app.utils.dpToPx
 import io.legado.app.utils.invisible
 import io.legado.app.utils.invisible
 
 
 import io.legado.app.utils.throttle
 import io.legado.app.utils.throttle
@@ -105,6 +106,15 @@ class ReadView(
     private var pressOnTextSelected = false
     private var pressOnTextSelected = false
     private val initialTextPos = TextPos(0, 0, 0)
     private val initialTextPos = TextPos(0, 0, 0)
 
 
+    /**
+     * 本次手势已被判定为「下滑切换书签」。
+     *
+     * 归属在 [isMove] 刚成立那一帧就定下来(此时首次拿到方向),之后的 MOVE 一律不再喂给
+     * [pageDelegate],否则横向委托会同时启动翻页动画。
+     */
+    private var isBookmarkSwipe = false
+    private val bookmarkSwipeMinDistance by lazy { BOOKMARK_SWIPE_MIN_DISTANCE_DP.dpToPx() }
+
     private val slopSquare by lazy { ViewConfiguration.get(context).scaledTouchSlop }
     private val slopSquare by lazy { ViewConfiguration.get(context).scaledTouchSlop }
     private var pageSlopSquare: Int = slopSquare
     private var pageSlopSquare: Int = slopSquare
     var pageSlopSquare2: Int = pageSlopSquare * pageSlopSquare
     var pageSlopSquare2: Int = pageSlopSquare * pageSlopSquare
@@ -233,6 +243,7 @@ class ReadView(
                 postDelayed(longPressRunnable, longPressTimeout)
                 postDelayed(longPressRunnable, longPressTimeout)
                 pressDown = true
                 pressDown = true
                 isMove = false
                 isMove = false
+                isBookmarkSwipe = false
                 pageDelegate?.onTouch(event)
                 pageDelegate?.onTouch(event)
                 pageDelegate?.onDown()
                 pageDelegate?.onDown()
                 setStartPoint(event.x, event.y, false)
                 setStartPoint(event.x, event.y, false)
@@ -244,13 +255,18 @@ class ReadView(
                 val absY = abs(startY - event.y)
                 val absY = abs(startY - event.y)
                 if (!isMove) {
                 if (!isMove) {
                     isMove = absX > slopSquare || absY > slopSquare
                     isMove = absX > slopSquare || absY > slopSquare
+                    if (isMove) {
+                        // 手势归属只在这一帧判定:竖直向下且竖直位移占主导 → 归书签手势。
+                        isBookmarkSwipe = !isScroll && !isTextSelected &&
+                                event.y - startY > 0 && absY > absX * VERTICAL_DOMINANCE_RATIO
+                    }
                 }
                 }
                 if (isMove) {
                 if (isMove) {
                     longPressed = false
                     longPressed = false
                     removeCallbacks(longPressRunnable)
                     removeCallbacks(longPressRunnable)
                     if (isTextSelected) {
                     if (isTextSelected) {
                         selectText(event.x, event.y)
                         selectText(event.x, event.y)
-                    } else {
+                    } else if (!isBookmarkSwipe) {
                         pageDelegate?.onTouch(event)
                         pageDelegate?.onTouch(event)
                     }
                     }
                 }
                 }
@@ -271,7 +287,12 @@ class ReadView(
                         return true
                         return true
                     }
                     }
                 }
                 }
-                if (isTextSelected) {
+                if (isBookmarkSwipe) {
+                    if (event.y - startY >= bookmarkSwipeMinDistance) {
+                        eventListener.onEvent(ReaderEvent.ToggleBookmark)
+                    }
+                    isBookmarkSwipe = false
+                } else if (isTextSelected) {
                     callBack.showTextActionMenu()
                     callBack.showTextActionMenu()
                 } else if (pageDelegate!!.isMoved) {
                 } else if (pageDelegate!!.isMoved) {
                     pageDelegate?.onTouch(event)
                     pageDelegate?.onTouch(event)
@@ -283,6 +304,7 @@ class ReadView(
                 removeCallbacks(longPressRunnable)
                 removeCallbacks(longPressRunnable)
                 if (!pressDown) return true
                 if (!pressDown) return true
                 pressDown = false
                 pressDown = false
+                isBookmarkSwipe = false
                 if (isTextSelected) {
                 if (isTextSelected) {
                     callBack.showTextActionMenu()
                     callBack.showTextActionMenu()
                 } else if (pageDelegate!!.isMoved) {
                 } else if (pageDelegate!!.isMoved) {
@@ -768,6 +790,15 @@ class ReadView(
         nextPage.upTime()
         nextPage.upTime()
     }
     }
 
 
+    /**
+     * 更新右上角书签角标
+     */
+    fun upBookmarkBadge() {
+        curPage.upBookmarkBadge()
+        prevPage.upBookmarkBadge()
+        nextPage.upBookmarkBadge()
+    }
+
     /**
     /**
      * 更新电量信息
      * 更新电量信息
      */
      */
@@ -892,4 +923,12 @@ class ReadView(
         fun showTextActionMenu()
         fun showTextActionMenu()
         fun upSystemUiVisibility()
         fun upSystemUiVisibility()
     }
     }
+
+    private companion object {
+        /** 下滑切换书签所需的最小竖直位移。取得比翻页 slop 大,避免与轻微斜划抢手势。 */
+        const val BOOKMARK_SWIPE_MIN_DISTANCE_DP = 80f
+
+        /** 竖直位移需超过水平位移的这个倍数,才判定为下滑而非斜向翻页。 */
+        const val VERTICAL_DOMINANCE_RATIO = 1.5f
+    }
 }
 }

+ 3 - 0
app/src/main/java/io/legado/app/ui/book/read/page/ReaderEvent.kt

@@ -23,6 +23,9 @@ sealed interface ReaderEvent {
 
 
     data object AddBookmark : ReaderEvent
     data object AddBookmark : ReaderEvent
 
 
+    /** 下滑手势:切换当前页书签。 */
+    data object ToggleBookmark : ReaderEvent
+
     data object ChangeReplaceRuleState : ReaderEvent
     data object ChangeReplaceRuleState : ReaderEvent
 
 
     data object NextChapter : ReaderEvent
     data object NextChapter : ReaderEvent

+ 12 - 0
app/src/main/res/layout/view_book_page.xml

@@ -73,6 +73,18 @@
         app:layout_constraintBottom_toTopOf="@id/vw_bottom_divider"
         app:layout_constraintBottom_toTopOf="@id/vw_bottom_divider"
         app:layout_constraintTop_toBottomOf="@id/vw_top_divider" />
         app:layout_constraintTop_toBottomOf="@id/vw_top_divider" />
 
 
+    <ImageView
+        android:id="@+id/iv_bookmark_badge"
+        android:layout_width="16dp"
+        android:layout_height="16dp"
+        android:layout_marginTop="2dp"
+        android:layout_marginEnd="4dp"
+        android:contentDescription="@string/a11y_page_bookmarked"
+        android:src="@drawable/ic_bookmark"
+        android:visibility="gone"
+        app:layout_constraintEnd_toEndOf="@id/content_text_view"
+        app:layout_constraintTop_toTopOf="@id/content_text_view" />
+
     <View
     <View
         android:id="@+id/vw_bottom_divider"
         android:id="@+id/vw_bottom_divider"
         android:layout_width="match_parent"
         android:layout_width="match_parent"

+ 3 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -332,6 +332,9 @@
     <string name="group_yf">养肥区</string>
     <string name="group_yf">养肥区</string>
     <string name="bookmark">书签</string>
     <string name="bookmark">书签</string>
     <string name="bookmark_add">添加书签</string>
     <string name="bookmark_add">添加书签</string>
+    <string name="bookmark_added">已添加书签</string>
+    <string name="bookmark_removed">已取消书签</string>
+    <string name="a11y_page_bookmarked">本页已添加书签</string>
     <string name="action_del">删除</string>
     <string name="action_del">删除</string>
     <string name="load_over_time">加载超时</string>
     <string name="load_over_time">加载超时</string>
     <string name="join_group">关注:%s</string>
     <string name="join_group">关注:%s</string>

+ 3 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -278,6 +278,9 @@
     <string name="group_yf">養肥區</string>
     <string name="group_yf">養肥區</string>
     <string name="bookmark">書籤</string>
     <string name="bookmark">書籤</string>
     <string name="bookmark_add">添加書籤</string>
     <string name="bookmark_add">添加書籤</string>
+    <string name="bookmark_added">已添加書籤</string>
+    <string name="bookmark_removed">已取消書籤</string>
+    <string name="a11y_page_bookmarked">本頁已添加書籤</string>
     <string name="action_del">刪除</string>
     <string name="action_del">刪除</string>
     <string name="load_over_time">加載超時</string>
     <string name="load_over_time">加載超時</string>
     <string name="join_group">關注: %s</string>
     <string name="join_group">關注: %s</string>

+ 3 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -280,6 +280,9 @@
     <string name="group_yf">養肥區</string>
     <string name="group_yf">養肥區</string>
     <string name="bookmark">書籤</string>
     <string name="bookmark">書籤</string>
     <string name="bookmark_add">新增書籤</string>
     <string name="bookmark_add">新增書籤</string>
+    <string name="bookmark_added">已新增書籤</string>
+    <string name="bookmark_removed">已取消書籤</string>
+    <string name="a11y_page_bookmarked">本頁已新增書籤</string>
     <string name="action_del">刪除</string>
     <string name="action_del">刪除</string>
     <string name="load_over_time">載入超時</string>
     <string name="load_over_time">載入超時</string>
     <string name="join_group">關注:%s</string>
     <string name="join_group">關注:%s</string>

+ 3 - 0
app/src/main/res/values/strings.xml

@@ -362,6 +362,9 @@
     <string name="group_yf">Awaiting more</string>
     <string name="group_yf">Awaiting more</string>
     <string name="bookmark">Bookmarks</string>
     <string name="bookmark">Bookmarks</string>
     <string name="bookmark_add">Add to Bookmarks</string>
     <string name="bookmark_add">Add to Bookmarks</string>
+    <string name="bookmark_added">Bookmark added</string>
+    <string name="bookmark_removed">Bookmark removed</string>
+    <string name="a11y_page_bookmarked">This page is bookmarked</string>
     <string name="action_del">Delete</string>
     <string name="action_del">Delete</string>
     <string name="load_over_time">Loading timeout</string>
     <string name="load_over_time">Loading timeout</string>
     <string name="join_group">Follow:%s</string>
     <string name="join_group">Follow:%s</string>

+ 60 - 0
app/src/test/java/io/legado/app/model/ReaderBookmarkStateTest.kt

@@ -0,0 +1,60 @@
+package io.legado.app.model
+
+import io.legado.app.data.entities.Bookmark
+import org.junit.After
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ReaderBookmarkStateTest {
+
+    @After
+    fun tearDown() {
+        ReaderBookmarkState.clear()
+    }
+
+    private fun bookmark(chapterIndex: Int, chapterPos: Int) = Bookmark(
+        time = chapterIndex * 1000L + chapterPos,
+        bookName = "book",
+        bookAuthor = "author",
+        chapterIndex = chapterIndex,
+        chapterPos = chapterPos,
+    )
+
+    @Test
+    fun `page start is inclusive and page end is exclusive`() {
+        ReaderBookmarkState.update(listOf(bookmark(chapterIndex = 3, chapterPos = 200)))
+
+        // 页首命中
+        assertTrue(ReaderBookmarkState.hasBookmarkInRange(3, startPos = 200, endPos = 400))
+        // 上一页的尾界不应吞掉下一页的页首书签
+        assertFalse(ReaderBookmarkState.hasBookmarkInRange(3, startPos = 0, endPos = 200))
+        // 下一页不应命中
+        assertFalse(ReaderBookmarkState.hasBookmarkInRange(3, startPos = 400, endPos = 600))
+    }
+
+    @Test
+    fun `bookmarks are isolated per chapter`() {
+        ReaderBookmarkState.update(listOf(bookmark(chapterIndex = 3, chapterPos = 200)))
+
+        assertFalse(ReaderBookmarkState.hasBookmarkInRange(4, startPos = 200, endPos = 400))
+        assertFalse(ReaderBookmarkState.hasBookmarkInRange(2, startPos = 200, endPos = 400))
+    }
+
+    @Test
+    fun `update replaces the previous snapshot`() {
+        ReaderBookmarkState.update(listOf(bookmark(chapterIndex = 3, chapterPos = 200)))
+        ReaderBookmarkState.update(listOf(bookmark(chapterIndex = 5, chapterPos = 10)))
+
+        assertFalse(ReaderBookmarkState.hasBookmarkInRange(3, startPos = 200, endPos = 400))
+        assertTrue(ReaderBookmarkState.hasBookmarkInRange(5, startPos = 0, endPos = 100))
+    }
+
+    @Test
+    fun `clear drops every bookmark`() {
+        ReaderBookmarkState.update(listOf(bookmark(chapterIndex = 3, chapterPos = 200)))
+        ReaderBookmarkState.clear()
+
+        assertFalse(ReaderBookmarkState.hasBookmarkInRange(3, startPos = 200, endPos = 400))
+    }
+}

+ 1 - 1
gradle.properties

@@ -48,4 +48,4 @@ android.r8.strictFullModeForKeepRules=false
 org.gradle.configuration-cache=true
 org.gradle.configuration-cache=true
 android.newDsl=false
 android.newDsl=false
 org.gradle.java.installations.auto-detect=true
 org.gradle.java.installations.auto-detect=true
-org.gradle.java.installations.auto-download=false
+org.gradle.java.installations.auto-download=false