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 #if T3_IOS_27_1_SDK
18 if #available(iOS 27.1, *) {
19 hasHinge = nil
20 addInteraction(UIHingeInteraction { [weak self] _, update in
21 guard let self, self.window != nil else { return }
22 // Capability survives temporary detachment and does not follow the hinge angle.
23 let hasHinge = self.hasHinge == true || update.hinge != nil
24 guard self.hasHinge != hasHinge else { return }
25 self.hasHinge = hasHinge
26 self.publishMetrics()
27 })
28 registerForTraitChanges(UITraitCollection.systemTraitsAffectingVerticalBarEdge) { (view: T3LayoutMetricsView, _: UITraitCollection) in
29 view.publishMetrics()
30 }
31 }
32 #endif
33 }
34
35 override func didMoveToWindow() {
36 super.didMoveToWindow()
37 lastMetrics = nil
38 assumeNoHingeIfSilent()
39 publishMetrics()
40 }
41
42 /// UIKit only sends hinge updates on devices that have a hinge. Without this,
43 /// an ordinary iPhone never publishes and the navigator never attaches.
44 private func assumeNoHingeIfSilent() {
45 guard window != nil, hasHinge == nil else { return }
46 DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
47 guard let self, self.window != nil, self.hasHinge == nil else { return }
48 self.hasHinge = false
49 self.publishMetrics()
50 }
51 }
52
53 override func layoutSubviews() {
54 super.layoutSubviews()
55 publishMetrics()
56 }
57
58 override func safeAreaInsetsDidChange() {
59 super.safeAreaInsetsDidChange()
60 publishMetrics()
61 }
62
63 private func publishMetrics() {
64 guard let window, bounds.width > 0, bounds.height > 0, let hasHinge else { return }
65 if hasHinge {
66 T3WorkspaceOrientationSubscriber.allowHingeOrientations(in: window)
67 }
68 var verticalBarEdge = "none"
69 var regions: [[String: Any]] = []
70 #if T3_IOS_27_1_SDK
71 if #available(iOS 27.1, *) {
72 let rtl = effectiveUserInterfaceLayoutDirection == .rightToLeft
73 switch traitCollection.verticalBarEdge {
74 case .leading: verticalBarEdge = rtl ? "right" : "left"
75 case .trailing: verticalBarEdge = rtl ? "left" : "right"
76 default: break
77 }
78 for kind in [UIView.ReservedRegion.Kind.division, .occlusion] {
79 for region in reservedRegions(kind: kind) where region.isActive {
80 let frame = region.frame.intersection(bounds)
81 guard !frame.isNull, !frame.isEmpty else { continue }
82 regions.append([
83 "kind": kind == .division ? "division" : "occlusion",
84 "x": frame.minX, "y": frame.minY,
85 "width": frame.width, "height": frame.height,
86 ])
87 }
88 }
89 }
90 #endif
91 let metrics: [String: Any] = [
92 "width": bounds.width, "height": bounds.height,
93 "hasHinge": hasHinge,
94 "horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact",
95 "verticalBarEdge": verticalBarEdge,
96 "safeArea": [
97 "top": safeAreaInsets.top, "bottom": safeAreaInsets.bottom,
98 "left": safeAreaInsets.left, "right": safeAreaInsets.right,
99 ],
100 "reservedRegions": regions,
101 ]
102 let next = metrics as NSDictionary
103 guard lastMetrics?.isEqual(next) != true else { return }
104 lastMetrics = next
105 onMetricsChange(metrics)
106 }
107}