- 移除 RDEPUBSelectableTextView,改用原生 UITextView - 新增 NSAttributedString 自定义属性(com.rdreader.highlight/underline)注入高亮 - RDEPUBTextPageRenderView 统一绘制高亮背景、文字和选区 - RDEPUBTextSelectionController 精简,选区矩形传递给 RenderView 绘制 - 新增高亮选区复刻 WXRead 实现方案文档 - UI 测试适配新架构 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
17 KiB
将高亮选区实现 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 自定义属性注入 NSAttributedString,在 drawInContext: 中读取绘制 |
| 菜单系统 | 自定义 selectionActionBar UIStackView |
UIMenuController + 自定义 items |
| 手势模型 | 无 pan 手势(UITextView 自带拖拽) | long-press 启动 + pan 扩展,isSelecting 控制 pan 启停 |
| 视图层级 | 3 层 overlay(background + 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 = NSNotFound、selectionEndIndex: Int = NSNotFound、isSelecting: 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
- 移除
clearSelection的textView:参数 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,启用panGRhandlePan:转发给selectionController.handlePanhandleTap:如果selectionController.isSelecting则clearSelection(),否则转发给 delegate 做工具栏切换
菜单改为 UIMenuController(对齐 WXRead):
- 删除
selectionActionBar: UIStackView及相关方法(showSelectionActionBarIfNeeded、hideSelectionActionBar、updateSelectionActionBarFrame、selectionMenuButton) - 在
selectionController.onSelectionChanged回调中,当 selection 非 nil 时调用showSelectionMenu(in:anchorRect:) RDEPUBTextContentView设为canBecomeFirstResponder = true,overridecanPerformAction仅允许三个自定义 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
handleLongPress和handlePan现在直接更新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) RDReaderView在didBeginSelection时禁用翻页手势,在didEndSelection时恢复
4.2 WXRead 手势时序对齐
WXRead 的手势流程:
- long-press
.began→ 设置selectionStartIndex = selectionEndIndex = index,isSelecting = true,启用 panGR,setNeedsDisplay - long-press
.ended→ 无额外操作(保留选区) - pan
.changed→ 更新selectionEndIndex,计算 rects,setNeedsDisplay - single tap →
clearSelection(),禁用 panGR
Phase 4 验证
- 长按选词 → 拖拽扩展 → 单击取消,全流程流畅
- 无选区时翻页手势正常
- 有选区时翻页手势被禁用
Phase 5: 菜单系统对齐 + 清理
5.1 UIMenuController 替换 selectionActionBar
已在 Phase 1 中完成。此阶段仅做清理:
- 删除
RDEPUBSelectableTextView.swift - 标记
RDEPUBSelectionOverlayView.swift和RDEPUBTextPageDecorationView.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 |
验证方案
- UI 测试:
ReaderAnnotationTests全部通过 - 手动测试:
- 长按选词 → 蓝色选区高亮显示
- 拖拽扩展选区 → 选区跟随手指
- 点击"高亮" → 黄色高亮创建成功
- 翻页后返回 → 高亮仍存在
- 点击"拷贝" → 文本已复制
- 点击"批注" → 弹出笔记输入框
- 单击空白处 → 选区清除
- 性能测试:
draw(_:)耗时 ≤ 之前(单次绘制 vs 三次绘制) - 对比验证:与 WXRead 截图对比高亮颜色、alpha、rect 位置