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) } }