ReadViewSDK/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift

336 lines
16 KiB
Swift
Raw Permalink 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
/// Compact reader settings drawer. It deliberately keeps the book visible and
/// exposes only the controls currently supported by the reader configuration.
final class RDEPUBReaderSettingsViewController: UIViewController {
var onBrightnessChange: ((CGFloat) -> Void)?
var onFontSizeChange: ((CGFloat) -> Void)?
var onFontChoiceChange: ((RDEPUBReaderFontChoice) -> Void)?
var onLineHeightChange: ((CGFloat) -> Void)?
var onDisplayTypeChange: ((RDEpubReaderView.DisplayType) -> Void)?
var onThemeChange: ((RDEPUBReaderTheme) -> Void)?
var onDismiss: (() -> Void)?
private enum ThemePreset: Int, CaseIterable {
case light, yellow, green, pink, blue, 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 handleView = UIView()
private let closeButton = UIButton(type: .system)
private let brightnessSlider = UISlider()
private let fontSizeSlider = UISlider()
private let fontSizeLabel = UILabel()
private let fontChoiceButton = UIButton(type: .system)
private let lineHeightButton = UIButton(type: .system)
private let displayTypeButton = UIButton(type: .system)
private let themeStack = UIStackView()
private var themeButtons: [UIButton] = []
private var currentConfiguration: RDEPUBReaderConfiguration
private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9]
private var hasNotifiedDismissal = false
init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) {
currentConfiguration = configuration
super.init(nibName: nil, bundle: nil)
brightnessSlider.value = Float(brightness)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupViews()
syncControls()
applyTheme(currentConfiguration.theme)
}
private func setupViews() {
view.accessibilityIdentifier = "epub.reader.settings.panel"
view.layer.cornerRadius = 24
view.layer.cornerCurve = .continuous
view.clipsToBounds = true
[handleView, closeButton, brightnessSlider, fontSizeSlider, fontSizeLabel,
fontChoiceButton, lineHeightButton, displayTypeButton, themeStack].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
view.addSubview($0)
}
handleView.layer.cornerRadius = 3
closeButton.setImage(UIImage(systemName: "xmark"), for: .normal)
closeButton.accessibilityIdentifier = "epub.reader.settings.done"
closeButton.accessibilityLabel = "完成"
closeButton.addTarget(self, action: #selector(doneAction), for: .touchUpInside)
brightnessSlider.minimumValue = 0
brightnessSlider.maximumValue = 1
brightnessSlider.accessibilityIdentifier = "epub.reader.settings.brightness"
brightnessSlider.addTarget(self, action: #selector(brightnessChanged(_:)), for: .valueChanged)
fontSizeSlider.minimumValue = 12
fontSizeSlider.maximumValue = 36
fontSizeSlider.accessibilityIdentifier = "epub.reader.settings.font.size"
fontSizeSlider.addTarget(self, action: #selector(fontSizeChanged(_:)), for: .valueChanged)
fontSizeLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 14, weight: .semibold)
fontSizeLabel.textAlignment = .center
fontSizeLabel.accessibilityIdentifier = "epub.reader.settings.font.value"
configurePill(fontChoiceButton, title: "字体")
configurePill(lineHeightButton, title: "行距")
configurePill(displayTypeButton, title: "翻页方式")
fontChoiceButton.accessibilityIdentifier = "epub.reader.settings.font.choice"
lineHeightButton.accessibilityIdentifier = "epub.reader.settings.lineHeight"
displayTypeButton.accessibilityIdentifier = "epub.reader.settings.displayType"
fontChoiceButton.addTarget(self, action: #selector(fontChoiceAction), for: .touchUpInside)
lineHeightButton.addTarget(self, action: #selector(lineHeightAction), for: .touchUpInside)
displayTypeButton.addTarget(self, action: #selector(displayTypeAction), for: .touchUpInside)
themeStack.axis = .horizontal
themeStack.distribution = .fillEqually
themeStack.spacing = 14
ThemePreset.allCases.forEach { preset in
let button = UIButton(type: .system)
button.tag = preset.rawValue
button.backgroundColor = preset.theme.contentBackgroundColor
button.layer.cornerRadius = 15
button.layer.borderWidth = 1
button.accessibilityLabel = preset.title
button.accessibilityIdentifier = "epub.reader.settings.theme.\(preset.rawValue)"
button.addTarget(self, action: #selector(themeButtonAction(_:)), for: .touchUpInside)
themeButtons.append(button)
themeStack.addArrangedSubview(button)
button.heightAnchor.constraint(equalToConstant: 30).isActive = true
}
let brightnessLabel = makeLabel("亮度")
let fontSizeTitle = makeLabel("字体大小")
let smallA = makeLabel("A", font: 18)
let largeA = makeLabel("A", font: 25)
[brightnessLabel, fontSizeTitle, smallA, largeA].forEach(view.addSubview)
let selectorStack = UIStackView(arrangedSubviews: [fontChoiceButton, lineHeightButton, displayTypeButton])
selectorStack.axis = .horizontal
selectorStack.distribution = .fillEqually
selectorStack.spacing = 10
selectorStack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(selectorStack)
let dividerOne = makeDivider()
let dividerTwo = makeDivider()
[dividerOne, dividerTwo].forEach(view.addSubview)
NSLayoutConstraint.activate([
handleView.topAnchor.constraint(equalTo: view.topAnchor, constant: 10),
handleView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
handleView.widthAnchor.constraint(equalToConstant: 42),
handleView.heightAnchor.constraint(equalToConstant: 5),
closeButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
closeButton.centerYAnchor.constraint(equalTo: handleView.centerYAnchor),
closeButton.widthAnchor.constraint(equalToConstant: 32),
closeButton.heightAnchor.constraint(equalToConstant: 32),
brightnessLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
brightnessLabel.topAnchor.constraint(equalTo: handleView.bottomAnchor, constant: 14),
brightnessSlider.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
brightnessSlider.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
brightnessSlider.topAnchor.constraint(equalTo: brightnessLabel.bottomAnchor, constant: 5),
dividerOne.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
dividerOne.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
dividerOne.topAnchor.constraint(equalTo: brightnessSlider.bottomAnchor, constant: 10),
fontSizeTitle.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
fontSizeTitle.topAnchor.constraint(equalTo: dividerOne.bottomAnchor, constant: 10),
smallA.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
smallA.centerYAnchor.constraint(equalTo: fontSizeSlider.centerYAnchor),
fontSizeSlider.leadingAnchor.constraint(equalTo: smallA.trailingAnchor, constant: 10),
fontSizeSlider.trailingAnchor.constraint(equalTo: largeA.leadingAnchor, constant: -10),
fontSizeSlider.topAnchor.constraint(equalTo: fontSizeTitle.bottomAnchor, constant: 4),
largeA.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
largeA.centerYAnchor.constraint(equalTo: fontSizeSlider.centerYAnchor),
fontSizeLabel.centerXAnchor.constraint(equalTo: fontSizeSlider.centerXAnchor),
fontSizeLabel.bottomAnchor.constraint(equalTo: fontSizeSlider.topAnchor, constant: -1),
dividerTwo.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
dividerTwo.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
dividerTwo.topAnchor.constraint(equalTo: fontSizeSlider.bottomAnchor, constant: 10),
selectorStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
selectorStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
selectorStack.topAnchor.constraint(equalTo: dividerTwo.bottomAnchor, constant: 12),
selectorStack.heightAnchor.constraint(equalToConstant: 42),
themeStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
themeStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
themeStack.topAnchor.constraint(equalTo: selectorStack.bottomAnchor, constant: 16),
themeStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -14)
])
}
private func makeLabel(_ text: String, font: CGFloat = 13) -> UILabel {
let label = UILabel()
label.text = text
label.font = UIFont.systemFont(ofSize: font, weight: font > 16 ? .regular : .semibold)
label.translatesAutoresizingMaskIntoConstraints = false
return label
}
private func makeDivider() -> UIView {
let divider = UIView()
divider.translatesAutoresizingMaskIntoConstraints = false
divider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true
return divider
}
private func configurePill(_ button: UIButton, title: String) {
button.setTitle(title, for: .normal)
button.titleLabel?.font = UIFont.systemFont(ofSize: 15, weight: .semibold)
button.layer.cornerRadius = 13
}
private func syncControls() {
fontSizeSlider.value = Float(currentConfiguration.fontSize)
fontSizeLabel.text = String(Int(currentConfiguration.fontSize.rounded()))
updateSelectorTitles()
updateThemeSelection(ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light)
}
private func updateSelectorTitles() {
fontChoiceButton.setTitle("\(currentConfiguration.fontChoice.displayName) ", for: .normal)
let lineIndex = lineHeightValues.enumerated().min {
abs($0.element - currentConfiguration.lineHeightMultiple) < abs($1.element - currentConfiguration.lineHeightMultiple)
}?.offset ?? 1
fontChoiceButton.accessibilityValue = currentConfiguration.fontChoice.displayName
let lineTitles = ["紧凑", "标准", "宽松"]
lineHeightButton.setTitle("\(lineTitles[lineIndex]) ", for: .normal)
lineHeightButton.accessibilityValue = lineTitles[lineIndex]
let displayTitle: String
switch currentConfiguration.displayType {
case .pageCurl: displayTitle = "仿真"
case .horizontalScroll: displayTitle = "横滑"
case .verticalScroll: displayTitle = "竖滑"
}
displayTypeButton.setTitle("\(displayTitle) ", for: .normal)
displayTypeButton.accessibilityValue = displayTitle
}
private func applyTheme(_ theme: RDEPUBReaderTheme) {
view.backgroundColor = theme.toolBackgroundColor.withAlphaComponent(0.98)
let textColor = theme.toolControlTextColor
handleView.backgroundColor = textColor.withAlphaComponent(0.45)
closeButton.tintColor = textColor
view.subviews.compactMap { $0 as? UILabel }.forEach { $0.textColor = textColor }
view.subviews.filter { $0 !== handleView && $0 !== closeButton && !($0 is UISlider) && !($0 is UIStackView) && !($0 is UILabel) }
.forEach { $0.backgroundColor = textColor.withAlphaComponent(0.12) }
[fontChoiceButton, lineHeightButton, displayTypeButton].forEach {
$0.setTitleColor(textColor, for: .normal)
$0.backgroundColor = textColor.withAlphaComponent(0.10)
}
brightnessSlider.minimumTrackTintColor = .systemBlue
fontSizeSlider.minimumTrackTintColor = .systemBlue
brightnessSlider.maximumTrackTintColor = textColor.withAlphaComponent(0.22)
fontSizeSlider.maximumTrackTintColor = textColor.withAlphaComponent(0.22)
updateThemeSelection(ThemePreset.allCases.first(where: { $0.theme == theme }) ?? .light)
}
private func updateThemeSelection(_ preset: ThemePreset) {
themeButtons.forEach { button in
let selected = button.tag == preset.rawValue
button.layer.borderWidth = selected ? 2.5 : 1
button.layer.borderColor = selected ? UIColor.systemBlue.cgColor : UIColor.white.withAlphaComponent(0.28).cgColor
button.accessibilityValue = selected ? "selected" : "unselected"
}
}
func notifyDismissalIfNeeded() {
guard !hasNotifiedDismissal else { return }
hasNotifiedDismissal = true
onDismiss?()
}
@objc private func doneAction() {
notifyDismissalIfNeeded()
dismiss(animated: true)
}
@objc private func brightnessChanged(_ sender: UISlider) { onBrightnessChange?(CGFloat(sender.value)) }
@objc private func fontSizeChanged(_ sender: UISlider) {
let value = CGFloat(sender.value.rounded())
guard value != currentConfiguration.fontSize else { return }
currentConfiguration.fontSize = value
fontSizeLabel.text = String(Int(value))
onFontSizeChange?(value)
}
@objc private func fontChoiceAction() {
let alert = UIAlertController(title: "字体", message: nil, preferredStyle: .actionSheet)
RDEPUBReaderFontChoice.allCases.forEach { choice in
alert.addAction(UIAlertAction(title: choice.displayName, style: .default) { [weak self] _ in
guard let self else { return }
self.currentConfiguration.fontChoice = choice
self.updateSelectorTitles()
self.onFontChoiceChange?(choice)
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
@objc private func lineHeightAction() {
let alert = UIAlertController(title: "行距", message: nil, preferredStyle: .actionSheet)
zip(["紧凑", "标准", "宽松"], lineHeightValues).forEach { title, value in
alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
guard let self else { return }
self.currentConfiguration.lineHeightMultiple = value
self.updateSelectorTitles()
self.onLineHeightChange?(value)
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
@objc private func displayTypeAction() {
let alert = UIAlertController(title: "翻页方式", message: nil, preferredStyle: .actionSheet)
[("仿真", RDEpubReaderView.DisplayType.pageCurl), ("横滑", .horizontalScroll), ("竖滑", .verticalScroll)].forEach { title, type in
alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
guard let self else { return }
self.currentConfiguration.displayType = type
self.updateSelectorTitles()
self.onDisplayTypeChange?(type)
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
@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)
}
}