feat: add in-reader search and restructure documentation
- 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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
d20196ee34
commit
0e7c0577e3
@@ -0,0 +1,251 @@
|
||||
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?()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user