182 lines
6.1 KiB
Swift
182 lines
6.1 KiB
Swift
import UIKit
|
||
|
||
// MARK: - 覆盖层装饰类型
|
||
|
||
/// 覆盖层装饰的数据结构
|
||
/// 表示一个需要绘制在文本上方或下方的视觉装饰(高亮、搜索结果、选区等)
|
||
struct RDEPUBTextOverlayDecoration {
|
||
/// 装饰类型枚举
|
||
enum Kind: String {
|
||
/// 文本选区(蓝色半透明)
|
||
case selection
|
||
/// 用户高亮标注
|
||
case highlight
|
||
/// 用户划线标注
|
||
case underline
|
||
/// 搜索匹配项(普通)
|
||
case search
|
||
/// 搜索匹配项(当前高亮)
|
||
case activeSearch
|
||
/// 定位指示(跳转到位置时的动画目标)
|
||
case locate
|
||
}
|
||
|
||
/// 装饰类型
|
||
var kind: Kind
|
||
/// 装饰对应的绝对文本范围
|
||
var absoluteRange: NSRange
|
||
/// 装饰的绘制矩形数组(每行一个矩形)
|
||
var rects: [CGRect]
|
||
/// 装饰的颜色
|
||
var color: UIColor
|
||
}
|
||
|
||
// MARK: - 选择覆盖层视图
|
||
|
||
/// 文本选择和装饰的覆盖层绘制视图
|
||
/// 位于文本内容上方,负责绘制选区、高亮、搜索结果等视觉效果
|
||
/// 使用 Core Graphics 直接绘制,支持填充矩形和下划线两种绘制模式
|
||
class RDEPUBSelectionOverlayView: UIView {
|
||
private(set) var page: RDEPUBTextPage?
|
||
private var snapshot: RDEPUBPageLayoutSnapshot?
|
||
private(set) var selectionRange: NSRange?
|
||
private var selectionRects: [CGRect] = []
|
||
private var decorations: [RDEPUBTextOverlayDecoration] = []
|
||
private let selectionVerticalAdjustment: CGFloat = -1
|
||
|
||
var selectionColor: UIColor = UIColor(red: 70 / 255, green: 140 / 255, blue: 1, alpha: 0.24) {
|
||
didSet {
|
||
setNeedsDisplay()
|
||
}
|
||
}
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
backgroundColor = .clear
|
||
isOpaque = false
|
||
isUserInteractionEnabled = false
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
func configure(page: RDEPUBTextPage, selectionColor: UIColor, snapshot: RDEPUBPageLayoutSnapshot? = nil) {
|
||
self.page = page
|
||
self.snapshot = snapshot
|
||
self.selectionColor = selectionColor
|
||
selectionRange = nil
|
||
decorations = []
|
||
setNeedsDisplay()
|
||
}
|
||
|
||
func updateSnapshot(_ snapshot: RDEPUBPageLayoutSnapshot?) {
|
||
self.snapshot = snapshot
|
||
}
|
||
|
||
func updateSelection(absoluteRange: NSRange?) {
|
||
selectionRange = absoluteRange
|
||
selectionRects = []
|
||
setNeedsDisplay()
|
||
}
|
||
|
||
func updateSelection(absoluteRange: NSRange?, rects: [CGRect]) {
|
||
selectionRange = absoluteRange
|
||
selectionRects = rects
|
||
setNeedsDisplay()
|
||
}
|
||
|
||
func applyDecorations(_ decorations: [RDEPUBTextOverlayDecoration]) {
|
||
self.decorations = decorations
|
||
setNeedsDisplay()
|
||
}
|
||
|
||
func clearSelection() {
|
||
updateSelection(absoluteRange: nil)
|
||
}
|
||
|
||
func absoluteRange(at point: CGPoint) -> NSRange? {
|
||
if let selectionRange,
|
||
selectionRects.contains(where: { $0.insetBy(dx: -4, dy: -4).contains(point) }) {
|
||
return selectionRange
|
||
}
|
||
|
||
if let snapshot,
|
||
let absoluteRange = snapshot.absoluteRange(at: point, in: resolvedDecorations) {
|
||
return absoluteRange
|
||
}
|
||
|
||
for decoration in resolvedDecorations {
|
||
for rect in decoration.rects {
|
||
if rect.insetBy(dx: -4, dy: -4).contains(point) {
|
||
return decoration.absoluteRange
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func decorationSummary() -> String {
|
||
let counts = Dictionary(grouping: resolvedDecorations, by: \.kind).mapValues(\.count)
|
||
let selectionLabel = selectionRange.map(NSStringFromRange) ?? "none"
|
||
let highlightCount = counts[.highlight, default: 0]
|
||
let underlineCount = counts[.underline, default: 0]
|
||
let searchCount = counts[.search, default: 0]
|
||
let activeSearchCount = counts[.activeSearch, default: 0]
|
||
let locateLabel = resolvedDecorations.first(where: { $0.kind == .locate }).map { NSStringFromRange($0.absoluteRange) } ?? "none"
|
||
return [
|
||
"selection \(selectionLabel)",
|
||
"highlight \(highlightCount)",
|
||
"underline \(underlineCount)",
|
||
"search \(searchCount)",
|
||
"activeSearch \(activeSearchCount)",
|
||
"locate \(locateLabel)"
|
||
].joined(separator: " · ")
|
||
}
|
||
|
||
override func draw(_ rect: CGRect) {
|
||
guard let context = UIGraphicsGetCurrentContext() else { return }
|
||
|
||
for decoration in resolvedDecorations {
|
||
switch decoration.kind {
|
||
case .underline:
|
||
context.setStrokeColor(decoration.color.cgColor)
|
||
context.setLineWidth(2)
|
||
for underlineRect in decoration.rects {
|
||
let y = underlineRect.maxY - 1
|
||
context.move(to: CGPoint(x: underlineRect.minX, y: y))
|
||
context.addLine(to: CGPoint(x: underlineRect.maxX, y: y))
|
||
context.strokePath()
|
||
}
|
||
default:
|
||
context.setFillColor(decoration.color.cgColor)
|
||
for selectionRect in decoration.rects {
|
||
let adjustedRect = selectionRect.offsetBy(dx: 0, dy: selectionVerticalAdjustment)
|
||
let path = UIBezierPath(roundedRect: adjustedRect.insetBy(dx: -1, dy: -1), cornerRadius: 4)
|
||
context.addPath(path.cgPath)
|
||
context.fillPath()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var resolvedDecorations: [RDEPUBTextOverlayDecoration] {
|
||
guard page != nil else { return [] }
|
||
|
||
var result = decorations.filter { !$0.rects.isEmpty }
|
||
|
||
if let selectionRange, !selectionRects.isEmpty {
|
||
result.append(
|
||
RDEPUBTextOverlayDecoration(
|
||
kind: .selection,
|
||
absoluteRange: selectionRange,
|
||
rects: selectionRects,
|
||
color: selectionColor
|
||
)
|
||
)
|
||
}
|
||
|
||
return result
|
||
}
|
||
}
|