336 lines
16 KiB
Swift
336 lines
16 KiB
Swift
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)
|
||
}
|
||
}
|