ReadViewSDK/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift
shenlei 6ed3fcb071 feat: 设置面板与章节运行时联动优化
- 设置页打开时仅重新计算当前章节预览,关闭后触发完整补全
- 新增 preview 加载优先级,支持设置页内当前章预览
- 添加设置页防抖机制,合并连续字号/行距变更
- 支持下滑手势关闭设置页,通过 presentation delegate 通知运行时
- 重新分页时跳过导航和尺寸校验,避免状态冲突
- currentTextPageSize 线程安全优化,后台线程使用缓存尺寸
- 更新 CocoaPods 依赖

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-17 09:47:50 +08:00

369 lines
17 KiB
Swift
Raw 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
// 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)
}
}