- 设置页打开时仅重新计算当前章节预览,关闭后触发完整补全 - 新增 preview 加载优先级,支持设置页内当前章预览 - 添加设置页防抖机制,合并连续字号/行距变更 - 支持下滑手势关闭设置页,通过 presentation delegate 通知运行时 - 重新分页时跳过导航和尺寸校验,避免状态冲突 - currentTextPageSize 线程安全优化,后台线程使用缓存尺寸 - 更新 CocoaPods 依赖 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
369 lines
17 KiB
Swift
369 lines
17 KiB
Swift
import UIKit
|
||
|
||
// MARK: - 设置面板
|
||
|
||
/// EPUB 阅读器的设置面板控制器
|
||
/// 提供亮度、字号、行距、翻页方式、主题的调节功能
|
||
/// 支持 iOS 13+ SF Symbols 和低版本文字回退
|
||
final class RDEPUBReaderSettingsViewController: UIViewController {
|
||
// MARK: 回调闭包
|
||
|
||
/// 亮度变化回调(0.0 ~ 1.0)
|
||
var onBrightnessChange: ((CGFloat) -> Void)?
|
||
/// 字号变化回调(pt 值)
|
||
var onFontSizeChange: ((CGFloat) -> Void)?
|
||
/// 字体变化回调
|
||
var onFontChoiceChange: ((RDEPUBReaderFontChoice) -> Void)?
|
||
/// 行距倍数变化回调
|
||
var onLineHeightChange: ((CGFloat) -> Void)?
|
||
/// 栏数变化回调
|
||
var onColumnCountChange: ((Int) -> Void)?
|
||
/// 翻页模式变化回调
|
||
var onDisplayTypeChange: ((RDReaderView.DisplayType) -> Void)?
|
||
/// 主题变化回调
|
||
var onThemeChange: ((RDEPUBReaderTheme) -> Void)?
|
||
/// 设置页面关闭回调
|
||
var onDismiss: (() -> Void)?
|
||
|
||
// MARK: 主题预设枚举
|
||
|
||
/// 设置面板中的主题预设枚举,用于按钮显示和主题切换
|
||
private enum ThemePreset: Int, CaseIterable {
|
||
case light
|
||
case yellow
|
||
case green
|
||
case pink
|
||
case blue
|
||
case dark
|
||
|
||
var title: String {
|
||
switch self {
|
||
case .light: return "浅色"
|
||
case .yellow: return "米黄"
|
||
case .green: return "青绿"
|
||
case .pink: return "粉色"
|
||
case .blue: return "蓝灰"
|
||
case .dark: return "夜间"
|
||
}
|
||
}
|
||
|
||
var theme: RDEPUBReaderTheme {
|
||
switch self {
|
||
case .light: return .light
|
||
case .yellow: return .yellow
|
||
case .green: return .green
|
||
case .pink: return .pink
|
||
case .blue: return .blue
|
||
case .dark: return .dark
|
||
}
|
||
}
|
||
}
|
||
|
||
private let scrollView = UIScrollView()
|
||
private let contentStack: UIStackView = {
|
||
let stackView = UIStackView()
|
||
stackView.axis = .vertical
|
||
stackView.spacing = 20
|
||
return stackView
|
||
}()
|
||
private let brightnessSlider = UISlider()
|
||
private let fontValueLabel = UILabel()
|
||
private let decreaseFontButton = UIButton(type: .system)
|
||
private let increaseFontButton = UIButton(type: .system)
|
||
private let fontChoiceControl = UISegmentedControl(items: RDEPUBReaderFontChoice.allCases.map(\.displayName))
|
||
private let lineHeightControl = UISegmentedControl(items: ["紧凑", "标准", "宽松"])
|
||
private let columnCountControl = UISegmentedControl(items: ["单栏", "双栏"])
|
||
private let displayTypeControl = UISegmentedControl(items: ["仿真", "横滑", "竖滑"])
|
||
private let themeStackView: UIStackView = {
|
||
let stackView = UIStackView()
|
||
stackView.axis = .horizontal
|
||
stackView.distribution = .fillEqually
|
||
stackView.spacing = 12
|
||
return stackView
|
||
}()
|
||
private var themeButtons: [UIButton] = []
|
||
|
||
/// 行距选项对应的倍数值(紧凑 1.3、标准 1.6、宽松 1.9)
|
||
private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9]
|
||
/// 当前配置的本地副本,修改后通过回调通知控制器
|
||
private var currentConfiguration: RDEPUBReaderConfiguration
|
||
|
||
init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) {
|
||
self.currentConfiguration = configuration
|
||
super.init(nibName: nil, bundle: nil)
|
||
brightnessSlider.value = Float(brightness)
|
||
title = "样式设置"
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
setupNavigationItems()
|
||
setupViews()
|
||
syncControls()
|
||
applyTheme(currentConfiguration.theme)
|
||
}
|
||
|
||
private func setupNavigationItems() {
|
||
let doneItem = UIBarButtonItem(title: "完成", style: .done, target: self, action: #selector(doneAction))
|
||
doneItem.accessibilityIdentifier = "epub.reader.settings.done"
|
||
navigationItem.rightBarButtonItem = doneItem
|
||
}
|
||
|
||
private func setupViews() {
|
||
view.addSubview(scrollView)
|
||
scrollView.accessibilityIdentifier = "epub.reader.settings.scroll"
|
||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||
scrollView.addSubview(contentStack)
|
||
contentStack.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
NSLayoutConstraint.activate([
|
||
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||
|
||
contentStack.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 16),
|
||
contentStack.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -16),
|
||
contentStack.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 20),
|
||
contentStack.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -24),
|
||
contentStack.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -32)
|
||
])
|
||
|
||
brightnessSlider.minimumValue = 0
|
||
brightnessSlider.maximumValue = 1
|
||
brightnessSlider.accessibilityIdentifier = "epub.reader.settings.brightness"
|
||
brightnessSlider.addTarget(self, action: #selector(brightnessChanged(_:)), for: .valueChanged)
|
||
|
||
fontValueLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 16, weight: .semibold)
|
||
fontValueLabel.textAlignment = .center
|
||
fontValueLabel.accessibilityIdentifier = "epub.reader.settings.font.value"
|
||
fontValueLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||
|
||
configureFontButton(decreaseFontButton, title: "A-")
|
||
configureFontButton(increaseFontButton, title: "A+")
|
||
decreaseFontButton.accessibilityIdentifier = "epub.reader.settings.font.decrease"
|
||
increaseFontButton.accessibilityIdentifier = "epub.reader.settings.font.increase"
|
||
decreaseFontButton.addTarget(self, action: #selector(decreaseFontAction), for: .touchUpInside)
|
||
increaseFontButton.addTarget(self, action: #selector(increaseFontAction), for: .touchUpInside)
|
||
|
||
lineHeightControl.accessibilityIdentifier = "epub.reader.settings.lineHeight"
|
||
fontChoiceControl.accessibilityIdentifier = "epub.reader.settings.font.choice"
|
||
columnCountControl.accessibilityIdentifier = "epub.reader.settings.columns"
|
||
displayTypeControl.accessibilityIdentifier = "epub.reader.settings.displayType"
|
||
fontChoiceControl.addTarget(self, action: #selector(fontChoiceChanged(_:)), for: .valueChanged)
|
||
lineHeightControl.addTarget(self, action: #selector(lineHeightChanged(_:)), for: .valueChanged)
|
||
columnCountControl.addTarget(self, action: #selector(columnCountChanged(_:)), for: .valueChanged)
|
||
displayTypeControl.addTarget(self, action: #selector(displayTypeChanged(_:)), for: .valueChanged)
|
||
|
||
ThemePreset.allCases.forEach { preset in
|
||
let button = UIButton(type: .system)
|
||
button.tag = preset.rawValue
|
||
button.layer.cornerRadius = 18
|
||
button.layer.borderWidth = 1.5
|
||
button.backgroundColor = preset.theme.contentBackgroundColor
|
||
button.accessibilityLabel = preset.title
|
||
button.accessibilityIdentifier = "epub.reader.settings.theme.\(preset.rawValue)"
|
||
button.addTarget(self, action: #selector(themeButtonAction(_:)), for: .touchUpInside)
|
||
themeButtons.append(button)
|
||
themeStackView.addArrangedSubview(button)
|
||
NSLayoutConstraint.activate([
|
||
button.heightAnchor.constraint(equalToConstant: 36)
|
||
])
|
||
}
|
||
|
||
contentStack.addArrangedSubview(makeSection(title: "亮度", content: brightnessSlider))
|
||
contentStack.addArrangedSubview(makeSection(title: "字号", content: makeFontSizeRow()))
|
||
contentStack.addArrangedSubview(makeSection(title: "字体", content: fontChoiceControl))
|
||
contentStack.addArrangedSubview(makeSection(title: "行距", content: lineHeightControl))
|
||
contentStack.addArrangedSubview(makeSection(title: "分栏", content: columnCountControl))
|
||
contentStack.addArrangedSubview(makeSection(title: "翻页方式", content: displayTypeControl))
|
||
contentStack.addArrangedSubview(makeSection(title: "主题", content: themeStackView))
|
||
}
|
||
|
||
private func makeSection(title: String, content: UIView) -> UIView {
|
||
let container = UIStackView()
|
||
container.axis = .vertical
|
||
container.spacing = 10
|
||
|
||
let titleLabel = UILabel()
|
||
titleLabel.text = title
|
||
titleLabel.font = UIFont.systemFont(ofSize: 14, weight: .semibold)
|
||
|
||
content.translatesAutoresizingMaskIntoConstraints = false
|
||
container.addArrangedSubview(titleLabel)
|
||
container.addArrangedSubview(content)
|
||
return container
|
||
}
|
||
|
||
private func makeFontSizeRow() -> UIView {
|
||
let stackView = UIStackView(arrangedSubviews: [decreaseFontButton, fontValueLabel, increaseFontButton])
|
||
stackView.axis = .horizontal
|
||
stackView.alignment = .center
|
||
stackView.distribution = .fill
|
||
stackView.spacing = 12
|
||
|
||
NSLayoutConstraint.activate([
|
||
decreaseFontButton.widthAnchor.constraint(equalToConstant: 64),
|
||
increaseFontButton.widthAnchor.constraint(equalToConstant: 64),
|
||
decreaseFontButton.heightAnchor.constraint(equalToConstant: 36),
|
||
increaseFontButton.heightAnchor.constraint(equalToConstant: 36)
|
||
])
|
||
return stackView
|
||
}
|
||
|
||
private func configureFontButton(_ button: UIButton, title: String) {
|
||
button.setTitle(title, for: .normal)
|
||
button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
|
||
button.layer.cornerRadius = 18
|
||
button.layer.borderWidth = 1
|
||
}
|
||
|
||
private func syncControls() {
|
||
fontValueLabel.text = String(Int(currentConfiguration.fontSize.rounded()))
|
||
fontChoiceControl.selectedSegmentIndex = RDEPUBReaderFontChoice.allCases.firstIndex(of: currentConfiguration.fontChoice) ?? 0
|
||
|
||
let lineHeightIndex = lineHeightValues.enumerated().min { abs($0.element - currentConfiguration.lineHeightMultiple) < abs($1.element - currentConfiguration.lineHeightMultiple) }?.offset ?? 1
|
||
lineHeightControl.selectedSegmentIndex = lineHeightIndex
|
||
columnCountControl.selectedSegmentIndex = currentConfiguration.numberOfColumns > 1 ? 1 : 0
|
||
|
||
switch currentConfiguration.displayType {
|
||
case .pageCurl:
|
||
displayTypeControl.selectedSegmentIndex = 0
|
||
case .horizontalScroll:
|
||
displayTypeControl.selectedSegmentIndex = 1
|
||
case .verticalScroll:
|
||
displayTypeControl.selectedSegmentIndex = 2
|
||
}
|
||
|
||
let selectedPreset = ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light
|
||
updateThemeSelection(selectedPreset)
|
||
updateControlAccessibilityValues()
|
||
}
|
||
|
||
private func applyTheme(_ theme: RDEPUBReaderTheme) {
|
||
view.backgroundColor = theme.contentBackgroundColor
|
||
scrollView.backgroundColor = theme.contentBackgroundColor
|
||
contentStack.arrangedSubviews
|
||
.compactMap { $0 as? UIStackView }
|
||
.flatMap { $0.arrangedSubviews }
|
||
.compactMap { $0 as? UILabel }
|
||
.forEach { $0.textColor = theme.contentTextColor }
|
||
|
||
fontValueLabel.textColor = theme.contentTextColor
|
||
[decreaseFontButton, increaseFontButton].forEach { button in
|
||
button.setTitleColor(theme.toolControlTextColor, for: .normal)
|
||
button.layer.borderColor = theme.toolControlBorderUnselectColor.cgColor
|
||
button.backgroundColor = theme.toolBackgroundColor
|
||
}
|
||
|
||
[fontChoiceControl, lineHeightControl, columnCountControl, displayTypeControl].forEach { control in
|
||
control.backgroundColor = theme.toolBackgroundColor
|
||
if #available(iOS 13.0, *) {
|
||
control.selectedSegmentTintColor = theme.toolControlTextColor.withAlphaComponent(0.14)
|
||
} else {
|
||
control.tintColor = theme.toolControlTextColor
|
||
}
|
||
control.setTitleTextAttributes([.foregroundColor: theme.contentTextColor], for: .normal)
|
||
control.setTitleTextAttributes([.foregroundColor: theme.toolControlTextColor], for: .selected)
|
||
}
|
||
|
||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
||
updateThemeSelection(ThemePreset.allCases.first(where: { $0.theme == theme }) ?? .light)
|
||
}
|
||
|
||
private func updateThemeSelection(_ preset: ThemePreset) {
|
||
themeButtons.forEach { button in
|
||
let isSelected = button.tag == preset.rawValue
|
||
button.layer.borderWidth = isSelected ? 2 : 1
|
||
button.layer.borderColor = isSelected ? currentConfiguration.theme.toolControlTextColor.cgColor : currentConfiguration.theme.toolControlBorderUnselectColor.cgColor
|
||
button.accessibilityValue = isSelected ? "selected" : "unselected"
|
||
}
|
||
}
|
||
|
||
private func updateControlAccessibilityValues() {
|
||
fontChoiceControl.accessibilityValue = fontChoiceControl.titleForSegment(at: fontChoiceControl.selectedSegmentIndex)
|
||
lineHeightControl.accessibilityValue = lineHeightControl.titleForSegment(at: lineHeightControl.selectedSegmentIndex)
|
||
columnCountControl.accessibilityValue = columnCountControl.titleForSegment(at: columnCountControl.selectedSegmentIndex)
|
||
displayTypeControl.accessibilityValue = displayTypeControl.titleForSegment(at: displayTypeControl.selectedSegmentIndex)
|
||
}
|
||
|
||
@objc private func doneAction() {
|
||
dismiss(animated: true) { [weak self] in
|
||
self?.onDismiss?()
|
||
}
|
||
}
|
||
|
||
@objc private func brightnessChanged(_ slider: UISlider) {
|
||
onBrightnessChange?(CGFloat(slider.value))
|
||
}
|
||
|
||
@objc private func decreaseFontAction() {
|
||
let nextValue = max(12, currentConfiguration.fontSize - 1)
|
||
guard nextValue != currentConfiguration.fontSize else { return }
|
||
currentConfiguration.fontSize = nextValue
|
||
fontValueLabel.text = String(Int(nextValue.rounded()))
|
||
onFontSizeChange?(nextValue)
|
||
}
|
||
|
||
@objc private func increaseFontAction() {
|
||
let nextValue = min(36, currentConfiguration.fontSize + 1)
|
||
guard nextValue != currentConfiguration.fontSize else { return }
|
||
currentConfiguration.fontSize = nextValue
|
||
fontValueLabel.text = String(Int(nextValue.rounded()))
|
||
onFontSizeChange?(nextValue)
|
||
}
|
||
|
||
@objc private func fontChoiceChanged(_ control: UISegmentedControl) {
|
||
let choices = RDEPUBReaderFontChoice.allCases
|
||
let index = max(0, min(control.selectedSegmentIndex, choices.count - 1))
|
||
let choice = choices[index]
|
||
guard choice != currentConfiguration.fontChoice else { return }
|
||
currentConfiguration.fontChoice = choice
|
||
updateControlAccessibilityValues()
|
||
onFontChoiceChange?(choice)
|
||
}
|
||
|
||
@objc private func lineHeightChanged(_ control: UISegmentedControl) {
|
||
let index = max(0, min(control.selectedSegmentIndex, lineHeightValues.count - 1))
|
||
let value = lineHeightValues[index]
|
||
currentConfiguration.lineHeightMultiple = value
|
||
updateControlAccessibilityValues()
|
||
onLineHeightChange?(value)
|
||
}
|
||
|
||
@objc private func columnCountChanged(_ control: UISegmentedControl) {
|
||
let numberOfColumns = control.selectedSegmentIndex == 1 ? 2 : 1
|
||
currentConfiguration.numberOfColumns = numberOfColumns
|
||
updateControlAccessibilityValues()
|
||
onColumnCountChange?(numberOfColumns)
|
||
}
|
||
|
||
@objc private func displayTypeChanged(_ control: UISegmentedControl) {
|
||
let displayType: RDReaderView.DisplayType
|
||
switch control.selectedSegmentIndex {
|
||
case 1:
|
||
displayType = .horizontalScroll
|
||
case 2:
|
||
displayType = .verticalScroll
|
||
default:
|
||
displayType = .pageCurl
|
||
}
|
||
currentConfiguration.displayType = displayType
|
||
updateControlAccessibilityValues()
|
||
onDisplayTypeChange?(displayType)
|
||
}
|
||
|
||
@objc private func themeButtonAction(_ sender: UIButton) {
|
||
guard let preset = ThemePreset(rawValue: sender.tag) else { return }
|
||
currentConfiguration.theme = preset.theme
|
||
applyTheme(preset.theme)
|
||
onThemeChange?(preset.theme)
|
||
}
|
||
}
|