mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
On iOS 27.1+, the window layout probe publishes the top bar guide frame and active occlusion rects. When the top bar starts above the system top inset, the safe-area top moves up to it, and each header row that lands in the band trims its trailing padding to the status region. A row whose controls cannot fit beside it starts below the region on its own. When the guide is missing or starts at or below the system top, the override is a no-op, which is the case on every iPhone and on the Duo outer screen. Co-authored-by: Elsa Granger <zeyugao@outlook.com>
141 lines
4.5 KiB
Swift
141 lines
4.5 KiB
Swift
import ExpoModulesCore
|
|
import UIKit
|
|
|
|
public final class PaseoWindowLayoutModule: Module {
|
|
public func definition() -> ModuleDefinition {
|
|
Name("PaseoWindowLayout")
|
|
View(WindowLayoutView.self) {
|
|
Events("onGeometryChange")
|
|
}
|
|
}
|
|
}
|
|
|
|
final class WindowLayoutView: ExpoView {
|
|
let onGeometryChange = EventDispatcher()
|
|
private var updateScheduled = false
|
|
private var previousPayload: [String: Any]?
|
|
private var divisions: [CGRect] = []
|
|
private var barGuide: UILayoutGuide?
|
|
private var barEdge = "none"
|
|
private var bottomBarGuide: UILayoutGuide?
|
|
private var topBarGuide: UILayoutGuide?
|
|
private var occlusions: [CGRect] = []
|
|
private var measuredBounds = CGRect.zero
|
|
|
|
required init(appContext: AppContext? = nil) {
|
|
super.init(appContext: appContext)
|
|
isUserInteractionEnabled = false
|
|
#if compiler(>=6.4)
|
|
if #available(iOS 27.1, *) {
|
|
registerForTraitChanges(UITraitCollection.systemTraitsAffectingVerticalBarEdge) {
|
|
(view: WindowLayoutView, _) in
|
|
view.setNeedsUpdateConstraints()
|
|
view.scheduleUpdate()
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
override func didMoveToWindow() {
|
|
super.didMoveToWindow()
|
|
previousPayload = nil
|
|
bottomBarGuide = nil
|
|
topBarGuide = nil
|
|
setNeedsUpdateConstraints()
|
|
scheduleUpdate()
|
|
}
|
|
|
|
override func safeAreaInsetsDidChange() {
|
|
super.safeAreaInsetsDidChange()
|
|
setNeedsUpdateConstraints()
|
|
scheduleUpdate()
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
if bounds != measuredBounds {
|
|
measuredBounds = bounds
|
|
setNeedsUpdateConstraints()
|
|
}
|
|
scheduleUpdate()
|
|
}
|
|
|
|
override func updateConstraints() {
|
|
#if compiler(>=6.4)
|
|
if #available(iOS 27.1, *) {
|
|
// UIKit tracks these reads and invalidates constraints when regions change.
|
|
divisions = reservedRegions(kind: .division).compactMap { $0.isActive ? $0.frame : nil }
|
|
// Frames already include the system's interactive margins.
|
|
occlusions = reservedRegions(kind: .occlusion).compactMap { $0.isActive ? $0.frame : nil }
|
|
let edge: String
|
|
switch traitCollection.verticalBarEdge {
|
|
case .leading: edge = "leading"
|
|
case .trailing: edge = "trailing"
|
|
default: edge = "none"
|
|
}
|
|
if bottomBarGuide == nil {
|
|
// Where a bottom bar rests: above the home indicator, refined to the
|
|
// screen's corners rather than the rectangular safe area.
|
|
bottomBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.bottom, extent: 44))
|
|
}
|
|
if topBarGuide == nil {
|
|
// Where a top bar rests. Beside a partial status region it can start above the
|
|
// rectangular safe area; content still keeps out of the occlusions.
|
|
topBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.top, extent: 44))
|
|
}
|
|
if edge != barEdge {
|
|
barEdge = edge
|
|
barGuide = edge == "none" ? nil : layoutGuide(
|
|
for: .bar(onEdge: edge == "trailing" ? .right : .left, extent: 44)
|
|
)
|
|
}
|
|
}
|
|
#endif
|
|
super.updateConstraints()
|
|
scheduleUpdate()
|
|
}
|
|
|
|
private func scheduleUpdate() {
|
|
guard !updateScheduled else { return }
|
|
updateScheduled = true
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.updateScheduled = false
|
|
self.publish()
|
|
}
|
|
}
|
|
|
|
private func publish() {
|
|
guard window != nil, bounds.width > 0, bounds.height > 0 else { return }
|
|
let bar = barGuide?.layoutFrame
|
|
var barPayload: Any = NSNull()
|
|
if let bar, bar.width > 0, bar.height > 0 { barPayload = rect(bar) }
|
|
// Before UIKit resolves the guide its frame has no height: publish null (the
|
|
// system inset applies) rather than a false zero clearance.
|
|
var bottomClearance: Any = NSNull()
|
|
if let bottomBar = bottomBarGuide?.layoutFrame, bottomBar.height > 0 {
|
|
bottomClearance = max(0, bounds.maxY - bottomBar.maxY)
|
|
}
|
|
var topBar: Any = NSNull()
|
|
if let frame = topBarGuide?.layoutFrame, frame.width > 0, frame.height > 0 {
|
|
topBar = rect(frame)
|
|
}
|
|
let payload: [String: Any] = [
|
|
"bounds": rect(bounds),
|
|
"verticalBarEdge": barEdge,
|
|
"verticalBar": barPayload,
|
|
"bottomClearance": bottomClearance,
|
|
"topBar": topBar,
|
|
"occlusions": occlusions.map(rect),
|
|
"divisions": divisions.map(rect)
|
|
]
|
|
guard !NSDictionary(dictionary: payload).isEqual(to: previousPayload ?? [:]) else { return }
|
|
previousPayload = payload
|
|
onGeometryChange(payload)
|
|
}
|
|
|
|
private func rect(_ frame: CGRect) -> [String: CGFloat] {
|
|
["x": frame.minX, "y": frame.minY, "width": frame.width, "height": frame.height]
|
|
}
|
|
}
|