refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs
- Rename source module from RDReaderView to RDEpubReaderView - Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/ - Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec - Update Podfile, demo project, and CocoaPods config for new pod name - Delete old RDReaderView pod support files from ReadViewDemo/Pods - Add new RDEpubReaderView pod support files - Update documentation (API ref, architecture, UML, conventions, etc.) - Add FixedLayoutRotationTests - Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
import UIKit
|
||||
|
||||
public enum RDEPUBTextRenderingEngine: Equatable {
|
||||
|
||||
case dtCoreText
|
||||
}
|
||||
|
||||
public enum RDEPUBReaderFontChoice: String, Codable, CaseIterable, Equatable {
|
||||
|
||||
case system
|
||||
|
||||
case serif
|
||||
|
||||
case rounded
|
||||
|
||||
case monospaced
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .system:
|
||||
return "系统"
|
||||
case .serif:
|
||||
return "宋体"
|
||||
case .rounded:
|
||||
return "圆体"
|
||||
case .monospaced:
|
||||
return "等宽"
|
||||
}
|
||||
}
|
||||
|
||||
public func font(ofSize size: CGFloat) -> UIFont {
|
||||
switch self {
|
||||
case .system:
|
||||
return UIFont.systemFont(ofSize: size)
|
||||
case .serif:
|
||||
let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
|
||||
.withDesign(.serif) ?? UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
|
||||
return UIFont(descriptor: descriptor, size: size)
|
||||
case .rounded:
|
||||
let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
|
||||
.withDesign(.rounded) ?? UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
|
||||
return UIFont(descriptor: descriptor, size: size)
|
||||
case .monospaced:
|
||||
return UIFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 试读策略:开启后,阅读器在最后一个可读页之后追加一页“试读墙”(UI 由宿主经 delegate 提供)。
|
||||
public struct RDEPUBReaderTrialPolicy: Equatable {
|
||||
|
||||
/// 可读章节数(可读 spine 为 0..<readableChapterCount)。
|
||||
/// nil = 全书可读,试读墙追加在全书末尾(对应 readoor 的独立试读 epub:整本都是试读章节)。
|
||||
/// 指定数值时超出范围的章节页不再展示(对应“整本 epub + 按章限制”形态,仅 bookPageMap 路径生效)。
|
||||
public var readableChapterCount: Int?
|
||||
|
||||
public init(readableChapterCount: Int? = nil) {
|
||||
self.readableChapterCount = readableChapterCount
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBReaderConfiguration: Equatable {
|
||||
|
||||
public var fontSize: CGFloat
|
||||
|
||||
public var lineHeightMultiple: CGFloat
|
||||
|
||||
public var fontChoice: RDEPUBReaderFontChoice
|
||||
|
||||
public var numberOfColumns: Int
|
||||
|
||||
public var columnGap: CGFloat
|
||||
|
||||
public var displayType: RDEpubReaderView.DisplayType
|
||||
|
||||
public var landscapeDualPageEnabled: Bool
|
||||
|
||||
public var showsTableOfContents: Bool
|
||||
|
||||
public var allowsHighlights: Bool
|
||||
|
||||
public var showsSettingsPanel: Bool
|
||||
|
||||
public var reflowableContentInsets: UIEdgeInsets
|
||||
|
||||
public var fixedContentInset: UIEdgeInsets
|
||||
|
||||
public var theme: RDEPUBReaderTheme
|
||||
|
||||
public var darkImageAdjustmentEnabled: Bool
|
||||
|
||||
public var darkImageBlendRatio: CGFloat
|
||||
|
||||
public var fixedLayoutFit: RDEPUBFixedLayoutFit
|
||||
|
||||
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
|
||||
|
||||
public var textRenderingEngine: RDEPUBTextRenderingEngine
|
||||
|
||||
public var onDemandChapterWindowSize: Int
|
||||
|
||||
public var metadataParsingConcurrency: Int
|
||||
|
||||
public var jumpSessionPolicy: RDEPUBJumpSessionPolicy
|
||||
|
||||
public var allowedExternalURLSchemes: Set<String>
|
||||
|
||||
public var requiresExternalLinkConfirmation: Bool
|
||||
|
||||
public var allowsInspectableWebViews: Bool
|
||||
|
||||
public var enablesVerboseWebViewLogging: Bool
|
||||
|
||||
/// 试读策略;nil = 非试读(全书可读、无试读墙)。
|
||||
public var trialPolicy: RDEPUBReaderTrialPolicy?
|
||||
|
||||
public init(
|
||||
fontSize: CGFloat = 15,
|
||||
lineHeightMultiple: CGFloat = 1.6,
|
||||
fontChoice: RDEPUBReaderFontChoice = .system,
|
||||
numberOfColumns: Int = 1,
|
||||
columnGap: CGFloat = 20,
|
||||
displayType: RDEpubReaderView.DisplayType = .pageCurl,
|
||||
landscapeDualPageEnabled: Bool = true,
|
||||
showsTableOfContents: Bool = true,
|
||||
allowsHighlights: Bool = true,
|
||||
showsSettingsPanel: Bool = true,
|
||||
reflowableContentInsets: UIEdgeInsets = RDEPUBSafeArea.defaultReflowableContentInsets(),
|
||||
fixedContentInset: UIEdgeInsets = .zero,
|
||||
theme: RDEPUBReaderTheme = .light,
|
||||
darkImageAdjustmentEnabled: Bool = true,
|
||||
darkImageBlendRatio: CGFloat = 0.15,
|
||||
fixedLayoutFit: RDEPUBFixedLayoutFit = .page,
|
||||
fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic,
|
||||
textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText,
|
||||
onDemandChapterWindowSize: Int = 3,
|
||||
metadataParsingConcurrency: Int = ProcessInfo.processInfo.activeProcessorCount,
|
||||
jumpSessionPolicy: RDEPUBJumpSessionPolicy = .default,
|
||||
allowedExternalURLSchemes: Set<String> = ["https"],
|
||||
requiresExternalLinkConfirmation: Bool = true,
|
||||
allowsInspectableWebViews: Bool = false,
|
||||
enablesVerboseWebViewLogging: Bool = false,
|
||||
trialPolicy: RDEPUBReaderTrialPolicy? = nil
|
||||
) {
|
||||
self.fontSize = fontSize
|
||||
self.lineHeightMultiple = lineHeightMultiple
|
||||
self.fontChoice = fontChoice
|
||||
self.numberOfColumns = max(1, numberOfColumns)
|
||||
self.columnGap = max(0, columnGap)
|
||||
self.displayType = displayType
|
||||
self.landscapeDualPageEnabled = landscapeDualPageEnabled
|
||||
self.showsTableOfContents = showsTableOfContents
|
||||
self.allowsHighlights = allowsHighlights
|
||||
self.showsSettingsPanel = showsSettingsPanel
|
||||
self.reflowableContentInsets = reflowableContentInsets
|
||||
self.fixedContentInset = fixedContentInset
|
||||
self.theme = theme
|
||||
self.darkImageAdjustmentEnabled = darkImageAdjustmentEnabled
|
||||
self.darkImageBlendRatio = max(0, min(0.35, darkImageBlendRatio))
|
||||
self.fixedLayoutFit = fixedLayoutFit
|
||||
self.fixedLayoutSpreadMode = fixedLayoutSpreadMode
|
||||
self.textRenderingEngine = textRenderingEngine
|
||||
self.onDemandChapterWindowSize = Self.normalizedChapterWindowSize(onDemandChapterWindowSize)
|
||||
self.metadataParsingConcurrency = max(1, metadataParsingConcurrency)
|
||||
self.jumpSessionPolicy = jumpSessionPolicy
|
||||
self.allowedExternalURLSchemes = allowedExternalURLSchemes
|
||||
self.requiresExternalLinkConfirmation = requiresExternalLinkConfirmation
|
||||
self.allowsInspectableWebViews = allowsInspectableWebViews
|
||||
self.enablesVerboseWebViewLogging = enablesVerboseWebViewLogging
|
||||
self.trialPolicy = trialPolicy
|
||||
}
|
||||
|
||||
public static let `default` = RDEPUBReaderConfiguration()
|
||||
}
|
||||
|
||||
extension RDEPUBReaderConfiguration {
|
||||
|
||||
static func normalizedChapterWindowSize(_ size: Int) -> Int {
|
||||
let clamped = max(3, min(15, size))
|
||||
return clamped % 2 == 0 ? clamped + 1 : clamped
|
||||
}
|
||||
|
||||
var chapterWindowRadius: Int {
|
||||
onDemandChapterWindowSize / 2
|
||||
}
|
||||
}
|
||||
|
||||
extension RDEPUBReaderConfiguration {
|
||||
|
||||
func makePreferences(safeAreaInsets: UIEdgeInsets = .zero) -> RDEPUBPreferences {
|
||||
// Use the larger of reflowableContentInsets and safeAreaInsets for each edge
|
||||
// to prevent content from being hidden under Dynamic Island / home indicator.
|
||||
// Falls back to the key-window safe area when the caller has no laid-out view.
|
||||
let resolvedSafeAreaInsets = RDEPUBSafeArea.resolve(safeAreaInsets)
|
||||
let safeInsets = UIEdgeInsets(
|
||||
top: max(reflowableContentInsets.top, resolvedSafeAreaInsets.top),
|
||||
left: max(reflowableContentInsets.left, resolvedSafeAreaInsets.left),
|
||||
bottom: max(reflowableContentInsets.bottom, resolvedSafeAreaInsets.bottom),
|
||||
right: max(reflowableContentInsets.right, resolvedSafeAreaInsets.right)
|
||||
)
|
||||
return RDEPUBPreferences(
|
||||
fontSize: fontSize,
|
||||
lineHeightMultiple: lineHeightMultiple,
|
||||
reflowableContentInsets: safeInsets,
|
||||
fixedContentInset: fixedContentInset,
|
||||
numberOfColumns: numberOfColumns,
|
||||
columnGap: columnGap,
|
||||
themeBackgroundColor: theme.themeBackgroundColorCSS,
|
||||
themeTextColor: theme.themeTextColorCSS,
|
||||
fixedBackgroundColor: theme.themeBackgroundColorCSS,
|
||||
fixedLayoutFit: fixedLayoutFit,
|
||||
fixedLayoutSpreadMode: fixedLayoutSpreadMode
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import UIKit
|
||||
|
||||
public enum RDEPUBReaderDisplayMode: String, Codable, Equatable {
|
||||
|
||||
case pageCurl
|
||||
|
||||
case horizontalScroll
|
||||
|
||||
case verticalScroll
|
||||
|
||||
case horizontalCoverScroll
|
||||
|
||||
init(displayType: RDEpubReaderView.DisplayType) {
|
||||
switch displayType {
|
||||
case .pageCurl:
|
||||
self = .pageCurl
|
||||
case .horizontalScroll:
|
||||
self = .horizontalScroll
|
||||
case .verticalScroll:
|
||||
self = .verticalScroll
|
||||
}
|
||||
}
|
||||
|
||||
var displayType: RDEpubReaderView.DisplayType {
|
||||
switch self {
|
||||
case .pageCurl:
|
||||
return .pageCurl
|
||||
case .horizontalScroll, .horizontalCoverScroll:
|
||||
return .horizontalScroll
|
||||
case .verticalScroll:
|
||||
return .verticalScroll
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable {
|
||||
|
||||
case light
|
||||
|
||||
case yellow
|
||||
|
||||
case green
|
||||
|
||||
case pink
|
||||
|
||||
case blue
|
||||
|
||||
case dark
|
||||
|
||||
public 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
|
||||
}
|
||||
}
|
||||
|
||||
public init?(theme: RDEPUBReaderTheme) {
|
||||
switch theme {
|
||||
case .light:
|
||||
self = .light
|
||||
case .yellow:
|
||||
self = .yellow
|
||||
case .green:
|
||||
self = .green
|
||||
case .pink:
|
||||
self = .pink
|
||||
case .blue:
|
||||
self = .blue
|
||||
case .dark:
|
||||
self = .dark
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBReaderSettings: Codable, Equatable {
|
||||
|
||||
public var brightness: CGFloat?
|
||||
|
||||
public var fontSize: CGFloat?
|
||||
|
||||
public var fontChoice: RDEPUBReaderFontChoice?
|
||||
|
||||
public var lineHeightMultiple: CGFloat?
|
||||
|
||||
public var numberOfColumns: Int?
|
||||
|
||||
public var displayMode: RDEPUBReaderDisplayMode?
|
||||
|
||||
public var themePreset: RDEPUBReaderThemePreset?
|
||||
|
||||
public init(
|
||||
brightness: CGFloat? = nil,
|
||||
fontSize: CGFloat? = nil,
|
||||
fontChoice: RDEPUBReaderFontChoice? = nil,
|
||||
lineHeightMultiple: CGFloat? = nil,
|
||||
numberOfColumns: Int? = nil,
|
||||
displayMode: RDEPUBReaderDisplayMode? = nil,
|
||||
themePreset: RDEPUBReaderThemePreset? = nil
|
||||
) {
|
||||
self.brightness = brightness
|
||||
self.fontSize = fontSize
|
||||
self.fontChoice = fontChoice
|
||||
self.lineHeightMultiple = lineHeightMultiple
|
||||
self.numberOfColumns = numberOfColumns
|
||||
self.displayMode = displayMode
|
||||
self.themePreset = themePreset
|
||||
}
|
||||
|
||||
public func applying(to configuration: RDEPUBReaderConfiguration) -> RDEPUBReaderConfiguration {
|
||||
var resolvedConfiguration = configuration
|
||||
|
||||
if let fontSize {
|
||||
resolvedConfiguration.fontSize = fontSize
|
||||
}
|
||||
if let fontChoice {
|
||||
resolvedConfiguration.fontChoice = fontChoice
|
||||
}
|
||||
if let lineHeightMultiple {
|
||||
resolvedConfiguration.lineHeightMultiple = lineHeightMultiple
|
||||
}
|
||||
if let numberOfColumns {
|
||||
resolvedConfiguration.numberOfColumns = max(1, numberOfColumns)
|
||||
}
|
||||
if let displayMode {
|
||||
resolvedConfiguration.displayType = displayMode.displayType
|
||||
}
|
||||
if let themePreset {
|
||||
resolvedConfiguration.theme = themePreset.theme
|
||||
}
|
||||
|
||||
return resolvedConfiguration
|
||||
}
|
||||
|
||||
public static func capture(
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
brightness: CGFloat
|
||||
) -> RDEPUBReaderSettings {
|
||||
RDEPUBReaderSettings(
|
||||
brightness: max(0, min(1, brightness)),
|
||||
fontSize: configuration.fontSize,
|
||||
fontChoice: configuration.fontChoice,
|
||||
lineHeightMultiple: configuration.lineHeightMultiple,
|
||||
numberOfColumns: configuration.numberOfColumns,
|
||||
displayMode: RDEPUBReaderDisplayMode(displayType: configuration.displayType),
|
||||
themePreset: RDEPUBReaderThemePreset(theme: configuration.theme)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
|
||||
var onBrightnessChange: ((CGFloat) -> Void)?
|
||||
|
||||
var onFontSizeChange: ((CGFloat) -> Void)?
|
||||
|
||||
var onFontChoiceChange: ((RDEPUBReaderFontChoice) -> Void)?
|
||||
|
||||
var onLineHeightChange: ((CGFloat) -> Void)?
|
||||
|
||||
var onColumnCountChange: ((Int) -> Void)?
|
||||
|
||||
var onDisplayTypeChange: ((RDEpubReaderView.DisplayType) -> Void)?
|
||||
|
||||
var onThemeChange: ((RDEPUBReaderTheme) -> Void)?
|
||||
|
||||
var onDismiss: (() -> Void)?
|
||||
|
||||
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] = []
|
||||
|
||||
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: RDEpubReaderView.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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import UIKit
|
||||
|
||||
public struct RDEPUBReaderTheme: Equatable {
|
||||
|
||||
public var contentBackgroundColor: UIColor
|
||||
|
||||
public var contentTextColor: UIColor
|
||||
|
||||
public var toolBackgroundColor: UIColor
|
||||
|
||||
public var toolControlTextColor: UIColor
|
||||
|
||||
public var toolControlBorderUnselectColor: UIColor
|
||||
|
||||
public var toolLineColor: UIColor
|
||||
|
||||
public init(
|
||||
contentBackgroundColor: UIColor,
|
||||
contentTextColor: UIColor,
|
||||
toolBackgroundColor: UIColor,
|
||||
toolControlTextColor: UIColor,
|
||||
toolControlBorderUnselectColor: UIColor,
|
||||
toolLineColor: UIColor
|
||||
) {
|
||||
self.contentBackgroundColor = contentBackgroundColor
|
||||
self.contentTextColor = contentTextColor
|
||||
self.toolBackgroundColor = toolBackgroundColor
|
||||
self.toolControlTextColor = toolControlTextColor
|
||||
self.toolControlBorderUnselectColor = toolControlBorderUnselectColor
|
||||
self.toolLineColor = toolLineColor
|
||||
}
|
||||
|
||||
public static let light = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: .white,
|
||||
contentTextColor: .black,
|
||||
toolBackgroundColor: .white,
|
||||
toolControlTextColor: .black,
|
||||
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
public static let dark = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: .black,
|
||||
contentTextColor: .white,
|
||||
toolBackgroundColor: .black,
|
||||
toolControlTextColor: .white,
|
||||
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
public static let yellow = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
toolBackgroundColor: UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1),
|
||||
toolControlTextColor: .black,
|
||||
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
public static let green = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
toolBackgroundColor: UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1),
|
||||
toolControlTextColor: .black,
|
||||
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
public static let pink = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
toolBackgroundColor: UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1),
|
||||
toolControlTextColor: .black,
|
||||
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
public static let blue = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
toolBackgroundColor: UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1),
|
||||
toolControlTextColor: .black,
|
||||
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
}
|
||||
|
||||
extension RDEPUBReaderTheme {
|
||||
|
||||
/// Background color for the full-screen image viewer.
|
||||
/// Always near-black regardless of theme, since the image is the focus.
|
||||
var imageViewerBackgroundColor: UIColor {
|
||||
UIColor(white: 0.0, alpha: 0.95)
|
||||
}
|
||||
|
||||
var themeBackgroundColorCSS: String {
|
||||
contentBackgroundColor.rd_cssString
|
||||
}
|
||||
|
||||
var themeTextColorCSS: String {
|
||||
contentTextColor.rd_cssString
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user