- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态 - 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试 - 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证) - 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互 - 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache) - 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy - 更新 epub-bridge.js 与 JS bridge 通信协议 - 全面更新现有 UI 测试以适配新的 helper 和状态管理
600 lines
25 KiB
Swift
600 lines
25 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]
|
|
}
|
|
|
|
// MARK: - 搜索面板
|
|
|
|
/// 阅读器搜索面板
|
|
/// 提供底部抽屉式搜索输入、分组结果列表和匹配跳转能力。
|
|
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)
|
|
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 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()
|
|
updateLegacyNavigationEnabled(false)
|
|
showInitialState()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func lineFrame(in bounds: CGRect) -> CGRect {
|
|
.zero
|
|
}
|
|
|
|
override func apply(theme: RDEPUBReaderTheme) {
|
|
super.apply(theme: theme)
|
|
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: secondaryTextColor]
|
|
)
|
|
|
|
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()
|
|
}
|
|
|
|
var presentedView: UIView {
|
|
panelView
|
|
}
|
|
|
|
func updateMatchCount(current: Int, total: Int) {
|
|
countLabel.text = "\(current)/\(total)"
|
|
textField.accessibilityValue = "\(current)/\(total)"
|
|
updateLegacyNavigationEnabled(total > 0)
|
|
}
|
|
|
|
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
|
|
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)
|
|
|
|
if #available(iOS 13.0, *) {
|
|
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)
|
|
}
|
|
|
|
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"
|
|
countLabel.accessibilityIdentifier = "epub.reader.search.count"
|
|
textField.accessibilityIdentifier = "epub.reader.search.field"
|
|
previousButton.alpha = 0.01
|
|
nextButton.alpha = 0.01
|
|
countLabel.alpha = 0.01
|
|
|
|
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: safeAreaLayoutGuide.topAnchor, constant: 8),
|
|
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: grabberView.bottomAnchor, constant: 18),
|
|
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),
|
|
|
|
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)
|
|
cancelButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
|
|
backgroundButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
|
|
}
|
|
|
|
private func updateLegacyNavigationEnabled(_ enabled: Bool) {
|
|
previousButton.isEnabled = enabled
|
|
nextButton.isEnabled = enabled
|
|
}
|
|
|
|
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?()
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|