unify PDF and EPUB reader: convert highlights to underlines, improve annotation UI, change paging from horizontal to vertical

- Convert PDF highlights from filled rectangles to underlines matching EPUB style
- Update PDF annotation editor with EPUB-style design: gold sidebar rail, semi-transparent background, improved spacing and shadows, dark mode support
- Change PDF page-curl paging from left-right horizontal to up-down vertical: update gesture recognition and tap regions accordingly

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
shenlei 2026-07-15 10:01:23 +09:00
parent 72c1f8d89c
commit fed34da246
3 changed files with 499 additions and 0 deletions

View File

@ -0,0 +1,239 @@
import UIKit
import RDPDFReaderView
/// PDF
final class PDFDemoAnnotationEditorViewController: UIViewController, UITextViewDelegate {
private let quote: String
private let initialNote: String?
private let onSave: (String?) -> Void
private let textView = UITextView()
private let countLabel = UILabel()
init(quote: String, initialNote: String? = nil, onSave: @escaping (String?) -> Void) {
self.quote = quote
self.initialNote = initialNote
self.onSave = onSave
super.init(nibName: nil, bundle: nil)
title = "添加注释"
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
let isDarkMode = traitCollection.userInterfaceStyle == .dark
view.backgroundColor = isDarkMode ? UIColor(red: 0.11, green: 0.11, blue: 0.12, alpha: 1) : .systemBackground
view.accessibilityIdentifier = "pdf.reader.annotation.editor"
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "取消", style: .plain, target: self, action: #selector(cancelAction))
navigationItem.leftBarButtonItem?.accessibilityIdentifier = "pdf.reader.annotation.cancel"
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "保存", style: .done, target: self, action: #selector(saveAction))
navigationItem.rightBarButtonItem?.accessibilityIdentifier = "pdf.reader.annotation.save"
navigationController?.navigationBar.tintColor = .systemBlue
let quoteCard = UIView()
let rail = UIView()
let quoteLabel = UILabel()
let textCard = UIView()
[quoteCard, rail, quoteLabel, textCard, textView, countLabel].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
view.addSubview(quoteCard)
quoteCard.addSubview(rail)
quoteCard.addSubview(quoteLabel)
view.addSubview(textCard)
textCard.addSubview(textView)
textCard.addSubview(countLabel)
let contentBgColor = isDarkMode ? UIColor(red: 0.13, green: 0.13, blue: 0.14, alpha: 1) : UIColor(red: 0.97, green: 0.97, blue: 0.99, alpha: 1)
let textColor = isDarkMode ? UIColor(red: 0.92, green: 0.92, blue: 0.96, alpha: 1) : .label
quoteCard.backgroundColor = contentBgColor.withAlphaComponent(0.78)
quoteCard.layer.cornerRadius = 16
rail.backgroundColor = UIColor(red: 0.94, green: 0.63, blue: 0.18, alpha: 1)
rail.layer.cornerRadius = 2
quoteLabel.text = quote
quoteLabel.numberOfLines = 5
quoteLabel.font = UIFont.systemFont(ofSize: 16)
quoteLabel.textColor = textColor.withAlphaComponent(0.85)
textCard.backgroundColor = contentBgColor
textCard.layer.cornerRadius = 18
textCard.layer.shadowColor = UIColor.black.cgColor
textCard.layer.shadowOpacity = 0.06
textCard.layer.shadowRadius = 18
textCard.layer.shadowOffset = CGSize(width: 0, height: 6)
textView.text = initialNote
textView.delegate = self
textView.font = UIFont.systemFont(ofSize: 17)
textView.textColor = textColor
textView.backgroundColor = .clear
textView.accessibilityIdentifier = "pdf.reader.annotation.input"
countLabel.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular)
countLabel.textColor = .secondaryLabel
countLabel.textAlignment = .right
NSLayoutConstraint.activate([
quoteCard.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
quoteCard.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
quoteCard.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
rail.leadingAnchor.constraint(equalTo: quoteCard.leadingAnchor, constant: 16),
rail.topAnchor.constraint(equalTo: quoteCard.topAnchor, constant: 16),
rail.bottomAnchor.constraint(equalTo: quoteCard.bottomAnchor, constant: -16),
rail.widthAnchor.constraint(equalToConstant: 4),
quoteLabel.leadingAnchor.constraint(equalTo: quoteCard.leadingAnchor, constant: 28),
quoteLabel.trailingAnchor.constraint(equalTo: quoteCard.trailingAnchor, constant: -16),
quoteLabel.topAnchor.constraint(equalTo: quoteCard.topAnchor, constant: 16),
quoteLabel.bottomAnchor.constraint(equalTo: quoteCard.bottomAnchor, constant: -16),
textCard.topAnchor.constraint(equalTo: quoteCard.bottomAnchor, constant: 20),
textCard.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
textCard.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
textCard.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -20),
textCard.heightAnchor.constraint(greaterThanOrEqualToConstant: 240),
textView.topAnchor.constraint(equalTo: textCard.topAnchor, constant: 16),
textView.leadingAnchor.constraint(equalTo: textCard.leadingAnchor, constant: 16),
textView.trailingAnchor.constraint(equalTo: textCard.trailingAnchor, constant: -16),
textView.bottomAnchor.constraint(equalTo: countLabel.topAnchor, constant: -12),
countLabel.leadingAnchor.constraint(equalTo: textCard.leadingAnchor, constant: 16),
countLabel.trailingAnchor.constraint(equalTo: textCard.trailingAnchor, constant: -16),
countLabel.bottomAnchor.constraint(equalTo: textCard.bottomAnchor, constant: -12)
])
updateCount()
DispatchQueue.main.async { [weak self] in self?.textView.becomeFirstResponder() }
}
func textViewDidChange(_ textView: UITextView) {
if textView.text.count > 1000 { textView.text = String(textView.text.prefix(1000)) }
updateCount()
}
private func updateCount() { countLabel.text = "\(textView.text.count)/1000" }
@objc private func cancelAction() { closeEditor() }
@objc private func saveAction() {
let note = textView.text.trimmingCharacters(in: .whitespacesAndNewlines)
onSave(note.isEmpty ? nil : note)
closeEditor()
}
private func closeEditor() {
guard let navigationController else {
dismiss(animated: true)
return
}
if navigationController.viewControllers.first === self {
navigationController.dismiss(animated: true)
} else {
navigationController.popViewController(animated: true)
}
}
}
///
final class PDFDemoAnnotationListViewController: UITableViewController {
var onSelectAnnotation: ((RDPDFReaderAnnotation) -> Void)?
var onDeleteAnnotation: ((RDPDFReaderAnnotation) -> Void)?
var onDone: (() -> Void)?
private let annotationsProvider: () -> [RDPDFReaderAnnotation]
private let filterControl = UISegmentedControl(items: ["全部", "注释", "高亮"])
private var annotations: [RDPDFReaderAnnotation] = []
init(annotationsProvider: @escaping () -> [RDPDFReaderAnnotation]) {
self.annotationsProvider = annotationsProvider
super.init(style: .plain)
title = "笔记"
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
view.accessibilityIdentifier = "pdf.reader.annotations.panel"
tableView.accessibilityIdentifier = "pdf.reader.annotations.table"
tableView.tableFooterView = UIView()
filterControl.selectedSegmentIndex = 0
filterControl.accessibilityIdentifier = "pdf.reader.annotations.filter"
filterControl.addTarget(self, action: #selector(filterChanged), for: .valueChanged)
navigationItem.titleView = filterControl
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "完成", style: .done, target: self, action: #selector(doneAction))
reloadAnnotations()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
reloadAnnotations()
}
private var filteredAnnotations: [RDPDFReaderAnnotation] {
switch filterControl.selectedSegmentIndex {
case 1: return annotations.filter { !($0.note?.isEmpty ?? true) }
case 2: return annotations.filter { $0.note?.isEmpty ?? true }
default: return annotations
}
}
private func reloadAnnotations() {
annotations = annotationsProvider().sorted { $0.updatedAt > $1.updatedAt }
tableView.reloadData()
updateEmptyState()
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { filteredAnnotations.count }
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "annotation") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "annotation")
let annotation = filteredAnnotations[indexPath.row]
cell.textLabel?.numberOfLines = 2
cell.detailTextLabel?.numberOfLines = 2
cell.textLabel?.text = annotation.selectedText?.isEmpty == false ? annotation.selectedText : "区域标注"
let note = annotation.note?.trimmingCharacters(in: .whitespacesAndNewlines)
cell.detailTextLabel?.text = ["\(annotation.pageIndex + 1)", note].compactMap { $0 }.joined(separator: " · ")
cell.imageView?.image = UIImage(systemName: note?.isEmpty == false ? "note.text" : "highlighter")
cell.imageView?.tintColor = UIColor(rdPDFHexString: annotation.color)
cell.accessibilityIdentifier = "pdf.reader.annotations.row.\(annotation.id)"
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let annotation = filteredAnnotations[indexPath.row]
tableView.deselectRow(at: indexPath, animated: true)
onSelectAnnotation?(annotation)
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { true }
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
guard editingStyle == .delete else { return }
let annotation = filteredAnnotations[indexPath.row]
onDeleteAnnotation?(annotation)
reloadAnnotations()
}
private func updateEmptyState() {
guard filteredAnnotations.isEmpty else { tableView.backgroundView = nil; return }
let label = UILabel()
label.text = filterControl.selectedSegmentIndex == 1 ? "暂无注释" : "暂无标注"
label.textAlignment = .center
label.textColor = .secondaryLabel
label.accessibilityIdentifier = "pdf.reader.annotations.empty"
tableView.backgroundView = label
}
@objc private func filterChanged() { reloadAnnotations() }
@objc private func doneAction() { onDone?() }
}
extension UIColor {
convenience init(rdPDFHexString: String) {
let hex = rdPDFHexString.replacingOccurrences(of: "#", with: "")
let value = UInt32(hex, radix: 16) ?? 0xF4C542
self.init(
red: CGFloat((value >> 16) & 0xFF) / 255,
green: CGFloat((value >> 8) & 0xFF) / 255,
blue: CGFloat(value & 0xFF) / 255,
alpha: 1
)
}
}

View File

@ -0,0 +1,194 @@
import UIKit
/// EPUB `RDEpubReaderView+ContentAccess`
/// Paging
extension RDPDFReaderView {
func makePageView(for page: Int) -> UIView {
let view = dataSource?.pageContentView(readerView: self, pageNum: page) ?? UIView()
view.tag = page
configureInteraction(for: view)
return view
}
func configureInteraction(for view: UIView) {
guard let page = view as? RDPDFReaderPageInteractable else { return }
page.readerSetInternalGesturesEnabled(currentDisplayType != .pageCurl)
page.readerContentTapHandler = { [weak self, weak view] point in
guard let self, let view else { return }
self.handleContentTap(view.convert(point, to: self))
}
page.readerZoomStateChangedHandler = { [weak self, weak view] zoomed in
guard let self, view?.tag == self.currentPage else { return }
self.isCurrentPageZoomed = zoomed
self.updatePagingState()
}
page.readerSelectionStateChangedHandler = { [weak self, weak view] active in
guard let self, view?.tag == self.currentPage else { return }
self.isCurrentPageTextSelectionActive = active
self.updatePagingState()
}
}
func synchronizePageState() {
isCurrentPageZoomed = (pageContentView(pageNum: currentPage) as? RDPDFReaderPageInteractable)?.readerIsZoomed ?? false
isCurrentPageTextSelectionActive = (pageContentView(pageNum: currentPage) as? RDPDFReaderPageInteractable)?.readerHasActiveTextSelection ?? false
updatePagingState()
}
func updatePagingState() {
let enabled = isPagingEnabled && !isCurrentPageZoomed && !isCurrentPageTextSelectionActive
collectionView.isScrollEnabled = enabled
curlPanGestureRecognizer.isEnabled = currentDisplayType == .pageCurl
&& isPagingEnabled
&& !isCurrentPageTextSelectionActive
}
func transitionCurl(to page: Int, animated: Bool) {
if isTransitioning {
pendingPage = (page, animated)
return
}
if page == currentPage, curlContentView != nil {
synchronizePageState()
return
}
let previousPage = currentPage
let nextView = makePageView(for: page)
nextView.frame = curlHostView.bounds
nextView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
let installPage = { [weak self] in
guard let self else { return }
self.curlContentView?.removeFromSuperview()
self.curlHostView.addSubview(nextView)
self.curlContentView = nextView
self.currentPage = page
}
guard animated, curlContentView != nil, previousPage != page else {
installPage()
return
}
isTransitioning = true
curlPanInteraction = .idle
let animation: UIView.AnimationOptions = page > previousPage ? .transitionCurlUp : .transitionCurlDown
UIView.transition(
with: curlHostView,
duration: 0.38,
options: [animation, .allowAnimatedContent],
animations: installPage
) { [weak self] _ in
self?.finishCurlTransition()
}
}
@objc func handleCurlPan(_ gesture: UIPanGestureRecognizer) {
guard let page = curlContentView as? RDPDFReaderPageInteractable else { return }
switch gesture.state {
case .began:
guard !page.readerHasActiveTextSelection else {
curlPanInteraction = .idle
return
}
if page.readerIsZoomed {
curlPanInteraction = .contentPan
page.readerBeginExternalPan()
} else {
curlPanInteraction = .pageTurn(startPage: currentPage)
}
case .changed:
guard !page.readerHasActiveTextSelection else {
curlPanInteraction = .idle
return
}
if case .contentPan = curlPanInteraction {
page.readerUpdateExternalPan(translation: gesture.translation(in: curlHostView))
}
case .ended:
guard !page.readerHasActiveTextSelection else {
curlPanInteraction = .idle
return
}
finishCurlPan(gesture, page: page, cancelled: false)
case .cancelled, .failed:
finishCurlPan(gesture, page: page, cancelled: true)
default:
break
}
}
private func finishCurlPan(
_ gesture: UIPanGestureRecognizer,
page: RDPDFReaderPageInteractable,
cancelled: Bool
) {
let interaction = curlPanInteraction
curlPanInteraction = .idle
switch interaction {
case .contentPan:
page.readerEndExternalPan()
case let .pageTurn(startPage):
guard !cancelled, currentPage == startPage else { return }
let translation = gesture.translation(in: curlHostView)
let velocity = gesture.velocity(in: curlHostView)
guard abs(translation.y) > abs(translation.x) else { return }
let crossedDistance = abs(translation.y) >= max(44, curlHostView.bounds.height * 0.12)
let crossedVelocity = abs(velocity.y) >= 450
guard crossedDistance || crossedVelocity else { return }
let direction = abs(translation.y) >= 4 ? translation.y : velocity.y
transitionToPage(pageNum: startPage + (direction < 0 ? 1 : -1), animated: true)
case .idle:
break
case .pinching:
// pan
curlPanInteraction = .pinching
}
}
@objc func handleCurlPinch(_ gesture: UIPinchGestureRecognizer) {
guard currentDisplayType == .pageCurl,
let contentView = curlContentView,
let page = contentView as? RDPDFReaderPageInteractable else { return }
let point = gesture.location(in: contentView)
switch gesture.state {
case .began:
if case .contentPan = curlPanInteraction {
page.readerEndExternalPan()
}
//
curlPanInteraction = .pinching
page.readerBeginExternalPinch(at: point)
case .changed:
if case .pinching = curlPanInteraction {
page.readerUpdateExternalPinch(scale: gesture.scale, at: point)
}
case .ended, .cancelled, .failed:
page.readerEndExternalPinch()
// recognizer
//
curlPanGestureRecognizer.isEnabled = false
curlPanInteraction = .idle
curlPanGestureRecognizer.isEnabled = currentDisplayType == .pageCurl && isPagingEnabled
default:
break
}
}
func handleContentTap(_ point: CGPoint) {
if isCurrentPageTextSelectionActive {
(curlContentView as? RDPDFReaderPageInteractable)?.readerClearTextSelection()
return
}
if isToolViewVisible {
toggleToolbars()
} else if point.y < bounds.height / 3 {
transitionToPage(pageNum: currentPage - 1, animated: true)
} else if point.y > bounds.height * 2 / 3 {
transitionToPage(pageNum: currentPage + 1, animated: true)
} else {
toggleToolbars()
}
}
}

View File

@ -0,0 +1,66 @@
import UIKit
/// SDK
public final class RDPDFReaderHighlightOverlayView: UIView {
public var highlights: [RDPDFReaderHighlight] = [] { didSet { setNeedsDisplay() } }
public override init(frame: CGRect) {
super.init(frame: frame)
isOpaque = false
backgroundColor = .clear
isUserInteractionEnabled = false
}
public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
public override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
context.clear(rect)
for highlight in highlights {
let color = UIColor(hexString: highlight.color)
context.setStrokeColor(color.cgColor)
context.setLineWidth(2)
let normalizedRect = highlight.normalizedRect
let underlineRect = CGRect(x: rect.width * normalizedRect.minX, y: rect.height * normalizedRect.minY, width: rect.width * normalizedRect.width, height: rect.height * normalizedRect.height)
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()
}
}
}
/// SDK
public final class RDPDFReaderSearchHighlightOverlayView: UIView {
public var matchRects: [CGRect] = [] { didSet { setNeedsDisplay() } }
public var activeIndex = -1 { didSet { setNeedsDisplay() } }
public override init(frame: CGRect) {
super.init(frame: frame)
isOpaque = false
backgroundColor = .clear
isUserInteractionEnabled = false
}
public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
public override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
context.clear(rect)
for (index, matchRect) in matchRects.enumerated() {
let color = index == activeIndex
? UIColor(red: 0.14, green: 0.42, blue: 0.95, alpha: 0.34)
: UIColor(red: 0.21, green: 0.48, blue: 0.95, alpha: 0.16)
context.setFillColor(color.cgColor)
context.fill(CGRect(x: rect.width * matchRect.minX, y: rect.height * matchRect.minY, width: rect.width * matchRect.width, height: rect.height * matchRect.height))
}
}
}
private extension UIColor {
convenience init(hexString: String) {
let hex = hexString.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "#", with: "")
let value = UInt32(hex, radix: 16) ?? 0
self.init(red: CGFloat((value >> 16) & 0xFF) / 255, green: CGFloat((value >> 8) & 0xFF) / 255, blue: CGFloat(value & 0xFF) / 255, alpha: 1)
}
}