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 } }