1import ExpoModulesCore
2import UIKit
3
4/// Reports geometry in the observing view's coordinate space, including system
5/// reservations that safe-area rectangles cannot represent, such as the fold.
6final class T3LayoutMetricsView: ExpoView {
7 let onMetricsChange = EventDispatcher()
8 private var lastMetrics: NSDictionary?
9 private var hasHinge: Bool? = false
10
11 required init(appContext: AppContext? = nil) {
12 super.init(appContext: appContext)
13 isUserInteractionEnabled = false
14 registerForTraitChanges([UITraitHorizontalSizeClass.self, UITraitVerticalSizeClass.self]) { (view: T3LayoutMetricsView, _: UITraitCollection) in
15 view.publishMetrics()
16 }
17 // The hinge and vertical-bar APIs ship in the iOS 27.1 SDK, whose UIKit is 9127.0.85.
18 // Xcode 27.0 has Swift 6.4 but the 27.0 SDK (UIKit 9127.0.84), so gate on the SDK.
19 #if canImport(UIKit, _version: 9127.0.85)
20 if #available(iOS 27.1, *) {
21 hasHinge = nil
22 addInteraction(UIHingeInteraction { [weak self] _, update in
23 guard let self, self.window != nil else { return }
24 // Capability survives temporary detachment and does not follow the hinge angle.
25 let hasHinge = self.hasHinge == true || update.hinge != nil
26 guard self.hasHinge != hasHinge else { return }
27 self.hasHinge = hasHinge
28 self.publishMetrics()
29 })
30 registerForTraitChanges(UITraitCollection.systemTraitsAffectingVerticalBarEdge) { (view: T3LayoutMetricsView, _: UITraitCollection) in
31 view.publishMetrics()
32 }
33 }
34 #endif
35 }
36
37 override func didMoveToWindow() {
38 super.didMoveToWindow()
39 lastMetrics = nil
40 assumeNoHingeIfSilent()
41 publishMetrics()
42 }
43
44 /// UIKit only sends hinge updates on devices that have a hinge. Without this,
45 /// an ordinary iPhone never publishes and the navigator never attaches.
46 private func assumeNoHingeIfSilent() {
47 guard window != nil, hasHinge == nil else { return }
48 DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
49 guard let self, self.window != nil, self.hasHinge == nil else { return }
50 self.hasHinge = false
51 self.publishMetrics()
52 }
53 }
54
55 override func layoutSubviews() {
56 super.layoutSubviews()
57 publishMetrics()
58 }
59
60 override func safeAreaInsetsDidChange() {
61 super.safeAreaInsetsDidChange()
62 publishMetrics()
63 }
64
65 private func publishMetrics() {
66 guard let window, bounds.width > 0, bounds.height > 0, let hasHinge else { return }
67 if hasHinge {
68 T3WorkspaceOrientationSubscriber.allowHingeOrientations(in: window)
69 }
70 var verticalBarEdge = "none"
71 var regions: [[String: Any]] = []
72 var bottomBarInset: CGFloat?
73 #if canImport(UIKit, _version: 9127.0.85)
74 if #available(iOS 27.1, *) {
75 let rtl = effectiveUserInterfaceLayoutDirection == .rightToLeft
76 switch traitCollection.verticalBarEdge {
77 case .leading: verticalBarEdge = rtl ? "right" : "left"
78 case .trailing: verticalBarEdge = rtl ? "left" : "right"
79 default: break
80 }
81 // Where UIKit floats a bar as tall as the app's bottom controls, level with its tab bar.
82 bottomBarInset = edgeInsets(for: .bar(onEdge: UIRectEdge.bottom, extent: 48)).bottom
83 for kind in [UIView.ReservedRegion.Kind.division, .occlusion] {
84 for region in reservedRegions(kind: kind) where region.isActive {
85 let frame = region.frame.intersection(bounds)
86 guard !frame.isNull, !frame.isEmpty else { continue }
87 regions.append([
88 "kind": kind == .division ? "division" : "occlusion",
89 "x": frame.minX, "y": frame.minY,
90 "width": frame.width, "height": frame.height,
91 ])
92 }
93 }
94 }
95 #endif
96 var metrics: [String: Any] = [
97 "width": bounds.width, "height": bounds.height,
98 "hasHinge": hasHinge,
99 "horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact",
100 "verticalBarEdge": verticalBarEdge,
101 "safeArea": [
102 "top": safeAreaInsets.top, "bottom": safeAreaInsets.bottom,
103 "left": safeAreaInsets.left, "right": safeAreaInsets.right,
104 ],
105 "reservedRegions": regions,
106 ]
107 metrics["bottomBarInset"] = bottomBarInset
108 let next = metrics as NSDictionary
109 guard lastMetrics?.isEqual(next) != true else { return }
110 lastMetrics = next
111 onMetricsChange(metrics)
112 }
113}