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