- 拆分 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>
189 lines
7.1 KiB
Swift
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
|
|
}
|
|
}
|