ReadViewSDK/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift

627 lines
25 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.01CALayer.opacity Float320.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
}
}