forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import ExpoModulesCore
2import React
3import UIKit
4
5class SheetView: ExpoView, UISheetPresentationControllerDelegate {
6 // Views
7 private var sheetVc: SheetViewController?
8 private var innerView: UIView?
9 private var touchHandler: RCTTouchHandler?
10
11 // Native content height observation (eliminates JS bridge round-trip)
12 private var contentHeightObservation: NSKeyValueObservation?
13
14 // Events
15 private let onAttemptDismiss = EventDispatcher()
16 private let onSnapPointChange = EventDispatcher()
17 private let onStateChange = EventDispatcher()
18
19 // Open event firing
20 private var isOpen: Bool = false {
21 didSet {
22 onStateChange([
23 "state": isOpen ? "open" : "closed"
24 ])
25 }
26 }
27
28 // React view props
29 var fullHeight = false
30 var preventDismiss = false
31 var preventExpansion = false
32 var cornerRadius: CGFloat?
33 var sourceViewTag: Int?
34 var minHeight = 0.0
35 var maxHeight: CGFloat! {
36 didSet {
37 let screenHeight = Util.getScreenHeight() ?? 0
38 if maxHeight > screenHeight {
39 maxHeight = screenHeight
40 }
41 }
42 }
43
44 private var isOpening = false {
45 didSet {
46 if isOpening {
47 onStateChange([
48 "state": "opening"
49 ])
50 }
51 }
52 }
53 private var isClosing = false {
54 didSet {
55 if isClosing {
56 onStateChange([
57 "state": "closing"
58 ])
59 }
60 }
61 }
62 private var selectedDetentIdentifier: UISheetPresentationController.Detent.Identifier? {
63 didSet {
64 if selectedDetentIdentifier == .large {
65 onSnapPointChange([
66 "snapPoint": 2
67 ])
68 } else {
69 onSnapPointChange([
70 "snapPoint": 1
71 ])
72 }
73 }
74 }
75
76 // MARK: - Lifecycle
77
78 required init (appContext: AppContext? = nil) {
79 super.init(appContext: appContext)
80 self.maxHeight = Util.getScreenHeight()
81 self.touchHandler = RCTTouchHandler(bridge: appContext?.reactBridge)
82 SheetManager.shared.add(self)
83 }
84
85 deinit {
86 self.destroy()
87 }
88
89 // We don't want this view to actually get added to the tree, so we'll simply store it for adding
90 // to the SheetViewController
91 override func insertReactSubview(_ subview: UIView!, at atIndex: Int) {
92 self.touchHandler?.attach(to: subview)
93 self.innerView = subview
94 }
95
96 // We'll grab the content height from here so we know the initial detent to set
97 override func layoutSubviews() {
98 super.layoutSubviews()
99
100 guard let innerView = self.innerView else {
101 return
102 }
103
104 if innerView.subviews.count != 1 {
105 return
106 }
107
108 self.present()
109 }
110
111 private func destroy() {
112 self.contentHeightObservation?.invalidate()
113 self.contentHeightObservation = nil
114 self.isClosing = false
115 self.isOpen = false
116 self.sheetVc = nil
117 self.touchHandler?.detach(from: self.innerView)
118 self.touchHandler = nil
119 self.innerView = nil
120 SheetManager.shared.remove(self)
121 }
122
123 // MARK: - Presentation
124
125 func present() {
126 guard !self.isOpen,
127 !self.isOpening,
128 !self.isClosing,
129 let innerView = self.innerView,
130 let contentHeight = innerView.subviews.first?.frame.height,
131 let rvc = self.reactViewController() else {
132 return
133 }
134
135 let sheetVc = SheetViewController()
136 sheetVc.setDetents(contentHeight: self.clampHeight(contentHeight), preventExpansion: self.preventExpansion, fullHeight: self.fullHeight)
137 if let sheet = sheetVc.sheetPresentationController {
138 sheet.delegate = self
139 sheet.preferredCornerRadius = self.cornerRadius
140 self.selectedDetentIdentifier = sheet.selectedDetentIdentifier
141 }
142 sheetVc.view.addSubview(innerView)
143
144 if #available(iOS 26.0, *),
145 let tag = self.sourceViewTag,
146 let bridge = self.appContext?.reactBridge,
147 let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: tag)) {
148 sheetVc.preferredTransition = .zoom { _ in
149 return sourceView
150 }
151 }
152
153 self.sheetVc = sheetVc
154 self.isOpening = true
155 if !self.fullHeight {
156 self.startObservingContentHeight()
157 }
158
159 rvc.present(sheetVc, animated: true) { [weak self] in
160 self?.isOpening = false
161 self?.isOpen = true
162 }
163 }
164
165 // Observe the content view's bounds via KVO so that height changes are detected
166 // purely on the native side, without a JS bridge round-trip through onLayout.
167 // Calls updateDetents directly with the observed height rather than going through
168 // updateLayout(), which has a prevLayoutDetentIdentifier guard that can block
169 // legitimate content-driven updates when detent identifiers drift during animations.
170 private func startObservingContentHeight() {
171 self.contentHeightObservation?.invalidate()
172
173 guard let contentView = self.innerView?.subviews.first else { return }
174
175 self.contentHeightObservation = contentView.observe(
176 \.bounds,
177 options: [.old, .new]
178 ) { [weak self] _, change in
179 guard let self = self,
180 (self.isOpen || self.isOpening) && !self.isClosing,
181 let oldBounds = change.oldValue,
182 let newBounds = change.newValue,
183 oldBounds.height != newBounds.height,
184 newBounds.height > 0 else { return }
185 let clampedHeight = self.clampHeight(newBounds.height)
186 self.sheetVc?.updateDetents(contentHeight: clampedHeight, preventExpansion: self.preventExpansion)
187 self.selectedDetentIdentifier = self.sheetVc?.getCurrentDetentIdentifier()
188 }
189 }
190
191 func dismiss() {
192 guard let sheetVc = self.sheetVc else {
193 return
194 }
195
196 self.isClosing = true
197 DispatchQueue.main.async {
198 sheetVc.dismiss(animated: true) { [weak self] in
199 self?.destroy()
200 }
201 }
202 }
203
204 // MARK: - Utils
205
206 private func clampHeight(_ height: CGFloat) -> CGFloat {
207 if height < self.minHeight {
208 return self.minHeight
209 } else if height > self.maxHeight {
210 return self.maxHeight
211 }
212 return height
213 }
214
215 // MARK: - UISheetPresentationControllerDelegate
216
217 func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
218 self.onAttemptDismiss()
219 return !self.preventDismiss
220 }
221
222 func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
223 self.isClosing = true
224 }
225
226 func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
227 self.destroy()
228 }
229
230 func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) {
231 self.selectedDetentIdentifier = sheetPresentationController.selectedDetentIdentifier
232 }
233}