From fed34da246e1952b5f308e7c11b6b75e4c0686fc Mon Sep 17 00:00:00 2001 From: shenlei Date: Wed, 15 Jul 2026 10:01:23 +0900 Subject: [PATCH] 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 --- .../ReadViewDemo/PDFDemoAnnotationUI.swift | 239 ++++++++++++++++++ .../ReaderView/RDPDFReaderView+Paging.swift | 194 ++++++++++++++ .../RDPDFReaderHighlightOverlayView.swift | 66 +++++ 3 files changed, 499 insertions(+) create mode 100644 ReadViewDemo/ReadViewDemo/PDFDemoAnnotationUI.swift create mode 100644 Sources/RDPDFReaderView/ReaderView/RDPDFReaderView+Paging.swift create mode 100644 Sources/RDPDFReaderView/Sources/RDPDFReaderHighlightOverlayView.swift diff --git a/ReadViewDemo/ReadViewDemo/PDFDemoAnnotationUI.swift b/ReadViewDemo/ReadViewDemo/PDFDemoAnnotationUI.swift new file mode 100644 index 0000000..f328b67 --- /dev/null +++ b/ReadViewDemo/ReadViewDemo/PDFDemoAnnotationUI.swift @@ -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 + ) + } +} diff --git a/Sources/RDPDFReaderView/ReaderView/RDPDFReaderView+Paging.swift b/Sources/RDPDFReaderView/ReaderView/RDPDFReaderView+Paging.swift new file mode 100644 index 0000000..7158ed5 --- /dev/null +++ b/Sources/RDPDFReaderView/ReaderView/RDPDFReaderView+Paging.swift @@ -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() + } + } +} diff --git a/Sources/RDPDFReaderView/Sources/RDPDFReaderHighlightOverlayView.swift b/Sources/RDPDFReaderView/Sources/RDPDFReaderHighlightOverlayView.swift new file mode 100644 index 0000000..d5595e8 --- /dev/null +++ b/Sources/RDPDFReaderView/Sources/RDPDFReaderHighlightOverlayView.swift @@ -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) + } +}