refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs
- Rename source module from RDReaderView to RDEpubReaderView - Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/ - Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec - Update Podfile, demo project, and CocoaPods config for new pod name - Delete old RDReaderView pod support files from ReadViewDemo/Pods - Add new RDEpubReaderView pod support files - Update documentation (API ref, architecture, UML, conventions, etc.) - Add FixedLayoutRotationTests - Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
This commit is contained in:
@@ -0,0 +1,634 @@
|
||||
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 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 hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
// Only claim touches that fall within the panelView or the
|
||||
// accessibility-only navigation buttons. Taps on the dimmed scrim
|
||||
// area pass through to the reader content below, allowing chrome
|
||||
// toggle taps to work.
|
||||
let panelPoint = convert(point, to: panelView)
|
||||
if panelView.point(inside: panelPoint, with: event) {
|
||||
return super.hitTest(point, with: event)
|
||||
}
|
||||
// Also claim touches on the accessibility-only navigation buttons
|
||||
if previousButton.frame.contains(point)
|
||||
|| nextButton.frame.contains(point)
|
||||
|| countLabel.frame.contains(point) {
|
||||
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 = 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)
|
||||
|
||||
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"
|
||||
// 0.02 而非 0.01:CALayer.opacity 为 Float32,0.01 落盘后略小于
|
||||
// 0.01,会被 UIKit 命中测试按「透明视图」剔除,导致按钮永远点不中。
|
||||
previousButton.alpha = 0.02
|
||||
nextButton.alpha = 0.02
|
||||
countLabel.alpha = 0.02
|
||||
|
||||
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),
|
||||
|
||||
// 无障碍/UI 测试专用的隐形控件:必须放在 panel 顶部而非视图
|
||||
// 左上角——视图左上角落在系统状态栏区域内,合成点击会被状态栏
|
||||
// 窗口拦截,永远到不了这些按钮。
|
||||
previousButton.topAnchor.constraint(equalTo: panelView.topAnchor),
|
||||
previousButton.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
previousButton.widthAnchor.constraint(equalToConstant: 1),
|
||||
previousButton.heightAnchor.constraint(equalToConstant: 1),
|
||||
|
||||
nextButton.topAnchor.constraint(equalTo: panelView.topAnchor),
|
||||
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor),
|
||||
nextButton.widthAnchor.constraint(equalToConstant: 1),
|
||||
nextButton.heightAnchor.constraint(equalToConstant: 1),
|
||||
|
||||
countLabel.topAnchor.constraint(equalTo: panelView.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?()
|
||||
}
|
||||
}
|
||||
|
||||
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