ReadViewSDK/Doc/FeatureSolution/高亮选区复刻WXRead实现方案.md
shenlei 70133e4e6e refactor(reader): 重构高亮选区绘制架构,对齐 WXRead 实现方案
- 移除 RDEPUBSelectableTextView,改用原生 UITextView
- 新增 NSAttributedString 自定义属性(com.rdreader.highlight/underline)注入高亮
- RDEPUBTextPageRenderView 统一绘制高亮背景、文字和选区
- RDEPUBTextSelectionController 精简,选区矩形传递给 RenderView 绘制
- 新增高亮选区复刻 WXRead 实现方案文档
- UI 测试适配新架构

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 20:57:27 +08:00

17 KiB
Raw Blame History

将高亮选区实现 1:1 复刻为 WXRead 架构

Context

当前 ReadViewSDK 的高亮选区实现与 WXRead 存在根本性架构差异。需要将选区系统从"UITextView 透明代理 + 独立 overlay 层"迁移到 WXRead 的"自定义手势 + CoreText 直接命中测试 + 统一 drawRect 绘制"架构。

核心差异对比

维度 当前 ReadViewSDK WXRead
选择触发 UITextView 原生长按(透明文本) 自定义 long-press(0.5s) + pan 手势
命中测试 DTCoreText stringIndex(forPosition:) CTLineGetStringIndexForPosition + 坐标翻转
选区绘制 独立 RDEPUBSelectionOverlayView overlay 层 同一 drawRect: 内绘制(文字+高亮+选区)
高亮绘制 overlay 层计算 rect 后 CG 填充 com.weread.highlight 自定义属性注入 NSAttributedStringdrawInContext: 中读取绘制
菜单系统 自定义 selectionActionBar UIStackView UIMenuController + 自定义 items
手势模型 无 pan 手势UITextView 自带拖拽) long-press 启动 + pan 扩展,isSelecting 控制 pan 启停
视图层级 3 层 overlaybackground + text + foreground 单一 WRPageView 统一绘制

Phase 1: 移除 UITextView改用自定义手势 + CoreText 命中测试

1.1 修改 RDEPUBPageInteractionController.swift

当前已正确封装 DTCoreText 的 stringIndex(forPosition:)offset(forStringIndex:),算法与 WXRead 一致。

新增方法:

  • characterIndexForViewPoint(at viewPoint: CGPoint, in view: UIView) — 将 UIKit 坐标转为相对于 content view 的坐标后调用 characterIndex(at:),对应 WXRead 的 stringIndexForPoint: + WRSFlipPointForCoreText

注意DTCoreText 已在内部处理了 UIKit↔CoreText 坐标翻转(line.baselineOrigin 是 UIKit 坐标),所以不需要手动翻转 Y 轴。但 WXRead 的自定义 DTCoreText 需要手动翻转。当前项目用的是原版 DTCoreText pod行为已正确。

1.2 重构 RDEPUBTextSelectionController.swift

当前状态: 遵循 UITextViewDelegate,通过 textViewDidChangeSelection 接收选区变化。handleLongPress 方法存在但未被任何手势调用。

改为:

  • 移除 UITextViewDelegate 遵循
  • 移除 textViewDidChangeSelection(_:)textViewDidChangeSelection(_:, page:)
  • 新增状态属性:selectionStartIndex: Int = NSNotFoundselectionEndIndex: Int = NSNotFoundisSelecting: Bool = false
  • 重构 handleLongPress
    • .began:调用 characterIndex(at:) 设置 selectionStartIndex = selectionEndIndex = index,设 isSelecting = true
    • .ended:设 isSelectionFromInteraction = false(保留,供后续扩展)
  • 新增 handlePan(_ gesture:, page:, renderView:, interactionController:)
    • .changed:计算字符索引,更新 selectionEndIndex,计算 range = (min, max - min),计算 rects更新 renderView
  • 移除 clearSelectiontextView: 参数
  • makeSelection(from:, page:) 保持不变(已正确基于绝对偏移构建 RDEPUBSelection

1.3 重构 RDEPUBTextContentView.swift — 移除 UITextView

删除:

  • textView: RDEPUBSelectableTextView 属性及其初始化
  • selectionProxyContent(from:) 方法
  • textView.delegate = selectionController 等 textView 配置代码
  • textView.frame = ...layoutSubviews 中的设置
  • configure(page:...) 中所有 textView.attributedText = ...textView.selectedRange = ...textView.isHidden = ... 赋值

新增手势识别器(对齐 WXRead 的 WRPageView

private let longPressGR = UILongPressGestureRecognizer(target: ..., action: #selector(handleLongPress))
private let panGR = UIPanGestureRecognizer(target: ..., action: #selector(handlePan))
private let tapGR = UITapGestureRecognizer(target: ..., action: #selector(handleTap))
  • longPressGR.minimumPressDuration = 0.5(与 WXRead 一致)
  • panGR.isEnabled = false初始禁用long-press began 时启用)
  • tapGR.require(toFail: longPressGR)(与 WXRead 一致)
  • 三个手势都添加到 contentView 自身

手势响应:

  • handleLongPress:转发给 selectionController.handleLongPress,启用 panGR
  • handlePan:转发给 selectionController.handlePan
  • handleTap:如果 selectionController.isSelectingclearSelection(),否则转发给 delegate 做工具栏切换

菜单改为 UIMenuController对齐 WXRead

  • 删除 selectionActionBar: UIStackView 及相关方法(showSelectionActionBarIfNeededhideSelectionActionBarupdateSelectionActionBarFrameselectionMenuButton
  • selectionController.onSelectionChanged 回调中,当 selection 非 nil 时调用 showSelectionMenu(in:anchorRect:)
  • RDEPUBTextContentView 设为 canBecomeFirstResponder = trueoverride canPerformAction 仅允许三个自定义 selector
  • 使用 UIMenuController.shared 配置 "拷贝"/"高亮"/"批注" 三个 UIMenuItem

调整 clearSelection()

func clearSelection() {
    currentSelection = nil
    menuSelection = nil
    panGR.isEnabled = false
    selectionController.clearSelection(overlayView: overlayView, backgroundOverlayView: backgroundOverlayView)
    UIMenuController.shared.setMenuVisible(false, animated: true)
}

1.4 删除 RDEPUBSelectableTextView.swift

该文件的功能(屏蔽系统菜单、暴露自定义 action已被 UIMenuController 方案替代,直接删除。

1.5 更新 RDEPUBReaderController+ContentDelegates.swift

textContentView(_:, didRequestSelectionAction:, selection:) 中的 contentView.clearSelection() 调用无需改动,新的 clearSelection() 签名兼容。

Phase 1 验证

  • UI 测试 ReaderAnnotationTests.testSelectionMenuCreatesHighlight 必须通过
  • 手动验证:长按选词 → 弹出 UIMenuController → 点击"高亮" → 高亮创建成功
  • 手动验证:拖拽扩展选区 → 蓝色选区矩形正确绘制
  • 手动验证:单击空白处 → 选区清除

Phase 2: 统一绘制循环 — 高亮/选区在 draw(_:) 中绘制

2.1 扩展 RDEPUBTextPageRenderView.swift

当前状态: 仅调用 layoutFrame.draw(in: context, options:) 绘制文字。

新增属性:

var highlightRanges: [(range: NSRange, color: UIColor)] = [] { didSet { setNeedsDisplay() } }
var underlineRanges: [(range: NSRange, color: UIColor, style: Int)] = [] { didSet { setNeedsDisplay() } }
var selectionRects: [CGRect] = [] { didSet { setNeedsDisplay() } }
var selectionColor: UIColor = UIColor(red: 70/255, green: 140/255, blue: 1, alpha: 0.24)

扩展 draw(_:)

override func draw(_ rect: CGRect) {
    guard let context = UIGraphicsGetCurrentContext(), let layoutFrame else { return }
    context.saveGState()

    // 1. 绘制高亮背景(在文字下方,匹配 WXRead 的 drawHighlightsInContext:
    drawHighlights(in: context, layoutFrame: layoutFrame)

    // 2. 绘制文字
    layoutFrame.draw(in: context, options: drawOptions)

    // 3. 绘制选区(在文字上方,匹配 WXRead 的 _drawSelectionInContext:
    drawSelection(in: context)

    context.restoreGState()
}

drawHighlights 算法(对齐 WXRead WRCoreTextLayoutFrame.drawHighlightsInContext:

private func drawHighlights(in context: CGContext, layoutFrame: DTCoreTextLayoutFrame) {
    for (range, color) in highlightRanges {
        let lines = layoutFrame.lines as! [DTCoreTextLayoutLine]
        for line in lines {
            let overlap = NSIntersectionRange(range, line.stringRange)
            guard overlap.length > 0 else { continue }
            let startX = line.offset(forStringIndex: overlap.location)
            let endX = line.offset(forStringIndex: overlap.location + overlap.length)
            let rect = CGRect(
                x: line.baselineOrigin.x + startX,
                y: line.baselineOrigin.y - line.ascent,
                width: endX - startX,
                height: line.ascent + line.descent
            )
            color.withAlphaComponent(0.35).setFill()  // WXRead 使用 35% alpha
            context.fill(rect)
        }
    }
}

说明WXRead 使用 [color colorWithAlphaComponent:0.3]WRPageHighlight 的预设色本身已是 35% alpha最终效果等同。当前项目使用 0.45 alpha需调整为 0.35 以完全对齐。

drawSelection 算法(对齐 WXRead _drawSelectionInContext:

private func drawSelection(in context: CGContext) {
    guard !selectionRects.isEmpty else { return }
    selectionColor.setFill()
    for rect in selectionRects {
        context.fill(rect)
    }
}

2.2 简化 RDEPUBTextContentView.swift 视图层级

删除/保留:

  • 删除 backgroundOverlayView 属性(高亮背景现在由 renderView 在文字下方绘制)
  • 保留 overlayView(用于非 DTCoreText 回退路径和搜索高亮)
  • configure(page:...) 中,将 highlight 数据传给 coreTextContentView 而非 overlayView
// DTCoreText 路径
coreTextContentView.highlightRanges = highlights.compactMap { highlight -> (NSRange, UIColor)? in
    guard let rangeInfo = highlight.rangeInfo,
          let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo) else { return nil }
    let absoluteRange = info.nsRange
    let overlap = NSIntersectionRange(absoluteRange, pageAbsoluteRange)
    guard overlap.length > 0 else { return nil }
    let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
    return (relativeRange, highlight.uiColor)
}

2.3 更新 RDEPUBTextSelectionController.swift — 直接更新 renderView

  • handleLongPresshandlePan 现在直接更新 renderView.selectionRects 并调用 renderView.setNeedsDisplay()
  • 移除 overlayView.updateSelection(absoluteRange:, rects:) 调用

Phase 2 验证

  • 视觉对比:高亮矩形与文字像素对齐
  • 性能测试:单次 draw(_:) 耗时应与之前持平或更快
  • 回归测试:搜索高亮仍正常显示

Phase 3: 高亮属性注入 NSAttributedString对齐 WXRead com.weread.highlight

3.1 定义自定义属性常量

// 对齐 WXRead 的 kWRHighlightAttributeName / kWRUnderlineAttributeName
let kRDEPUBHighlightAttributeName = NSAttributedString.Key("com.rdreader.highlight")
let kRDEPUBUnderlineAttributeName = NSAttributedString.Key("com.rdreader.underline")

3.2 新增 RDEPUBChapterData.applyHighlights(to:page:highlights:)

对齐 WXRead 的 WRChapterData.addHighlightInRange:key:itemId:color:

func applyHighlights(
    to content: NSMutableAttributedString,
    page: RDEPUBTextPage,
    highlights: [RDEPUBHighlight]
) {
    for highlight in highlights {
        guard let rangeInfo = highlight.rangeInfo,
              let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo) else { continue }
        let absoluteRange = info.nsRange
        let pageRange = NSRange(location: page.pageStartOffset, length: page.pageEndOffset - page.pageStartOffset)
        let overlap = NSIntersectionRange(absoluteRange, pageRange)
        guard overlap.length > 0 else { continue }
        let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)

        switch highlight.style {
        case .highlight:
            content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: relativeRange)
        case .underline:
            content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: relativeRange)
        }
    }
}

3.3 RDEPUBTextPageRenderView.draw(_:) 从属性读取高亮

替代 Phase 2 的 highlightRanges 属性方案,改为在 draw(_:) 中枚举 attributed string 的自定义属性:

private func drawHighlightsFromAttributes(in context: CGContext, attributedString: NSAttributedString) {
    let fullRange = NSRange(location: 0, length: attributedString.length)
    attributedString.enumerateAttribute(kRDEPUBHighlightAttributeName, in: fullRange) { value, range, _ in
        guard let color = value as? UIColor else { return }
        let rects = computeRects(for: range)  // 复用 line 迭代 + CTLineGetOffsetForStringIndex
        color.withAlphaComponent(0.35).setFill()
        for rect in rects { context.fill(rect) }
    }
}

3.4 更新 RDEPUBTextContentView.configure(page:...)

// 在传给 renderView 之前注入高亮属性
let displayContent = darkImageAdjustedContentIfNeeded(...)
chapterData.applyHighlights(to: displayContent, page: page, highlights: highlights)
coreTextContentView.attributedDisplayContent = displayContent  // 新增属性

Phase 3 验证

  • 高亮在翻页后仍正确显示(属性嵌入 attributed string不依赖外部状态
  • 高亮颜色、alpha、rect 与 WXRead 截图一致

Phase 4: 手势模型对齐 — long-press + pan + isSelecting

4.1 手势冲突处理

风险: pan 手势(选区扩展)可能与 RDReaderView 的翻页手势冲突。

解决方案(对齐 WXRead

  • panGR 初始 isEnabled = false,仅在 isSelecting = true 时启用
  • clearSelection() 时禁用 panGR
  • 新增 delegate 方法通知父视图:
    func textContentViewDidBeginSelection(_ contentView: RDEPUBTextContentView)
    func textContentViewDidEndSelection(_ contentView: RDEPUBTextContentView)
    
  • RDReaderViewdidBeginSelection 时禁用翻页手势,在 didEndSelection 时恢复

4.2 WXRead 手势时序对齐

WXRead 的手势流程:

  1. long-press .began → 设置 selectionStartIndex = selectionEndIndex = indexisSelecting = true,启用 panGRsetNeedsDisplay
  2. long-press .ended → 无额外操作(保留选区)
  3. pan .changed → 更新 selectionEndIndex,计算 rectssetNeedsDisplay
  4. single tap → clearSelection(),禁用 panGR

Phase 4 验证

  • 长按选词 → 拖拽扩展 → 单击取消,全流程流畅
  • 无选区时翻页手势正常
  • 有选区时翻页手势被禁用

Phase 5: 菜单系统对齐 + 清理

5.1 UIMenuController 替换 selectionActionBar

已在 Phase 1 中完成。此阶段仅做清理:

  • 删除 RDEPUBSelectableTextView.swift
  • 标记 RDEPUBSelectionOverlayView.swiftRDEPUBTextPageDecorationView.swift 为 deprecated保留给非 DTCoreText 回退路径)

5.2 更新 UI 测试

ReaderAnnotationTests 中查找菜单项的方式需更新:

  • 当前:app.buttons["高亮"]UIStackView 中的按钮)
  • 改为:app.menuItems["高亮"]UIMenuController 的菜单项)
  • 或者:保留 accessibilityIdentifier 在 RDEPUBTextContentView 上以便测试定位

5.3 高亮颜色对齐

WXRead 的 5 种预设色35% alpha

  • Yellow: (1.0, 0.92, 0.23, 0.35)
  • Blue: (0.26, 0.65, 0.96, 0.35)
  • Red: (0.96, 0.26, 0.26, 0.35)
  • Green: (0.30, 0.85, 0.39, 0.35)
  • Purple: (0.67, 0.33, 0.97, 0.35)

当前项目使用 CSS hex 颜色 + 0.45 alpha需对齐为 WXRead 的 RGBA 值。


文件变更清单

文件 操作 Phase
RDEPUBPageInteractionController.swift 修改:新增 characterIndexForViewPoint 1
RDEPUBTextSelectionController.swift 重构:移除 UITextViewDelegate新增 pan 处理、状态机 1
RDEPUBTextContentView.swift 重构:移除 textView新增手势替换菜单简化层级 1,2,4
RDEPUBSelectableTextView.swift 删除 1
RDEPUBTextPageRenderView.swift 扩展:新增高亮/选区/下划线绘制逻辑 2,3
RDEPUBChapterData.swift 新增:applyHighlights(to:page:highlights:) 3
RDEPUBReaderController+ContentDelegates.swift 小改:适配新 clearSelection 签名 1
RDReaderView.swift 新增:选区期间禁用翻页手势 4
RDEPUBSelectionOverlayView.swift 保留deprecated for DTCoreText path 5
RDEPUBTextPageDecorationView.swift 保留deprecated for DTCoreText path 5
RDEPUBTextAnnotationOverlay.swift 保留deprecated for DTCoreText path 5
ReaderAnnotationTests.swift 更新:菜单项查找方式 5

验证方案

  1. UI 测试ReaderAnnotationTests 全部通过
  2. 手动测试
    • 长按选词 → 蓝色选区高亮显示
    • 拖拽扩展选区 → 选区跟随手指
    • 点击"高亮" → 黄色高亮创建成功
    • 翻页后返回 → 高亮仍存在
    • 点击"拷贝" → 文本已复制
    • 点击"批注" → 弹出笔记输入框
    • 单击空白处 → 选区清除
  3. 性能测试draw(_:) 耗时 ≤ 之前(单次绘制 vs 三次绘制)
  4. 对比验证:与 WXRead 截图对比高亮颜色、alpha、rect 位置