ReadViewSDK/Sources/RDReaderView/EPUBUI/RDEPUBAttachmentTooltipView.swift
shenlei d15f20b097 feat: 交互协调器拆分、附件提示、暗色图片适配、选区放大镜及文档清理
- 拆分 ContentDelegates/TextContentView 为独立协调器(InteractionCoordinator、LocationResolution、ExternalLinks、AttachmentTooltip)
- 新增 RDEPUBAttachmentTooltipView/OverlayView 附件气泡提示
- 新增 RDEPUBDarkImageAdjuster 暗色模式图片亮度适配
- 新增 RDEPUBSelectionLoupeView 选区放大镜
- 新增 MetadataParseWorker/CancellationController 元数据解析取消机制
- 重构 PresentationRuntime/PaginationCoordinator 精简职责
- 优化 ChapterLoader/WarmupOrchestrator 异步章节加载
- CFI 模块微调与 NoteModels 更新
- 清理冗余文档,更新架构/UML/业务逻辑文档

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 17:47:24 +08:00

189 lines
7.1 KiB
Swift

import UIKit
final class RDEPUBAttachmentTooltipView: UIView {
enum ArrowPlacement {
case top
case bottom
}
private let contentInsets = UIEdgeInsets(top: 18, left: 20, bottom: 24, right: 20)
private let arrowSize = CGSize(width: 20, height: 10)
private let cornerRadius: CGFloat = 18
private(set) var minimumArrowX: CGFloat = 28
private var arrowTipX: CGFloat?
private var arrowPlacement: ArrowPlacement = .bottom
private let textLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
label.textColor = .white
label.font = .systemFont(ofSize: 16, weight: .regular)
label.lineBreakMode = .byWordWrapping
return label
}()
private let shapeLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
layer.addSublayer(shapeLayer)
addSubview(textLabel)
accessibilityIdentifier = "epub.reader.attachment.tooltip"
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
shapeLayer.frame = bounds
shapeLayer.path = bubblePath(in: bounds).cgPath
shapeLayer.fillColor = UIColor(white: 0.26, alpha: 0.96).cgColor
let topInset = contentInsets.top + (arrowPlacement == .top ? arrowSize.height : 0)
let bottomInset = contentInsets.bottom + (arrowPlacement == .bottom ? arrowSize.height : 0)
let labelFrame = bounds.inset(by: UIEdgeInsets(
top: topInset,
left: contentInsets.left,
bottom: bottomInset,
right: contentInsets.right
))
textLabel.frame = labelFrame
}
func setArrowTipX(_ value: CGFloat, placement: ArrowPlacement) {
arrowTipX = value
arrowPlacement = placement
setNeedsLayout()
}
func configure(text: String, maxWidth: CGFloat) {
textLabel.text = text
let labelMaxWidth = max(maxWidth - contentInsets.left - contentInsets.right, 120)
let labelSize = textLabel.sizeThatFits(CGSize(width: labelMaxWidth, height: .greatestFiniteMagnitude))
frame.size = CGSize(
width: min(maxWidth, labelSize.width + contentInsets.left + contentInsets.right),
height: labelSize.height + contentInsets.top + contentInsets.bottom + arrowSize.height
)
setNeedsLayout()
layoutIfNeeded()
}
private func bubblePath(in rect: CGRect) -> UIBezierPath {
let bubbleRect: CGRect
switch arrowPlacement {
case .bottom:
bubbleRect = CGRect(
x: rect.minX,
y: rect.minY,
width: rect.width,
height: rect.height - arrowSize.height
)
case .top:
bubbleRect = CGRect(
x: rect.minX,
y: rect.minY + arrowSize.height,
width: rect.width,
height: rect.height - arrowSize.height
)
}
let arrowMidX = min(
max(arrowTipX ?? bubbleRect.midX, minimumArrowX),
bubbleRect.width - minimumArrowX
)
let arrowHalfWidth = arrowSize.width / 2
let path = UIBezierPath()
switch arrowPlacement {
case .bottom:
path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: -.pi / 2,
endAngle: 0,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: 0,
endAngle: .pi / 2,
clockwise: true
)
path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.maxY))
path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.maxY + arrowSize.height))
path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.maxY))
path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: .pi / 2,
endAngle: .pi,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: .pi,
endAngle: -.pi / 2,
clockwise: true
)
case .top:
path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.minY - arrowSize.height))
path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: -.pi / 2,
endAngle: 0,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: 0,
endAngle: .pi / 2,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: .pi / 2,
endAngle: .pi,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: .pi,
endAngle: -.pi / 2,
clockwise: true
)
}
path.close()
return path
}
}