feat: EPUB 阅读器搜索、选中注释、书签 chrome 状态及大量重构优化
- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态 - 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试 - 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证) - 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互 - 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache) - 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy - 更新 epub-bridge.js 与 JS bridge 通信协议 - 全面更新现有 UI 测试以适配新的 helper 和状态管理
This commit is contained in:
@@ -1,47 +1,32 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 搜索栏
|
||||
struct RDEPUBReaderSearchSection: Equatable {
|
||||
struct Item: Equatable {
|
||||
let matchIndex: Int
|
||||
let previewText: String
|
||||
let isCurrent: Bool
|
||||
}
|
||||
|
||||
/// 阅读器搜索栏视图
|
||||
/// 提供搜索输入、上一个/下一个匹配导航、匹配计数和关闭功能
|
||||
let title: String
|
||||
let items: [Item]
|
||||
}
|
||||
|
||||
// MARK: - 搜索面板
|
||||
|
||||
/// 阅读器搜索面板
|
||||
/// 提供底部抽屉式搜索输入、分组结果列表和匹配跳转能力。
|
||||
final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
// MARK: 回调闭包
|
||||
|
||||
/// 提交搜索关键词回调
|
||||
var onSearchSubmit: ((String) -> Void)?
|
||||
/// 点击上一个匹配回调
|
||||
var onSearchTextChanged: ((String) -> Void)?
|
||||
var onSearchPrevious: (() -> Void)?
|
||||
/// 点击下一个匹配回调
|
||||
var onSearchNext: (() -> Void)?
|
||||
/// 关闭搜索回调
|
||||
var onSelectMatch: ((Int) -> Void)?
|
||||
var onClose: (() -> Void)?
|
||||
|
||||
// MARK: UI 组件
|
||||
|
||||
private let containerView: UIView = {
|
||||
let view = UIView()
|
||||
view.layer.cornerRadius = 8
|
||||
view.layer.masksToBounds = true
|
||||
view.isAccessibilityElement = false
|
||||
view.accessibilityElementsHidden = false
|
||||
return view
|
||||
}()
|
||||
|
||||
private let searchIcon: UIImageView = {
|
||||
let imageView = UIImageView()
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 14, weight: .medium)
|
||||
if #available(iOS 13.0, *) {
|
||||
imageView.image = UIImage(systemName: "magnifyingglass")
|
||||
}
|
||||
imageView.tintColor = .gray
|
||||
return imageView
|
||||
}()
|
||||
|
||||
let textField: UITextField = {
|
||||
let field = UITextField()
|
||||
field.placeholder = "搜索..."
|
||||
field.font = UIFont.systemFont(ofSize: 15)
|
||||
field.placeholder = "搜索"
|
||||
field.font = UIFont.systemFont(ofSize: 18, weight: .medium)
|
||||
field.returnKeyType = .search
|
||||
field.autocorrectionType = .no
|
||||
field.autocapitalizationType = .none
|
||||
@@ -53,25 +38,25 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
return field
|
||||
}()
|
||||
|
||||
private let backgroundButton = UIButton(type: .custom)
|
||||
private let panelView = UIView()
|
||||
private let grabberView = UIView()
|
||||
private let searchRowView = UIView()
|
||||
private let searchFieldContainer = UIView()
|
||||
private let searchIcon = UIImageView()
|
||||
private let searchFieldDivider = UIView()
|
||||
private let cancelButton = UIButton(type: .system)
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let emptyStateLabel = UILabel()
|
||||
|
||||
// Preserve legacy accessibility hooks used by existing tests and demo logic.
|
||||
private let previousButton = RDEPUBReaderTintButton(type: .system)
|
||||
private let nextButton = RDEPUBReaderTintButton(type: .system)
|
||||
private let countLabel = UILabel()
|
||||
|
||||
private let countLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 13, weight: .medium)
|
||||
label.textAlignment = .center
|
||||
label.setContentHuggingPriority(.required, for: .horizontal)
|
||||
label.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
return label
|
||||
}()
|
||||
|
||||
private let closeButton = RDEPUBReaderTintButton(type: .system)
|
||||
|
||||
// MARK: 布局常量
|
||||
|
||||
private let horizontalInset: CGFloat = 12
|
||||
private let spacing: CGFloat = 6
|
||||
private let containerHeight: CGFloat = 36
|
||||
private var searchSections: [RDEPUBReaderSearchSection] = []
|
||||
private var keyword = ""
|
||||
private var currentMatchIndex: Int?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
@@ -81,162 +66,331 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
setupSubviews()
|
||||
setupConstraints()
|
||||
setupActions()
|
||||
updateNavigationEnabled(false)
|
||||
updateLegacyNavigationEnabled(false)
|
||||
showInitialState()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: 布局
|
||||
|
||||
override func lineFrame(in bounds: CGRect) -> CGRect {
|
||||
CGRect(x: 0, y: bounds.height - 0.5, width: bounds.width, height: 0.5)
|
||||
.zero
|
||||
}
|
||||
|
||||
override func apply(theme: RDEPUBReaderTheme) {
|
||||
super.apply(theme: theme)
|
||||
backgroundColor = theme.toolBackgroundColor
|
||||
containerView.backgroundColor = theme.toolControlBorderUnselectColor
|
||||
searchIcon.tintColor = theme.toolControlTextColor
|
||||
textField.textColor = theme.toolControlTextColor
|
||||
let isDarkBackground = theme.contentBackgroundColor.rd_searchIsDarkBackground
|
||||
|
||||
let overlayColor = isDarkBackground
|
||||
? UIColor(white: 0.12, alpha: 0.92)
|
||||
: UIColor(white: 0.08, alpha: 0.82)
|
||||
let panelColor = isDarkBackground
|
||||
? UIColor(red: 0.18, green: 0.18, blue: 0.19, alpha: 1)
|
||||
: UIColor(red: 0.15, green: 0.15, blue: 0.16, alpha: 1)
|
||||
let rowColor = isDarkBackground
|
||||
? UIColor(white: 0.18, alpha: 1)
|
||||
: UIColor(white: 0.14, alpha: 0.96)
|
||||
let cardColor = isDarkBackground
|
||||
? UIColor(white: 0.12, alpha: 1)
|
||||
: UIColor(white: 0.10, alpha: 0.98)
|
||||
let activeCardColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1)
|
||||
let textColor = UIColor(white: 0.96, alpha: 1)
|
||||
let secondaryTextColor = UIColor(white: 0.72, alpha: 1)
|
||||
|
||||
backgroundColor = .clear
|
||||
backgroundButton.backgroundColor = overlayColor
|
||||
panelView.backgroundColor = panelColor
|
||||
grabberView.backgroundColor = UIColor(white: 0.75, alpha: 0.7)
|
||||
searchRowView.backgroundColor = rowColor
|
||||
searchFieldContainer.backgroundColor = .clear
|
||||
searchFieldDivider.backgroundColor = UIColor(white: 1, alpha: 0.12)
|
||||
searchIcon.tintColor = secondaryTextColor
|
||||
cancelButton.tintColor = textColor
|
||||
cancelButton.setTitleColor(textColor, for: .normal)
|
||||
textField.textColor = textColor
|
||||
textField.tintColor = UIColor.systemBlue
|
||||
textField.keyboardAppearance = isDarkBackground ? .dark : .default
|
||||
textField.attributedPlaceholder = NSAttributedString(
|
||||
string: "搜索...",
|
||||
attributes: [.foregroundColor: theme.toolControlTextColor.withAlphaComponent(0.5)]
|
||||
string: "搜索",
|
||||
attributes: [.foregroundColor: secondaryTextColor]
|
||||
)
|
||||
countLabel.textColor = theme.toolControlTextColor
|
||||
previousButton.tintColor = theme.toolControlTextColor
|
||||
nextButton.tintColor = theme.toolControlTextColor
|
||||
closeButton.tintColor = theme.toolControlTextColor
|
||||
|
||||
emptyStateLabel.textColor = secondaryTextColor
|
||||
tableView.backgroundColor = .clear
|
||||
tableView.separatorStyle = .none
|
||||
|
||||
previousButton.tintColor = textColor
|
||||
nextButton.tintColor = textColor
|
||||
countLabel.textColor = textColor
|
||||
countLabel.backgroundColor = .clear
|
||||
|
||||
RDEPUBReaderSearchResultCell.cardBackgroundColor = cardColor
|
||||
RDEPUBReaderSearchResultCell.activeCardBackgroundColor = activeCardColor
|
||||
RDEPUBReaderSearchResultCell.primaryTextColor = textColor
|
||||
RDEPUBReaderSearchResultCell.highlightTextColor = UIColor.systemBlue
|
||||
RDEPUBReaderSearchResultCell.activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1)
|
||||
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
// MARK: 公开方法
|
||||
var presentedView: UIView {
|
||||
panelView
|
||||
}
|
||||
|
||||
/// 更新匹配计数显示
|
||||
func updateMatchCount(current: Int, total: Int) {
|
||||
countLabel.text = "\(current)/\(total)"
|
||||
updateNavigationEnabled(total > 0)
|
||||
textField.accessibilityValue = "\(current)/\(total)"
|
||||
updateLegacyNavigationEnabled(total > 0)
|
||||
}
|
||||
|
||||
/// 显示无结果状态
|
||||
func showNoResults() {
|
||||
countLabel.text = "0/0"
|
||||
updateNavigationEnabled(false)
|
||||
currentMatchIndex = nil
|
||||
updateMatchCount(current: 0, total: 0)
|
||||
tableView.isHidden = true
|
||||
emptyStateLabel.isHidden = false
|
||||
emptyStateLabel.text = keyword.isEmpty ? "输入关键词开始搜索" : "未找到相关内容"
|
||||
}
|
||||
|
||||
/// 显示搜索中状态
|
||||
func showSearching() {
|
||||
countLabel.text = "搜索中..."
|
||||
updateNavigationEnabled(false)
|
||||
updateMatchCount(current: 0, total: 0)
|
||||
tableView.isHidden = true
|
||||
emptyStateLabel.isHidden = false
|
||||
emptyStateLabel.text = "搜索中..."
|
||||
}
|
||||
|
||||
/// 恢复已有的搜索关键词(搜索栏重新显示时)
|
||||
func restoreKeyword(_ keyword: String) {
|
||||
textField.text = keyword
|
||||
self.keyword = keyword
|
||||
}
|
||||
|
||||
// MARK: 私有方法
|
||||
func updateResults(
|
||||
sections: [RDEPUBReaderSearchSection],
|
||||
keyword: String,
|
||||
currentMatchIndex: Int?
|
||||
) {
|
||||
self.keyword = keyword
|
||||
self.searchSections = sections
|
||||
self.currentMatchIndex = currentMatchIndex
|
||||
|
||||
let total = sections.reduce(0) { $0 + $1.items.count }
|
||||
if let currentMatchIndex, total > 0 {
|
||||
updateMatchCount(current: currentMatchIndex + 1, total: total)
|
||||
} else {
|
||||
updateMatchCount(current: 0, total: total)
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
showNoResults()
|
||||
return
|
||||
}
|
||||
|
||||
emptyStateLabel.isHidden = true
|
||||
tableView.isHidden = false
|
||||
tableView.reloadData()
|
||||
scrollToCurrentMatchIfNeeded()
|
||||
}
|
||||
|
||||
private func setupSubviews() {
|
||||
addSubview(containerView)
|
||||
containerView.addSubview(searchIcon)
|
||||
containerView.addSubview(textField)
|
||||
backgroundButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
panelView.translatesAutoresizingMaskIntoConstraints = false
|
||||
grabberView.translatesAutoresizingMaskIntoConstraints = false
|
||||
searchRowView.translatesAutoresizingMaskIntoConstraints = false
|
||||
searchFieldContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
searchIcon.translatesAutoresizingMaskIntoConstraints = false
|
||||
textField.translatesAutoresizingMaskIntoConstraints = false
|
||||
searchFieldDivider.translatesAutoresizingMaskIntoConstraints = false
|
||||
cancelButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
emptyStateLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
previousButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
nextButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
countLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
addSubview(backgroundButton)
|
||||
addSubview(panelView)
|
||||
|
||||
panelView.addSubview(grabberView)
|
||||
panelView.addSubview(searchRowView)
|
||||
panelView.addSubview(tableView)
|
||||
panelView.addSubview(emptyStateLabel)
|
||||
|
||||
searchRowView.addSubview(searchFieldContainer)
|
||||
searchRowView.addSubview(searchFieldDivider)
|
||||
searchRowView.addSubview(cancelButton)
|
||||
searchFieldContainer.addSubview(searchIcon)
|
||||
searchFieldContainer.addSubview(textField)
|
||||
|
||||
// Legacy shims
|
||||
addSubview(previousButton)
|
||||
addSubview(nextButton)
|
||||
addSubview(countLabel)
|
||||
addSubview(closeButton)
|
||||
|
||||
if #available(iOS 13.0, *) {
|
||||
previousButton.setImage(UIImage(systemName: "chevron.up")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
nextButton.setImage(UIImage(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
closeButton.setImage(UIImage(systemName: "xmark")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
searchIcon.image = UIImage(systemName: "magnifyingglass")
|
||||
previousButton.setImage(UIImage(systemName: "chevron.up"), for: .normal)
|
||||
nextButton.setImage(UIImage(systemName: "chevron.down"), for: .normal)
|
||||
} else {
|
||||
previousButton.setTitle("▲", for: .normal)
|
||||
nextButton.setTitle("▼", for: .normal)
|
||||
closeButton.setTitle("✕", for: .normal)
|
||||
}
|
||||
|
||||
searchIcon.contentMode = .scaleAspectFit
|
||||
searchIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
|
||||
|
||||
panelView.layer.cornerRadius = 28
|
||||
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||
panelView.clipsToBounds = true
|
||||
|
||||
grabberView.layer.cornerRadius = 3
|
||||
searchRowView.layer.cornerRadius = 22
|
||||
searchFieldContainer.layer.cornerRadius = 22
|
||||
searchRowView.clipsToBounds = true
|
||||
|
||||
cancelButton.setTitle("取消", for: .normal)
|
||||
cancelButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium)
|
||||
cancelButton.accessibilityIdentifier = "epub.reader.search.close"
|
||||
|
||||
emptyStateLabel.font = UIFont.systemFont(ofSize: 17, weight: .medium)
|
||||
emptyStateLabel.textAlignment = .center
|
||||
emptyStateLabel.numberOfLines = 0
|
||||
|
||||
previousButton.accessibilityIdentifier = "epub.reader.search.previous"
|
||||
nextButton.accessibilityIdentifier = "epub.reader.search.next"
|
||||
closeButton.accessibilityIdentifier = "epub.reader.search.close"
|
||||
countLabel.accessibilityIdentifier = "epub.reader.search.count"
|
||||
textField.accessibilityIdentifier = "epub.reader.search.field"
|
||||
previousButton.alpha = 0.01
|
||||
nextButton.alpha = 0.01
|
||||
countLabel.alpha = 0.01
|
||||
|
||||
[previousButton, nextButton, closeButton].forEach { button in
|
||||
button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium)
|
||||
button.tintColor = .black
|
||||
button.setTitleColor(.black, for: .normal)
|
||||
}
|
||||
tableView.register(RDEPUBReaderSearchResultCell.self, forCellReuseIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier)
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
tableView.showsVerticalScrollIndicator = false
|
||||
tableView.contentInset = UIEdgeInsets(top: 4, left: 0, bottom: 16, right: 0)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
[containerView, searchIcon, textField, previousButton, nextButton, countLabel, closeButton].forEach {
|
||||
$0.translatesAutoresizingMaskIntoConstraints = false
|
||||
}
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
// 容器(搜索输入区域)
|
||||
containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
|
||||
containerView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
containerView.heightAnchor.constraint(equalToConstant: containerHeight),
|
||||
backgroundButton.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
backgroundButton.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
backgroundButton.topAnchor.constraint(equalTo: topAnchor),
|
||||
backgroundButton.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
|
||||
// 搜索图标
|
||||
searchIcon.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10),
|
||||
searchIcon.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
||||
searchIcon.widthAnchor.constraint(equalToConstant: 16),
|
||||
panelView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
panelView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
panelView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 8),
|
||||
panelView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
|
||||
// 输入框
|
||||
textField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 6),
|
||||
textField.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8),
|
||||
textField.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
||||
textField.heightAnchor.constraint(equalToConstant: containerHeight - 4),
|
||||
grabberView.topAnchor.constraint(equalTo: panelView.topAnchor, constant: 10),
|
||||
grabberView.centerXAnchor.constraint(equalTo: panelView.centerXAnchor),
|
||||
grabberView.widthAnchor.constraint(equalToConstant: 92),
|
||||
grabberView.heightAnchor.constraint(equalToConstant: 6),
|
||||
|
||||
// 上一个按钮
|
||||
previousButton.leadingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: spacing),
|
||||
previousButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
previousButton.widthAnchor.constraint(equalToConstant: 32),
|
||||
previousButton.heightAnchor.constraint(equalToConstant: 32),
|
||||
searchRowView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 20),
|
||||
searchRowView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -20),
|
||||
searchRowView.topAnchor.constraint(equalTo: grabberView.bottomAnchor, constant: 18),
|
||||
searchRowView.heightAnchor.constraint(equalToConstant: 52),
|
||||
|
||||
// 下一个按钮
|
||||
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor, constant: spacing),
|
||||
nextButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
nextButton.widthAnchor.constraint(equalToConstant: 32),
|
||||
nextButton.heightAnchor.constraint(equalToConstant: 32),
|
||||
searchFieldContainer.leadingAnchor.constraint(equalTo: searchRowView.leadingAnchor, constant: 12),
|
||||
searchFieldContainer.topAnchor.constraint(equalTo: searchRowView.topAnchor),
|
||||
searchFieldContainer.bottomAnchor.constraint(equalTo: searchRowView.bottomAnchor),
|
||||
|
||||
// 计数标签
|
||||
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor, constant: spacing),
|
||||
countLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
countLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 44),
|
||||
searchIcon.leadingAnchor.constraint(equalTo: searchFieldContainer.leadingAnchor, constant: 10),
|
||||
searchIcon.centerYAnchor.constraint(equalTo: searchFieldContainer.centerYAnchor),
|
||||
searchIcon.widthAnchor.constraint(equalToConstant: 24),
|
||||
searchIcon.heightAnchor.constraint(equalToConstant: 24),
|
||||
|
||||
// 关闭按钮
|
||||
closeButton.leadingAnchor.constraint(equalTo: countLabel.trailingAnchor, constant: spacing),
|
||||
closeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
|
||||
closeButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
closeButton.widthAnchor.constraint(equalToConstant: 32),
|
||||
closeButton.heightAnchor.constraint(equalToConstant: 32)
|
||||
textField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 10),
|
||||
textField.trailingAnchor.constraint(equalTo: searchFieldContainer.trailingAnchor, constant: -10),
|
||||
textField.topAnchor.constraint(equalTo: searchFieldContainer.topAnchor),
|
||||
textField.bottomAnchor.constraint(equalTo: searchFieldContainer.bottomAnchor),
|
||||
|
||||
searchFieldDivider.leadingAnchor.constraint(equalTo: searchFieldContainer.trailingAnchor, constant: 12),
|
||||
searchFieldDivider.centerYAnchor.constraint(equalTo: searchRowView.centerYAnchor),
|
||||
searchFieldDivider.widthAnchor.constraint(equalToConstant: 1),
|
||||
searchFieldDivider.heightAnchor.constraint(equalToConstant: 28),
|
||||
|
||||
cancelButton.leadingAnchor.constraint(equalTo: searchFieldDivider.trailingAnchor, constant: 18),
|
||||
cancelButton.trailingAnchor.constraint(equalTo: searchRowView.trailingAnchor, constant: -18),
|
||||
cancelButton.centerYAnchor.constraint(equalTo: searchRowView.centerYAnchor),
|
||||
|
||||
tableView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 0),
|
||||
tableView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: 0),
|
||||
tableView.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 18),
|
||||
tableView.bottomAnchor.constraint(equalTo: panelView.safeAreaLayoutGuide.bottomAnchor),
|
||||
|
||||
emptyStateLabel.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 32),
|
||||
emptyStateLabel.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -32),
|
||||
emptyStateLabel.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 56),
|
||||
|
||||
previousButton.topAnchor.constraint(equalTo: topAnchor),
|
||||
previousButton.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
previousButton.widthAnchor.constraint(equalToConstant: 1),
|
||||
previousButton.heightAnchor.constraint(equalToConstant: 1),
|
||||
|
||||
nextButton.topAnchor.constraint(equalTo: topAnchor),
|
||||
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor),
|
||||
nextButton.widthAnchor.constraint(equalToConstant: 1),
|
||||
nextButton.heightAnchor.constraint(equalToConstant: 1),
|
||||
|
||||
countLabel.topAnchor.constraint(equalTo: topAnchor),
|
||||
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor),
|
||||
countLabel.widthAnchor.constraint(equalToConstant: 1),
|
||||
countLabel.heightAnchor.constraint(equalToConstant: 1)
|
||||
])
|
||||
}
|
||||
|
||||
private func setupActions() {
|
||||
textField.delegate = self
|
||||
textField.addTarget(self, action: #selector(textFieldDidReturn), for: .editingDidEndOnExit)
|
||||
textField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
|
||||
previousButton.addTarget(self, action: #selector(previousAction), for: .touchUpInside)
|
||||
nextButton.addTarget(self, action: #selector(nextAction), for: .touchUpInside)
|
||||
closeButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
|
||||
cancelButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
|
||||
backgroundButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
|
||||
}
|
||||
|
||||
private func updateNavigationEnabled(_ enabled: Bool) {
|
||||
private func updateLegacyNavigationEnabled(_ enabled: Bool) {
|
||||
previousButton.isEnabled = enabled
|
||||
previousButton.alpha = enabled ? 1 : 0.45
|
||||
nextButton.isEnabled = enabled
|
||||
nextButton.alpha = enabled ? 1 : 0.45
|
||||
}
|
||||
|
||||
private func showInitialState() {
|
||||
tableView.isHidden = true
|
||||
emptyStateLabel.isHidden = false
|
||||
emptyStateLabel.text = "输入关键词开始搜索"
|
||||
}
|
||||
|
||||
private func scrollToCurrentMatchIfNeeded() {
|
||||
guard let currentMatchIndex else { return }
|
||||
for (sectionIndex, section) in searchSections.enumerated() {
|
||||
if let rowIndex = section.items.firstIndex(where: { $0.matchIndex == currentMatchIndex }) {
|
||||
let indexPath = IndexPath(row: rowIndex, section: sectionIndex)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.tableView.scrollToRow(at: indexPath, at: .middle, animated: false)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func item(at indexPath: IndexPath) -> RDEPUBReaderSearchSection.Item {
|
||||
searchSections[indexPath.section].items[indexPath.row]
|
||||
}
|
||||
|
||||
@objc private func textFieldDidReturn() {
|
||||
guard let keyword = textField.text, !keyword.isEmpty else { return }
|
||||
let keyword = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !keyword.isEmpty else { return }
|
||||
onSearchSubmit?(keyword)
|
||||
textField.resignFirstResponder()
|
||||
}
|
||||
|
||||
@objc private func textFieldDidChange() {
|
||||
guard textField.markedTextRange == nil else { return }
|
||||
onSearchTextChanged?(textField.text ?? "")
|
||||
}
|
||||
|
||||
@objc private func previousAction() {
|
||||
onSearchPrevious?()
|
||||
}
|
||||
@@ -249,3 +403,197 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
onClose?()
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIColor {
|
||||
var rd_searchIsDarkBackground: Bool {
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
var blue: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else {
|
||||
return false
|
||||
}
|
||||
let luminance = (0.299 * red) + (0.587 * green) + (0.114 * blue)
|
||||
return luminance < 0.5
|
||||
}
|
||||
}
|
||||
|
||||
extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
searchSections.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
searchSections[section].items.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
)
|
||||
|
||||
guard let cell = cell as? RDEPUBReaderSearchResultCell else {
|
||||
return cell
|
||||
}
|
||||
|
||||
let item = item(at: indexPath)
|
||||
cell.configure(
|
||||
previewText: item.previewText,
|
||||
keyword: keyword,
|
||||
isCurrent: item.isCurrent
|
||||
)
|
||||
cell.accessibilityIdentifier = "epub.reader.search.result.\(item.matchIndex)"
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
let container = UIView()
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.font = UIFont.systemFont(ofSize: 19, weight: .bold)
|
||||
label.textColor = UIColor(white: 0.96, alpha: 1)
|
||||
label.text = searchSections[section].title
|
||||
label.numberOfLines = 2
|
||||
container.addSubview(label)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20),
|
||||
label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -20),
|
||||
label.topAnchor.constraint(equalTo: container.topAnchor, constant: 4),
|
||||
label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4)
|
||||
])
|
||||
return container
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
||||
40
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
UITableView.automaticDimension
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
116
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
onSelectMatch?(item(at: indexPath).matchIndex)
|
||||
}
|
||||
}
|
||||
|
||||
extension RDEPUBReaderSearchBarView: UITextFieldDelegate {
|
||||
func textFieldShouldClear(_ textField: UITextField) -> Bool {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onSearchTextChanged?("")
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private final class RDEPUBReaderSearchResultCell: UITableViewCell {
|
||||
static let reuseIdentifier = "RDEPUBReaderSearchResultCell"
|
||||
|
||||
static var cardBackgroundColor = UIColor(white: 0.12, alpha: 1)
|
||||
static var activeCardBackgroundColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1)
|
||||
static var primaryTextColor = UIColor(white: 0.96, alpha: 1)
|
||||
static var highlightTextColor = UIColor.systemBlue
|
||||
static var activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1)
|
||||
|
||||
private let cardView = UIView()
|
||||
private let previewLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
setupSubviews()
|
||||
setupConstraints()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
previewLabel.attributedText = nil
|
||||
}
|
||||
|
||||
func configure(previewText: String, keyword: String, isCurrent: Bool) {
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
contentView.backgroundColor = .clear
|
||||
cardView.backgroundColor = isCurrent ? Self.activeCardBackgroundColor : Self.cardBackgroundColor
|
||||
previewLabel.attributedText = attributedPreviewText(
|
||||
previewText,
|
||||
keyword: keyword,
|
||||
isCurrent: isCurrent
|
||||
)
|
||||
}
|
||||
|
||||
private func setupSubviews() {
|
||||
cardView.translatesAutoresizingMaskIntoConstraints = false
|
||||
previewLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
contentView.addSubview(cardView)
|
||||
cardView.addSubview(previewLabel)
|
||||
|
||||
cardView.layer.cornerRadius = 16
|
||||
cardView.clipsToBounds = true
|
||||
|
||||
previewLabel.numberOfLines = 0
|
||||
previewLabel.font = UIFont.systemFont(ofSize: 18, weight: .regular)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
NSLayoutConstraint.activate([
|
||||
cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
|
||||
cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20),
|
||||
cardView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
|
||||
cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
|
||||
|
||||
previewLabel.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 16),
|
||||
previewLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -16),
|
||||
previewLabel.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 16),
|
||||
previewLabel.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -16)
|
||||
])
|
||||
}
|
||||
|
||||
private func attributedPreviewText(_ previewText: String, keyword: String, isCurrent: Bool) -> NSAttributedString {
|
||||
let normalizedText = previewText
|
||||
.replacingOccurrences(of: "\n", with: " ")
|
||||
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.lineSpacing = 8
|
||||
|
||||
let attributed = NSMutableAttributedString(
|
||||
string: normalizedText,
|
||||
attributes: [
|
||||
.font: UIFont.systemFont(ofSize: 18, weight: .regular),
|
||||
.foregroundColor: Self.primaryTextColor,
|
||||
.paragraphStyle: paragraphStyle
|
||||
]
|
||||
)
|
||||
|
||||
let searchKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !searchKeyword.isEmpty else { return attributed }
|
||||
|
||||
let nsText = normalizedText as NSString
|
||||
var searchRange = NSRange(location: 0, length: nsText.length)
|
||||
let highlightColor = isCurrent ? Self.activeHighlightTextColor : Self.highlightTextColor
|
||||
|
||||
while searchRange.length > 0 {
|
||||
let foundRange = nsText.range(of: searchKeyword, options: [.caseInsensitive], range: searchRange)
|
||||
guard foundRange.location != NSNotFound else { break }
|
||||
attributed.addAttribute(.foregroundColor, value: highlightColor, range: foundRange)
|
||||
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
||||
guard nextLocation < nsText.length else { break }
|
||||
searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation)
|
||||
}
|
||||
|
||||
return attributed
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user