安全区域改为从 key window 动态获取

- 新增 RDEPUBSafeArea:参考 GKNavigationBarSwift 的 keyWindow 三级查找与临时 window 兜底
- 移除 reflowableContentInsets 中 top/bottom 40 的固定默认值,改按设备真实安全区生成
- 各取安全区处的 ?? .zero 兜底改为回退到 key window 值
- DEBUG 构建下为文本可读区域绘制红色边框,方便查看排版范围

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
shenlei 2026-07-06 15:04:22 +09:00
parent ca408c20ab
commit d0efe3f2cc
8 changed files with 1605 additions and 1513 deletions

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,7 @@ public struct RDEPUBNavigatorLayoutContext: Equatable {
pagesPerScreen: Int = 1, pagesPerScreen: Int = 1,
safeAreaInsets: UIEdgeInsets = .zero, safeAreaInsets: UIEdgeInsets = .zero,
userInterfaceIdiom: UIUserInterfaceIdiom = .phone, userInterfaceIdiom: UIUserInterfaceIdiom = .phone,
reflowableContentInsets: UIEdgeInsets = UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16) reflowableContentInsets: UIEdgeInsets = RDEPUBSafeArea.defaultReflowableContentInsets()
) { ) {
self.containerSize = containerSize self.containerSize = containerSize
self.pagesPerScreen = max(1, pagesPerScreen) self.pagesPerScreen = max(1, pagesPerScreen)

View File

@ -0,0 +1,67 @@
import UIKit
/// Device-level safe area lookup modeled after GKNavigationBarSwift.
/// Reads the real safe area from the key window so correct values are
/// available even before a view has been laid out in the hierarchy.
/// Must be called on the main thread.
public enum RDEPUBSafeArea {
/// Minimum text margins applied when the device safe area on an edge is
/// smaller (e.g. no notch / no home indicator). These are aesthetic
/// paddings, not approximations of the safe area itself.
public static let minimumVerticalTextMargin: CGFloat = 20
public static let minimumHorizontalTextMargin: CGFloat = 16
public static func keyWindow() -> UIWindow? {
let scenes = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
if let window = scenes
.filter({ $0.activationState == .foregroundActive })
.flatMap({ $0.windows })
.first(where: { $0.isKeyWindow }) {
return window
}
if let window = scenes
.flatMap({ $0.windows })
.first(where: { $0.isKeyWindow }) {
return window
}
return UIApplication.shared.delegate?.window ?? nil
}
public static func insets() -> UIEdgeInsets {
if let window = keyWindow() {
return window.safeAreaInsets
}
// No key window yet (early launch): create a detached window to read
// the device safe area, same fallback as GKNavigationBarSwift.
let window = UIWindow(frame: UIScreen.main.bounds)
if window.safeAreaInsets.bottom <= 0 {
window.rootViewController = UIViewController()
}
return window.safeAreaInsets
}
/// Prefers insets measured from a view already installed in the hierarchy;
/// falls back to the key-window insets when the view is not laid out yet
/// and reports .zero.
public static func resolve(_ measuredInsets: UIEdgeInsets?) -> UIEdgeInsets {
if let measuredInsets, measuredInsets != .zero {
return measuredInsets
}
return insets()
}
/// Default reflowable content insets derived from the live device safe
/// area instead of hard-coded heights.
public static func defaultReflowableContentInsets() -> UIEdgeInsets {
let safe = insets()
return UIEdgeInsets(
top: max(safe.top, minimumVerticalTextMargin),
left: max(safe.left, minimumHorizontalTextMargin),
bottom: max(safe.bottom, minimumVerticalTextMargin),
right: max(safe.right, minimumHorizontalTextMargin)
)
}
}

View File

@ -154,7 +154,7 @@ public final class RDURLReaderController: UIViewController {
let bookTitle = bookURL.deletingPathExtension().lastPathComponent let bookTitle = bookURL.deletingPathExtension().lastPathComponent
let pageSize = currentTextPageSize() let pageSize = currentTextPageSize()
let renderStyle = currentTextRenderStyle() let renderStyle = currentTextRenderStyle()
let safeInsets = view.safeAreaInsets let safeInsets = RDEPUBSafeArea.resolve(view.safeAreaInsets)
let edgeInsets = UIEdgeInsets( let edgeInsets = UIEdgeInsets(
top: max(epubConfiguration.reflowableContentInsets.top, safeInsets.top), top: max(epubConfiguration.reflowableContentInsets.top, safeInsets.top),
left: max(epubConfiguration.reflowableContentInsets.left, safeInsets.left), left: max(epubConfiguration.reflowableContentInsets.left, safeInsets.left),

View File

@ -163,7 +163,7 @@ final class RDEPUBReaderContext {
} }
func currentPreferences() -> RDEPUBPreferences { func currentPreferences() -> RDEPUBPreferences {
let safeInsets = controller?.view.safeAreaInsets ?? .zero let safeInsets = RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets)
return configuration.makePreferences(safeAreaInsets: safeInsets) return configuration.makePreferences(safeAreaInsets: safeInsets)
} }

View File

@ -47,7 +47,7 @@ final class RDEPUBReaderEnvironment {
return RDEPUBNavigatorLayoutContext( return RDEPUBNavigatorLayoutContext(
containerSize: resolvedSize, containerSize: resolvedSize,
pagesPerScreen: readerView?.pagesPerScreen ?? 1, pagesPerScreen: readerView?.pagesPerScreen ?? 1,
safeAreaInsets: controller?.view.safeAreaInsets ?? .zero, safeAreaInsets: RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets),
userInterfaceIdiom: controller?.traitCollection.userInterfaceIdiom ?? .phone, userInterfaceIdiom: controller?.traitCollection.userInterfaceIdiom ?? .phone,
reflowableContentInsets: configuration.reflowableContentInsets reflowableContentInsets: configuration.reflowableContentInsets
) )
@ -68,7 +68,7 @@ final class RDEPUBReaderEnvironment {
configuration: RDEPUBReaderConfiguration, configuration: RDEPUBReaderConfiguration,
pageSize: CGSize pageSize: CGSize
) -> RDEPUBTextLayoutConfig { ) -> RDEPUBTextLayoutConfig {
let safeAreaInsets = controller?.view.safeAreaInsets ?? .zero let safeAreaInsets = RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets)
return RDEPUBTextLayoutConfig( return RDEPUBTextLayoutConfig(
frameWidth: max(pageSize.width, 1), frameWidth: max(pageSize.width, 1),
frameHeight: max(pageSize.height, 1), frameHeight: max(pageSize.height, 1),

View File

@ -109,7 +109,7 @@ public struct RDEPUBReaderConfiguration: Equatable {
showsTableOfContents: Bool = true, showsTableOfContents: Bool = true,
allowsHighlights: Bool = true, allowsHighlights: Bool = true,
showsSettingsPanel: Bool = true, showsSettingsPanel: Bool = true,
reflowableContentInsets: UIEdgeInsets = UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16), reflowableContentInsets: UIEdgeInsets = RDEPUBSafeArea.defaultReflowableContentInsets(),
fixedContentInset: UIEdgeInsets = .zero, fixedContentInset: UIEdgeInsets = .zero,
theme: RDEPUBReaderTheme = .light, theme: RDEPUBReaderTheme = .light,
darkImageAdjustmentEnabled: Bool = true, darkImageAdjustmentEnabled: Bool = true,
@ -172,11 +172,13 @@ extension RDEPUBReaderConfiguration {
func makePreferences(safeAreaInsets: UIEdgeInsets = .zero) -> RDEPUBPreferences { func makePreferences(safeAreaInsets: UIEdgeInsets = .zero) -> RDEPUBPreferences {
// Use the larger of reflowableContentInsets and safeAreaInsets for each edge // Use the larger of reflowableContentInsets and safeAreaInsets for each edge
// to prevent content from being hidden under Dynamic Island / home indicator. // 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( let safeInsets = UIEdgeInsets(
top: max(reflowableContentInsets.top, safeAreaInsets.top), top: max(reflowableContentInsets.top, resolvedSafeAreaInsets.top),
left: max(reflowableContentInsets.left, safeAreaInsets.left), left: max(reflowableContentInsets.left, resolvedSafeAreaInsets.left),
bottom: max(reflowableContentInsets.bottom, safeAreaInsets.bottom), bottom: max(reflowableContentInsets.bottom, resolvedSafeAreaInsets.bottom),
right: max(reflowableContentInsets.right, safeAreaInsets.right) right: max(reflowableContentInsets.right, resolvedSafeAreaInsets.right)
) )
return RDEPUBPreferences( return RDEPUBPreferences(
fontSize: fontSize, fontSize: fontSize,

View File

@ -126,6 +126,19 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
return spinner return spinner
}() }()
#if DEBUG
/// Debug-only outline of the readable content area (bounds inset by contentInsets).
private let debugContentAreaBorderView: UIView = {
let view = UIView()
view.isUserInteractionEnabled = false
view.backgroundColor = .clear
view.layer.borderColor = UIColor.systemRed.withAlphaComponent(0.6).cgColor
view.layer.borderWidth = 1
view.accessibilityIdentifier = "epub.reader.debug.contentAreaBorder"
return view
}()
#endif
private lazy var longPressGestureRecognizer: UILongPressGestureRecognizer = { private lazy var longPressGestureRecognizer: UILongPressGestureRecognizer = {
let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:))) let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
gesture.minimumPressDuration = 0.5 gesture.minimumPressDuration = 0.5
@ -243,6 +256,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
addSubview(pageNumberLabel) addSubview(pageNumberLabel)
addSubview(loadingSpinner) addSubview(loadingSpinner)
addSubview(selectionLoupeView) addSubview(selectionLoupeView)
#if DEBUG
addSubview(debugContentAreaBorderView)
#endif
addGestureRecognizer(longPressGestureRecognizer) addGestureRecognizer(longPressGestureRecognizer)
addGestureRecognizer(panGestureRecognizer) addGestureRecognizer(panGestureRecognizer)
addGestureRecognizer(tapGestureRecognizer) addGestureRecognizer(tapGestureRecognizer)
@ -338,6 +354,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
coverImageView.frame = bounds.inset(by: contentInsets) coverImageView.frame = bounds.inset(by: contentInsets)
let contentRect = bounds.inset(by: contentInsets) let contentRect = bounds.inset(by: contentInsets)
#if DEBUG
debugContentAreaBorderView.frame = contentRect
#endif
let labelSize = pageNumberLabel.sizeThatFits( let labelSize = pageNumberLabel.sizeThatFits(
CGSize(width: contentRect.width, height: Self.pageNumberReservedHeight) CGSize(width: contentRect.width, height: Self.pageNumberReservedHeight)
) )
@ -374,7 +393,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
selectionController.clearSelection(renderView: coreTextRenderView) selectionController.clearSelection(renderView: coreTextRenderView)
contentInsets = Self.safeContentInsets( contentInsets = Self.safeContentInsets(
configuration: configuration, configuration: configuration,
safeAreaInsets: safeAreaInsets safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets)
) )
backgroundColor = configuration.theme.contentBackgroundColor backgroundColor = configuration.theme.contentBackgroundColor
pageNumberLabel.textColor = configuration.theme.contentTextColor pageNumberLabel.textColor = configuration.theme.contentTextColor
@ -459,7 +478,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
selectionController.clearSelection(renderView: coreTextRenderView) selectionController.clearSelection(renderView: coreTextRenderView)
contentInsets = Self.safeContentInsets( contentInsets = Self.safeContentInsets(
configuration: configuration, configuration: configuration,
safeAreaInsets: safeAreaInsets safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets)
) )
backgroundColor = configuration.theme.contentBackgroundColor backgroundColor = configuration.theme.contentBackgroundColor
pageNumberLabel.textColor = configuration.theme.contentTextColor pageNumberLabel.textColor = configuration.theme.contentTextColor