1import ExpoModulesCore
2import UIKit
3
4/// Wraps a screen's header scroll view. Where a column pads its content away from the iPhone Duo's
5/// vertical bar, this widens the scroll view under the bar, like the system apps' lists. UIKit
6/// draws the header's scroll-edge effect inside the scroll view, so the blur reaches the bar while
7/// the content stays in the safe area. Everything happens in UIKit layout, so folds move the blur
8/// and the content together.
9final class T3ScrollEdgeBleedView: ExpoView {
10 // Fabric recycles scroll views, so everything changed here is put back before this view leaves
11 // the window, which unmounting does before any recycling.
12 private var scrollView: UIScrollView?
13 private var container: UIView?
14 /// React Native's content view, which every row lives in.
15 private var content: UIView?
16 private var bleed = UIEdgeInsets.zero
17 /// Whether the scroll view currently carries this view's changes.
18 private var applied = false
19 /// React Native's clipping, saved while bleeding. React Native only rewrites it when the
20 /// `overflow` style changes, so it stays off while bleeding.
21 private var containerClipped: Bool?
22
23 required init(appContext: AppContext? = nil) {
24 super.init(appContext: appContext)
25 #if canImport(UIKit, _version: 9127.0.85)
26 if #available(iOS 27.1, *) {
27 registerForTraitChanges(UITraitCollection.systemTraitsAffectingVerticalBarEdge) { (view: T3ScrollEdgeBleedView, _: UITraitCollection) in
28 view.setNeedsLayout()
29 }
30 }
31 #endif
32 }
33
34 deinit {
35 MainActor.assumeIsolated { detach() }
36 }
37
38 override func didMoveToWindow() {
39 super.didMoveToWindow()
40 if window == nil { detach() } else { setNeedsLayout() }
41 }
42
43 override func didAddSubview(_ subview: UIView) {
44 super.didAddSubview(subview)
45 setNeedsLayout()
46 }
47
48 override func willRemoveSubview(_ subview: UIView) {
49 if let scrollView, scrollView.isDescendant(of: subview) { detach() }
50 super.willRemoveSubview(subview)
51 }
52
53 override func layoutSubviews() {
54 super.layoutSubviews()
55 guard window != nil, let found = findScrollView(), let parent = found.superview else { return }
56 if found !== scrollView {
57 detach()
58 scrollView = found
59 container = parent
60 // React Native adds one plain UIView to its scroll view and mounts every child into it.
61 content = found.subviews.first { type(of: $0) == UIView.self }
62 }
63 bleed = verticalBarBleed()
64 // Without the content view to shift back, bleeding left would move the rows under the bar.
65 if content == nil { bleed.left = 0 }
66 apply()
67 }
68
69 /// How far the vertical bar's safe area reaches past this view's edge, when this view sits flush
70 /// against the padding its column added for it. Nothing else is bled into: not a sidebar column
71 /// overlapping this one, and not a notch.
72 private func verticalBarBleed() -> UIEdgeInsets {
73 guard let side = verticalBarSide() else { return .zero }
74 var ancestor = superview
75 while let view = ancestor {
76 let inset = side == .left ? view.safeAreaInsets.left : view.safeAreaInsets.right
77 if inset > 0 {
78 let frame = convert(bounds, to: view)
79 let flush = side == .left
80 ? abs(frame.minX - (view.bounds.minX + inset)) < 0.5
81 : abs(view.bounds.maxX - inset - frame.maxX) < 0.5
82 guard flush else { return .zero }
83 return side == .left
84 ? UIEdgeInsets(top: 0, left: inset, bottom: 0, right: 0)
85 : UIEdgeInsets(top: 0, left: 0, bottom: 0, right: inset)
86 }
87 ancestor = view.superview
88 }
89 return .zero
90 }
91
92 private enum Side { case left, right }
93
94 private func verticalBarSide() -> Side? {
95 // The vertical-bar API ships in the iOS 27.1 SDK (UIKit 9127.0.85); see T3LayoutMetricsView.
96 #if canImport(UIKit, _version: 9127.0.85)
97 if #available(iOS 27.1, *) {
98 // UIKit leaves the trait unset in a split view's inspector column, though the bar overlaps
99 // it; the flush check above still decides whether this view sits at the bar.
100 let traits = window?.rootViewController?.traitCollection ?? traitCollection
101 let rtl = effectiveUserInterfaceLayoutDirection == .rightToLeft
102 switch traits.verticalBarEdge {
103 case .leading: return rtl ? .right : .left
104 case .trailing: return rtl ? .left : .right
105 default: return nil
106 }
107 }
108 #endif
109 return nil
110 }
111
112 /// Widening to the left would move the rows with it. Instead of a horizontal content offset,
113 /// which React Native rewrites on its own layout passes, the content view's bounds origin shifts
114 /// them back: React Native only ever sets that view's frame, which leaves its bounds origin.
115 /// Nothing at all is written unless this view is bleeding or undoing its bleed.
116 private func apply() {
117 guard let scrollView, let container else { return }
118 let bleeding = bleed != .zero
119 guard bleeding || applied else { return }
120 // React Native sizes the scroll view's component view; the UIScrollView inside follows it by
121 // autoresizing, so the widened frame survives React Native's own layout updates.
122 let frame = container.bounds.inset(
123 by: UIEdgeInsets(top: 0, left: -bleed.left, bottom: 0, right: -bleed.right))
124 if scrollView.frame != frame { scrollView.frame = frame }
125 if let content, content.bounds.origin.x != -bleed.left {
126 content.bounds.origin.x = -bleed.left
127 }
128 if !bleeding {
129 if let clipped = containerClipped {
130 containerClipped = nil
131 container.layer.masksToBounds = clipped
132 }
133 } else if container.layer.masksToBounds {
134 // The component view clips to its own bounds, which would cut the widened scroll view back.
135 // The scroll view itself still clips its content.
136 containerClipped = containerClipped ?? true
137 container.layer.masksToBounds = false
138 }
139 var indicators = scrollView.verticalScrollIndicatorInsets
140 if indicators.right != bleed.right {
141 indicators.right = bleed.right
142 scrollView.verticalScrollIndicatorInsets = indicators
143 }
144 applied = bleeding
145 }
146
147 private func detach() {
148 guard scrollView != nil else { return }
149 bleed = .zero
150 apply()
151 scrollView = nil
152 container = nil
153 content = nil
154 }
155
156 /// The scroll view sits a fixed few levels down (marker, React Native's scroll view component);
157 /// never walk into a scroll view's content.
158 private func findScrollView() -> UIScrollView? {
159 var level: [UIView] = subviews
160 for _ in 0..<4 {
161 var next: [UIView] = []
162 for view in level {
163 if let scrollView = view as? UIScrollView { return scrollView }
164 next.append(contentsOf: view.subviews)
165 }
166 level = next
167 }
168 return nil
169 }
170}