阅读器在多会话朗读、加密 EPUB 资源、并发打开同一本书和多 UserDefaults 容器等场景下,存在旧异步结果覆盖新状态、锁屏控制串会话、大型明文资源被误拒绝、解压半成品被复用及标注跨容器混用的风险;同时高亮、书签、搜索和设置面板的空状态与自动化可访问性入口不完整。\n\n本次为朗读会话引入代次和当前 utterance 校验,远程控制改为仅响应当前会话并按自身 token 清理;加密资源 provider 先判定是否实际返回解密数据,明文大资源继续流式读取;解压缓存串行化以避免并发复用未完成目录。书签与高亮迁移至受保护文件,按 UserDefaults 容器隔离命名空间,保留标准容器旧文件兼容并确保新副本成功落盘后才删除历史数据。\n\n同步调整缓存淘汰、FoundationModels 弱链接、搜索/标注/设置面板交互与 UI 测试,并更新 Pod 生成配置及 API、风险文档。已执行 git diff --check 和 ReadViewDemo Debug Simulator 构建,结果为 BUILD SUCCEEDED。
654 lines
27 KiB
Swift
654 lines
27 KiB
Swift
import UIKit
|
|
|
|
struct RDEPUBReaderSearchSection: Equatable {
|
|
|
|
struct Item: Equatable {
|
|
|
|
let matchIndex: Int
|
|
|
|
let previewText: String
|
|
|
|
let isCurrent: Bool
|
|
}
|
|
|
|
let title: String
|
|
|
|
let items: [Item]
|
|
}
|
|
|
|
final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
|
|
|
var onSearchSubmit: ((String) -> Void)?
|
|
|
|
var onSearchTextChanged: ((String) -> Void)?
|
|
|
|
var onSearchPrevious: (() -> Void)?
|
|
|
|
var onSearchNext: (() -> Void)?
|
|
|
|
var onSelectMatch: ((Int) -> Void)?
|
|
|
|
var onClose: (() -> Void)?
|
|
|
|
let textField: UITextField = {
|
|
let field = UITextField()
|
|
field.placeholder = "搜索"
|
|
field.font = UIFont.systemFont(ofSize: 18, weight: .medium)
|
|
field.returnKeyType = .search
|
|
field.autocorrectionType = .no
|
|
field.autocapitalizationType = .none
|
|
field.clearButtonMode = .whileEditing
|
|
field.isAccessibilityElement = true
|
|
if #available(iOS 13.0, *) {
|
|
field.accessibilityTraits = .searchField
|
|
}
|
|
return field
|
|
}()
|
|
|
|
private let backgroundButton = UIButton(type: .custom)
|
|
|
|
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()
|
|
|
|
private let navigationRowView = UIView()
|
|
|
|
private let previousButton = RDEPUBReaderTintButton(type: .system)
|
|
|
|
private let nextButton = RDEPUBReaderTintButton(type: .system)
|
|
|
|
private let countLabel = UILabel()
|
|
|
|
/// Collapsed to 0 in the pristine state so the prompt keeps its original
|
|
/// spacing under the search field. See `updateNavigationRow(total:)`.
|
|
private var navigationRowHeightConstraint: NSLayoutConstraint?
|
|
|
|
private var searchSections: [RDEPUBReaderSearchSection] = []
|
|
|
|
private var keyword = ""
|
|
|
|
private var currentMatchIndex: Int?
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
accessibilityIdentifier = "epub.reader.search.bar"
|
|
shouldGroupAccessibilityChildren = false
|
|
isAccessibilityElement = false
|
|
setupSubviews()
|
|
setupConstraints()
|
|
setupActions()
|
|
updateNavigationRow(total: 0)
|
|
showInitialState()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
// Only claim touches that fall within the panelView. Taps on the dimmed
|
|
// scrim area pass through to the reader content below, allowing chrome
|
|
// toggle taps to work. The step controls live inside the panel, so they
|
|
// no longer need a special case here.
|
|
let panelPoint = convert(point, to: panelView)
|
|
if panelView.point(inside: panelPoint, with: event) {
|
|
return super.hitTest(point, with: event)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
override func lineFrame(in bounds: CGRect) -> CGRect {
|
|
.zero
|
|
}
|
|
|
|
override func apply(theme: RDEPUBReaderTheme) {
|
|
super.apply(theme: theme)
|
|
let isDarkBackground = theme.contentBackgroundColor.rd_isDarkBackground
|
|
|
|
let overlayColor = UIColor(white: 0.05, alpha: 0.32)
|
|
let panelColor = UIColor(red: 0.975, green: 0.965, blue: 0.935, alpha: 1)
|
|
let rowColor = UIColor.white.withAlphaComponent(0.88)
|
|
let cardColor = UIColor.white.withAlphaComponent(0.78)
|
|
let activeCardColor = UIColor(red: 0.90, green: 0.94, blue: 1.0, alpha: 1)
|
|
let textColor = UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)
|
|
let secondaryTextColor = UIColor(red: 0.38, green: 0.40, blue: 0.44, 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.black.withAlphaComponent(0.10)
|
|
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: secondaryTextColor]
|
|
)
|
|
|
|
emptyStateLabel.textColor = secondaryTextColor
|
|
tableView.backgroundColor = .clear
|
|
tableView.separatorStyle = .none
|
|
|
|
navigationRowView.backgroundColor = rowColor
|
|
previousButton.tintColor = textColor
|
|
nextButton.tintColor = textColor
|
|
countLabel.textColor = secondaryTextColor
|
|
countLabel.backgroundColor = .clear
|
|
|
|
RDEPUBReaderSearchResultCell.cardBackgroundColor = cardColor
|
|
RDEPUBReaderSearchResultCell.activeCardBackgroundColor = activeCardColor
|
|
RDEPUBReaderSearchResultCell.primaryTextColor = textColor
|
|
RDEPUBReaderSearchResultCell.highlightTextColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
|
|
RDEPUBReaderSearchResultCell.activeHighlightTextColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
|
|
|
|
tableView.reloadData()
|
|
}
|
|
|
|
var presentedView: UIView {
|
|
panelView
|
|
}
|
|
|
|
func updateMatchCount(current: Int, total: Int) {
|
|
// label 保持 "N/M" 原文:无障碍与 UI 测试都按这个格式读取,
|
|
// 覆盖 accessibilityLabel 会让 XCUIElement.label 拿不到计数。
|
|
countLabel.text = "\(current)/\(total)"
|
|
textField.accessibilityValue = "\(current)/\(total)"
|
|
updateNavigationRow(total: total)
|
|
}
|
|
|
|
func showNoResults() {
|
|
currentMatchIndex = nil
|
|
updateMatchCount(current: 0, total: 0)
|
|
tableView.isHidden = true
|
|
emptyStateLabel.isHidden = false
|
|
emptyStateLabel.text = keyword.isEmpty ? "输入关键词开始搜索" : "未找到相关内容"
|
|
}
|
|
|
|
func showSearching() {
|
|
updateMatchCount(current: 0, total: 0)
|
|
tableView.isHidden = true
|
|
emptyStateLabel.isHidden = false
|
|
emptyStateLabel.text = "搜索中..."
|
|
}
|
|
|
|
func restoreKeyword(_ keyword: String) {
|
|
textField.text = keyword
|
|
self.keyword = keyword
|
|
}
|
|
|
|
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() {
|
|
backgroundButton.translatesAutoresizingMaskIntoConstraints = false
|
|
panelView.translatesAutoresizingMaskIntoConstraints = false
|
|
navigationRowView.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(navigationRowView)
|
|
panelView.addSubview(tableView)
|
|
panelView.addSubview(emptyStateLabel)
|
|
|
|
searchRowView.addSubview(searchFieldContainer)
|
|
searchRowView.addSubview(searchFieldDivider)
|
|
searchRowView.addSubview(cancelButton)
|
|
searchFieldContainer.addSubview(searchIcon)
|
|
searchFieldContainer.addSubview(textField)
|
|
|
|
navigationRowView.addSubview(countLabel)
|
|
navigationRowView.addSubview(previousButton)
|
|
navigationRowView.addSubview(nextButton)
|
|
|
|
if #available(iOS 13.0, *) {
|
|
searchIcon.image = UIImage(systemName: "magnifyingglass")
|
|
let stepSymbol = UIImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
|
|
previousButton.setImage(UIImage(systemName: "chevron.up", withConfiguration: stepSymbol), for: .normal)
|
|
nextButton.setImage(UIImage(systemName: "chevron.down", withConfiguration: stepSymbol), for: .normal)
|
|
} else {
|
|
previousButton.setTitle("▲", for: .normal)
|
|
nextButton.setTitle("▼", for: .normal)
|
|
}
|
|
|
|
searchIcon.contentMode = .scaleAspectFit
|
|
searchIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
|
|
|
|
panelView.layer.cornerRadius = 0
|
|
panelView.clipsToBounds = true
|
|
|
|
grabberView.isHidden = 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"
|
|
countLabel.accessibilityIdentifier = "epub.reader.search.count"
|
|
textField.accessibilityIdentifier = "epub.reader.search.field"
|
|
|
|
previousButton.accessibilityLabel = "上一个匹配"
|
|
nextButton.accessibilityLabel = "下一个匹配"
|
|
|
|
countLabel.font = UIFont.systemFont(ofSize: 15, weight: .medium)
|
|
countLabel.textAlignment = .left
|
|
countLabel.isAccessibilityElement = true
|
|
|
|
navigationRowView.layer.cornerRadius = 18
|
|
navigationRowView.clipsToBounds = true
|
|
|
|
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() {
|
|
NSLayoutConstraint.activate([
|
|
backgroundButton.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
backgroundButton.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
backgroundButton.topAnchor.constraint(equalTo: topAnchor),
|
|
backgroundButton.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
|
|
panelView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
panelView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
panelView.topAnchor.constraint(equalTo: topAnchor),
|
|
panelView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
|
|
grabberView.topAnchor.constraint(equalTo: panelView.topAnchor, constant: 10),
|
|
grabberView.centerXAnchor.constraint(equalTo: panelView.centerXAnchor),
|
|
grabberView.widthAnchor.constraint(equalToConstant: 92),
|
|
grabberView.heightAnchor.constraint(equalToConstant: 6),
|
|
|
|
searchRowView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 20),
|
|
searchRowView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -20),
|
|
searchRowView.topAnchor.constraint(equalTo: panelView.safeAreaLayoutGuide.topAnchor, constant: 12),
|
|
searchRowView.heightAnchor.constraint(equalToConstant: 52),
|
|
|
|
searchFieldContainer.leadingAnchor.constraint(equalTo: searchRowView.leadingAnchor, constant: 12),
|
|
searchFieldContainer.topAnchor.constraint(equalTo: searchRowView.topAnchor),
|
|
searchFieldContainer.bottomAnchor.constraint(equalTo: searchRowView.bottomAnchor),
|
|
|
|
searchIcon.leadingAnchor.constraint(equalTo: searchFieldContainer.leadingAnchor, constant: 10),
|
|
searchIcon.centerYAnchor.constraint(equalTo: searchFieldContainer.centerYAnchor),
|
|
searchIcon.widthAnchor.constraint(equalToConstant: 24),
|
|
searchIcon.heightAnchor.constraint(equalToConstant: 24),
|
|
|
|
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),
|
|
|
|
navigationRowView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 20),
|
|
navigationRowView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -20),
|
|
navigationRowView.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 12),
|
|
|
|
countLabel.leadingAnchor.constraint(equalTo: navigationRowView.leadingAnchor, constant: 16),
|
|
countLabel.centerYAnchor.constraint(equalTo: navigationRowView.centerYAnchor),
|
|
|
|
nextButton.trailingAnchor.constraint(equalTo: navigationRowView.trailingAnchor, constant: -8),
|
|
nextButton.centerYAnchor.constraint(equalTo: navigationRowView.centerYAnchor),
|
|
nextButton.widthAnchor.constraint(equalToConstant: 44),
|
|
nextButton.topAnchor.constraint(equalTo: navigationRowView.topAnchor),
|
|
nextButton.bottomAnchor.constraint(equalTo: navigationRowView.bottomAnchor),
|
|
|
|
previousButton.trailingAnchor.constraint(equalTo: nextButton.leadingAnchor, constant: -4),
|
|
previousButton.centerYAnchor.constraint(equalTo: navigationRowView.centerYAnchor),
|
|
previousButton.widthAnchor.constraint(equalToConstant: 44),
|
|
previousButton.topAnchor.constraint(equalTo: navigationRowView.topAnchor),
|
|
previousButton.bottomAnchor.constraint(equalTo: navigationRowView.bottomAnchor),
|
|
|
|
countLabel.trailingAnchor.constraint(lessThanOrEqualTo: previousButton.leadingAnchor, constant: -8),
|
|
|
|
tableView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 0),
|
|
tableView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: 0),
|
|
tableView.topAnchor.constraint(equalTo: navigationRowView.bottomAnchor, constant: 12),
|
|
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: navigationRowView.bottomAnchor, constant: 50)
|
|
])
|
|
|
|
let heightConstraint = navigationRowView.heightAnchor.constraint(equalToConstant: 0)
|
|
heightConstraint.isActive = true
|
|
navigationRowHeightConstraint = heightConstraint
|
|
}
|
|
|
|
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)
|
|
cancelButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
|
|
backgroundButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
|
|
}
|
|
|
|
/// Stepping is only enabled once there are matches, but the row stays
|
|
/// visible (showing `0/0` with disabled arrows) for any keyword the user has
|
|
/// actually searched, so the result count is never silently missing. It
|
|
/// collapses away only in the pristine "type a keyword" state.
|
|
private func updateNavigationRow(total: Int) {
|
|
let hasMatches = total > 0
|
|
previousButton.isEnabled = hasMatches
|
|
nextButton.isEnabled = hasMatches
|
|
|
|
let isVisible = hasMatches || !keyword.isEmpty
|
|
navigationRowView.isHidden = !isVisible
|
|
navigationRowHeightConstraint?.constant = isVisible ? 36 : 0
|
|
}
|
|
|
|
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() {
|
|
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?()
|
|
}
|
|
|
|
@objc private func nextAction() {
|
|
onSearchNext?()
|
|
}
|
|
|
|
@objc private func closeAction() {
|
|
onClose?()
|
|
}
|
|
}
|
|
|
|
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(red: 0.08, green: 0.10, blue: 0.14, 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
|
|
}
|
|
}
|