- Add RDEPUBReaderSearchBarView with animated show/hide, keyword navigation, and match counting integrated into the reader controller - Restructure docs: replace scattered design docs with consolidated BUSINESS_LOGIC.md and UML_CLASS_DIAGRAMS.md; update ARCHITECTURE.md - Add SearchTests and FanrenParseTimeTest; enhance LargeBookOnDemandTests - Add scripts/run_ui_regression.sh and summarize_ui_results.py for automated UI test execution and reporting Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
252 lines
9.6 KiB
Swift
252 lines
9.6 KiB
Swift
import UIKit
|
||
|
||
// MARK: - 搜索栏
|
||
|
||
/// 阅读器搜索栏视图
|
||
/// 提供搜索输入、上一个/下一个匹配导航、匹配计数和关闭功能
|
||
final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||
// MARK: 回调闭包
|
||
|
||
/// 提交搜索关键词回调
|
||
var onSearchSubmit: ((String) -> Void)?
|
||
/// 点击上一个匹配回调
|
||
var onSearchPrevious: (() -> Void)?
|
||
/// 点击下一个匹配回调
|
||
var onSearchNext: (() -> Void)?
|
||
/// 关闭搜索回调
|
||
var onClose: (() -> Void)?
|
||
|
||
// MARK: UI 组件
|
||
|
||
private let containerView: UIView = {
|
||
let view = UIView()
|
||
view.layer.cornerRadius = 8
|
||
view.layer.masksToBounds = true
|
||
view.isAccessibilityElement = false
|
||
view.accessibilityElementsHidden = false
|
||
return view
|
||
}()
|
||
|
||
private let searchIcon: UIImageView = {
|
||
let imageView = UIImageView()
|
||
imageView.contentMode = .scaleAspectFit
|
||
imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 14, weight: .medium)
|
||
if #available(iOS 13.0, *) {
|
||
imageView.image = UIImage(systemName: "magnifyingglass")
|
||
}
|
||
imageView.tintColor = .gray
|
||
return imageView
|
||
}()
|
||
|
||
let textField: UITextField = {
|
||
let field = UITextField()
|
||
field.placeholder = "搜索..."
|
||
field.font = UIFont.systemFont(ofSize: 15)
|
||
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 previousButton = RDEPUBReaderTintButton(type: .system)
|
||
private let nextButton = RDEPUBReaderTintButton(type: .system)
|
||
|
||
private let countLabel: UILabel = {
|
||
let label = UILabel()
|
||
label.font = UIFont.systemFont(ofSize: 13, weight: .medium)
|
||
label.textAlignment = .center
|
||
label.setContentHuggingPriority(.required, for: .horizontal)
|
||
label.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||
return label
|
||
}()
|
||
|
||
private let closeButton = RDEPUBReaderTintButton(type: .system)
|
||
|
||
// MARK: 布局常量
|
||
|
||
private let horizontalInset: CGFloat = 12
|
||
private let spacing: CGFloat = 6
|
||
private let containerHeight: CGFloat = 36
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
accessibilityIdentifier = "epub.reader.search.bar"
|
||
shouldGroupAccessibilityChildren = false
|
||
isAccessibilityElement = false
|
||
setupSubviews()
|
||
setupConstraints()
|
||
setupActions()
|
||
updateNavigationEnabled(false)
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
// MARK: 布局
|
||
|
||
override func lineFrame(in bounds: CGRect) -> CGRect {
|
||
CGRect(x: 0, y: bounds.height - 0.5, width: bounds.width, height: 0.5)
|
||
}
|
||
|
||
override func apply(theme: RDEPUBReaderTheme) {
|
||
super.apply(theme: theme)
|
||
backgroundColor = theme.toolBackgroundColor
|
||
containerView.backgroundColor = theme.toolControlBorderUnselectColor
|
||
searchIcon.tintColor = theme.toolControlTextColor
|
||
textField.textColor = theme.toolControlTextColor
|
||
textField.attributedPlaceholder = NSAttributedString(
|
||
string: "搜索...",
|
||
attributes: [.foregroundColor: theme.toolControlTextColor.withAlphaComponent(0.5)]
|
||
)
|
||
countLabel.textColor = theme.toolControlTextColor
|
||
previousButton.tintColor = theme.toolControlTextColor
|
||
nextButton.tintColor = theme.toolControlTextColor
|
||
closeButton.tintColor = theme.toolControlTextColor
|
||
}
|
||
|
||
// MARK: 公开方法
|
||
|
||
/// 更新匹配计数显示
|
||
func updateMatchCount(current: Int, total: Int) {
|
||
countLabel.text = "\(current)/\(total)"
|
||
updateNavigationEnabled(total > 0)
|
||
}
|
||
|
||
/// 显示无结果状态
|
||
func showNoResults() {
|
||
countLabel.text = "0/0"
|
||
updateNavigationEnabled(false)
|
||
}
|
||
|
||
/// 显示搜索中状态
|
||
func showSearching() {
|
||
countLabel.text = "搜索中..."
|
||
updateNavigationEnabled(false)
|
||
}
|
||
|
||
/// 恢复已有的搜索关键词(搜索栏重新显示时)
|
||
func restoreKeyword(_ keyword: String) {
|
||
textField.text = keyword
|
||
}
|
||
|
||
// MARK: 私有方法
|
||
|
||
private func setupSubviews() {
|
||
addSubview(containerView)
|
||
containerView.addSubview(searchIcon)
|
||
containerView.addSubview(textField)
|
||
addSubview(previousButton)
|
||
addSubview(nextButton)
|
||
addSubview(countLabel)
|
||
addSubview(closeButton)
|
||
|
||
if #available(iOS 13.0, *) {
|
||
previousButton.setImage(UIImage(systemName: "chevron.up")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||
nextButton.setImage(UIImage(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||
closeButton.setImage(UIImage(systemName: "xmark")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||
} else {
|
||
previousButton.setTitle("▲", for: .normal)
|
||
nextButton.setTitle("▼", for: .normal)
|
||
closeButton.setTitle("✕", for: .normal)
|
||
}
|
||
|
||
previousButton.accessibilityIdentifier = "epub.reader.search.previous"
|
||
nextButton.accessibilityIdentifier = "epub.reader.search.next"
|
||
closeButton.accessibilityIdentifier = "epub.reader.search.close"
|
||
countLabel.accessibilityIdentifier = "epub.reader.search.count"
|
||
textField.accessibilityIdentifier = "epub.reader.search.field"
|
||
|
||
[previousButton, nextButton, closeButton].forEach { button in
|
||
button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium)
|
||
button.tintColor = .black
|
||
button.setTitleColor(.black, for: .normal)
|
||
}
|
||
}
|
||
|
||
private func setupConstraints() {
|
||
[containerView, searchIcon, textField, previousButton, nextButton, countLabel, closeButton].forEach {
|
||
$0.translatesAutoresizingMaskIntoConstraints = false
|
||
}
|
||
|
||
NSLayoutConstraint.activate([
|
||
// 容器(搜索输入区域)
|
||
containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
|
||
containerView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||
containerView.heightAnchor.constraint(equalToConstant: containerHeight),
|
||
|
||
// 搜索图标
|
||
searchIcon.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10),
|
||
searchIcon.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
||
searchIcon.widthAnchor.constraint(equalToConstant: 16),
|
||
|
||
// 输入框
|
||
textField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 6),
|
||
textField.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8),
|
||
textField.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
||
textField.heightAnchor.constraint(equalToConstant: containerHeight - 4),
|
||
|
||
// 上一个按钮
|
||
previousButton.leadingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: spacing),
|
||
previousButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||
previousButton.widthAnchor.constraint(equalToConstant: 32),
|
||
previousButton.heightAnchor.constraint(equalToConstant: 32),
|
||
|
||
// 下一个按钮
|
||
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor, constant: spacing),
|
||
nextButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||
nextButton.widthAnchor.constraint(equalToConstant: 32),
|
||
nextButton.heightAnchor.constraint(equalToConstant: 32),
|
||
|
||
// 计数标签
|
||
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor, constant: spacing),
|
||
countLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||
countLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 44),
|
||
|
||
// 关闭按钮
|
||
closeButton.leadingAnchor.constraint(equalTo: countLabel.trailingAnchor, constant: spacing),
|
||
closeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
|
||
closeButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||
closeButton.widthAnchor.constraint(equalToConstant: 32),
|
||
closeButton.heightAnchor.constraint(equalToConstant: 32)
|
||
])
|
||
}
|
||
|
||
private func setupActions() {
|
||
textField.addTarget(self, action: #selector(textFieldDidReturn), for: .editingDidEndOnExit)
|
||
previousButton.addTarget(self, action: #selector(previousAction), for: .touchUpInside)
|
||
nextButton.addTarget(self, action: #selector(nextAction), for: .touchUpInside)
|
||
closeButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
|
||
}
|
||
|
||
private func updateNavigationEnabled(_ enabled: Bool) {
|
||
previousButton.isEnabled = enabled
|
||
previousButton.alpha = enabled ? 1 : 0.45
|
||
nextButton.isEnabled = enabled
|
||
nextButton.alpha = enabled ? 1 : 0.45
|
||
}
|
||
|
||
@objc private func textFieldDidReturn() {
|
||
guard let keyword = textField.text, !keyword.isEmpty else { return }
|
||
onSearchSubmit?(keyword)
|
||
textField.resignFirstResponder()
|
||
}
|
||
|
||
@objc private func previousAction() {
|
||
onSearchPrevious?()
|
||
}
|
||
|
||
@objc private func nextAction() {
|
||
onSearchNext?()
|
||
}
|
||
|
||
@objc private func closeAction() {
|
||
onClose?()
|
||
}
|
||
}
|