627 lines
25 KiB
Swift
627 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]
|
||
}
|
||
|
||
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 = 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
|
||
|
||
previousButton.tintColor = textColor
|
||
nextButton.tintColor = textColor
|
||
countLabel.textColor = textColor
|
||
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) {
|
||
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 = 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"
|
||
// 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: 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),
|
||
|
||
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(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
|
||
}
|
||
}
|