a328929 3 месяцев назад
Родитель
Сommit
bb2d67d8d1

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

@@ -11,8 +11,11 @@ import android.view.MotionEvent
 import android.view.View
 import android.view.ViewConfiguration
 import android.view.WindowInsets
+import android.view.accessibility.AccessibilityEvent
 import android.view.accessibility.AccessibilityNodeInfo
 import android.widget.FrameLayout
+import android.widget.TextView
+import androidx.core.view.ViewCompat
 import io.legado.app.BuildConfig
 import io.legado.app.R
 import io.legado.app.constant.PageAnim
@@ -118,6 +121,7 @@ class ReadView(
     private val upProgressThrottle = throttle(200) { post { upProgress() } }
     val autoPager = AutoPager(this)
     val isAutoPage get() = autoPager.isRunning
+    private var accessibilityPageText = ""
 
     init {
         if (!isInEditMode) {
@@ -130,10 +134,16 @@ class ReadView(
         addView(nextPage)
         addView(curPage)
         addView(prevPage)
+        // 三个 PageView 只负责把同一页绘制到 Canvas;它们的内部 View 没有正文语义。
+        // 读屏应只命中下面由 ReadView 提供的当前页文本节点。
+        listOf(prevPage, curPage, nextPage).forEach {
+            it.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
+        }
         descendantFocusability = FOCUS_BLOCK_DESCENDANTS
         importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
         isClickable = true
         isFocusable = true
+        ViewCompat.setScreenReaderFocusable(this, true)
         prevPage.invisible()
         nextPage.invisible()
         curPage.markAsMainView()
@@ -183,6 +193,9 @@ class ReadView(
         return true
     }
 
+    // Canvas 正文没有子级语义,触摸探索必须命中本页级节点。
+    override fun onInterceptHoverEvent(event: MotionEvent): Boolean = true
+
     /**
      * 触摸事件
      */
@@ -289,7 +302,9 @@ class ReadView(
 
     override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
         super.onInitializeAccessibilityNodeInfo(info)
-        info.className = ReadView::class.java.name
+        // The Canvas-rendered page is exposed as one stable text node.
+        info.className = TextView::class.java.name
+        info.text = accessibilityPageText
         info.isScrollable = true
         info.addAction(
             AccessibilityNodeInfo.AccessibilityAction(
@@ -311,6 +326,13 @@ class ReadView(
         )
     }
 
+    override fun onInitializeAccessibilityEvent(event: AccessibilityEvent) {
+        super.onInitializeAccessibilityEvent(event)
+        if (event.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) {
+            event.contentChangeTypes = AccessibilityEvent.CONTENT_CHANGE_TYPE_TEXT
+        }
+    }
+
     override fun performAccessibilityAction(action: Int, arguments: Bundle?): Boolean {
         return when (action) {
             AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD -> {
@@ -628,7 +650,7 @@ class ReadView(
     override fun upContent(relativePosition: Int, resetPageOffset: Boolean) {
         post {
             val text = pageFactory.curPage.text
-            contentDescription = text
+            updateAccessibilityPageText(text)
             if (BuildConfig.DEBUG) {
                 // Device-independent readiness/page-change signal for the debug
                 // scenario runner (uiautomator does not expose ReadView on all
@@ -661,6 +683,14 @@ class ReadView(
         callBack.screenOffTimerStart()
     }
 
+    private fun updateAccessibilityPageText(text: String) {
+        if (accessibilityPageText == text) return
+        accessibilityPageText = text
+        if (isAttachedToWindow) {
+            sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED)
+        }
+    }
+
     private fun upProgress() {
         curPage.setProgress(pageFactory.curPage)
     }

+ 33 - 0
app/src/test/java/io/legado/app/ui/book/read/page/ReadViewBoundaryTest.kt

@@ -30,6 +30,39 @@ class ReadViewBoundaryTest {
         )
     }
 
+    @Test
+    fun `ReadView 正文保持为可刷新的读屏文本节点`() {
+        val source = stripComments(readViewSource())
+        val contracts = mapOf(
+            "读屏焦点" to Regex(
+                """ViewCompat\.setScreenReaderFocusable\(\s*this\s*,\s*true\s*\)"""
+            ),
+            "绘制子树排除" to Regex(
+                """importantForAccessibility\s*=\s*View\.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS"""
+            ),
+            "正文文本" to Regex("""info\.text\s*=\s*accessibilityPageText"""),
+            "当前页文本同步" to Regex(
+                """val\s+text\s*=\s*pageFactory\.curPage\.text\s+updateAccessibilityPageText\(\s*text\s*\)"""
+            ),
+            "触摸探索归属" to Regex(
+                """override\s+fun\s+onInterceptHoverEvent\s*\([^)]*\)\s*:\s*Boolean\s*=\s*true"""
+            ),
+            "翻页内容通知" to Regex(
+                """sendAccessibilityEvent\(\s*AccessibilityEvent\.TYPE_WINDOW_CONTENT_CHANGED\s*\)"""
+            ),
+            "文本变更类型" to Regex(
+                """event\.contentChangeTypes\s*=\s*AccessibilityEvent\.CONTENT_CHANGE_TYPE_TEXT"""
+            ),
+        )
+        val missing = contracts.filterValues { !it.containsMatchIn(source) }.keys
+        assertTrue(
+            "ReadView 的页级读屏契约缺失:${missing.joinToString()}。\n" +
+                "Canvas 正文没有独立文本节点;必须由 ReadView 保持可聚焦、提供当前页文本," +
+                "并在翻页时通知无障碍服务刷新。",
+            missing.isEmpty(),
+        )
+    }
+
     @Test
     fun `TextPageFactory 不引用业务单例`() {
         val violations = businessSingletonViolations(