f83662275d
Replace the async JS bridge round-trip (onLayout → updateLayout()) with native KVO observation on the content view's bounds. When React Native's Yoga layout engine sets the frame, the KVO callback fires synchronously on the same run loop iteration and calls updateLayout() directly. This eliminates the timing issues that surfaced after enabling iOS 26 design. https://claude.ai/code/session_01Rp7ef1h3fKh6fhStcjqLJ5
238 lines
6.7 KiB
Swift
238 lines
6.7 KiB
Swift
import ExpoModulesCore
|
|
import React
|
|
import UIKit
|
|
|
|
class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
|
// Views
|
|
private var sheetVc: SheetViewController?
|
|
private var innerView: UIView?
|
|
private var touchHandler: RCTTouchHandler?
|
|
|
|
// Native content height observation (eliminates JS bridge round-trip)
|
|
private var contentHeightObservation: NSKeyValueObservation?
|
|
|
|
// Events
|
|
private let onAttemptDismiss = EventDispatcher()
|
|
private let onSnapPointChange = EventDispatcher()
|
|
private let onStateChange = EventDispatcher()
|
|
|
|
// Open event firing
|
|
private var isOpen: Bool = false {
|
|
didSet {
|
|
onStateChange([
|
|
"state": isOpen ? "open" : "closed"
|
|
])
|
|
}
|
|
}
|
|
|
|
// React view props
|
|
var preventDismiss = false
|
|
var preventExpansion = false
|
|
var cornerRadius: CGFloat?
|
|
var sourceViewTag: Int?
|
|
var minHeight = 0.0
|
|
var maxHeight: CGFloat! {
|
|
didSet {
|
|
let screenHeight = Util.getScreenHeight() ?? 0
|
|
if maxHeight > screenHeight {
|
|
maxHeight = screenHeight
|
|
}
|
|
}
|
|
}
|
|
|
|
private var isOpening = false {
|
|
didSet {
|
|
if isOpening {
|
|
onStateChange([
|
|
"state": "opening"
|
|
])
|
|
}
|
|
}
|
|
}
|
|
private var isClosing = false {
|
|
didSet {
|
|
if isClosing {
|
|
onStateChange([
|
|
"state": "closing"
|
|
])
|
|
}
|
|
}
|
|
}
|
|
private var selectedDetentIdentifier: UISheetPresentationController.Detent.Identifier? {
|
|
didSet {
|
|
if selectedDetentIdentifier == .large {
|
|
onSnapPointChange([
|
|
"snapPoint": 2
|
|
])
|
|
} else {
|
|
onSnapPointChange([
|
|
"snapPoint": 1
|
|
])
|
|
}
|
|
}
|
|
}
|
|
private var prevLayoutDetentIdentifier: UISheetPresentationController.Detent.Identifier?
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
required init (appContext: AppContext? = nil) {
|
|
super.init(appContext: appContext)
|
|
self.maxHeight = Util.getScreenHeight()
|
|
self.touchHandler = RCTTouchHandler(bridge: appContext?.reactBridge)
|
|
SheetManager.shared.add(self)
|
|
}
|
|
|
|
deinit {
|
|
self.destroy()
|
|
}
|
|
|
|
// We don't want this view to actually get added to the tree, so we'll simply store it for adding
|
|
// to the SheetViewController
|
|
override func insertReactSubview(_ subview: UIView!, at atIndex: Int) {
|
|
self.touchHandler?.attach(to: subview)
|
|
self.innerView = subview
|
|
}
|
|
|
|
// We'll grab the content height from here so we know the initial detent to set
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
|
|
guard let innerView = self.innerView else {
|
|
return
|
|
}
|
|
|
|
if innerView.subviews.count != 1 {
|
|
return
|
|
}
|
|
|
|
self.present()
|
|
}
|
|
|
|
private func destroy() {
|
|
self.contentHeightObservation?.invalidate()
|
|
self.contentHeightObservation = nil
|
|
self.isClosing = false
|
|
self.isOpen = false
|
|
self.sheetVc = nil
|
|
self.touchHandler?.detach(from: self.innerView)
|
|
self.touchHandler = nil
|
|
self.innerView = nil
|
|
SheetManager.shared.remove(self)
|
|
}
|
|
|
|
// MARK: - Presentation
|
|
|
|
func present() {
|
|
guard !self.isOpen,
|
|
!self.isOpening,
|
|
!self.isClosing,
|
|
let innerView = self.innerView,
|
|
let contentHeight = innerView.subviews.first?.frame.height,
|
|
let rvc = self.reactViewController() else {
|
|
return
|
|
}
|
|
|
|
let sheetVc = SheetViewController()
|
|
sheetVc.setDetents(contentHeight: self.clampHeight(contentHeight), preventExpansion: self.preventExpansion)
|
|
if let sheet = sheetVc.sheetPresentationController {
|
|
sheet.delegate = self
|
|
sheet.preferredCornerRadius = self.cornerRadius
|
|
self.selectedDetentIdentifier = sheet.selectedDetentIdentifier
|
|
}
|
|
sheetVc.view.addSubview(innerView)
|
|
|
|
if #available(iOS 26.0, *),
|
|
let tag = self.sourceViewTag,
|
|
let bridge = self.appContext?.reactBridge,
|
|
let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: tag)) {
|
|
sheetVc.preferredTransition = .zoom { _ in
|
|
return sourceView
|
|
}
|
|
}
|
|
|
|
self.sheetVc = sheetVc
|
|
self.isOpening = true
|
|
self.startObservingContentHeight()
|
|
|
|
rvc.present(sheetVc, animated: true) { [weak self] in
|
|
self?.isOpening = false
|
|
self?.isOpen = true
|
|
}
|
|
}
|
|
|
|
// Observe the content view's bounds via KVO so that height changes are detected
|
|
// purely on the native side, without a JS bridge round-trip through onLayout.
|
|
private func startObservingContentHeight() {
|
|
self.contentHeightObservation?.invalidate()
|
|
|
|
guard let contentView = self.innerView?.subviews.first else { return }
|
|
|
|
self.contentHeightObservation = contentView.observe(
|
|
\.bounds,
|
|
options: [.old, .new]
|
|
) { [weak self] _, change in
|
|
guard let self = self,
|
|
(self.isOpen || self.isOpening) && !self.isClosing,
|
|
let oldBounds = change.oldValue,
|
|
let newBounds = change.newValue,
|
|
oldBounds.height != newBounds.height,
|
|
newBounds.height > 0 else { return }
|
|
self.updateLayout()
|
|
}
|
|
}
|
|
|
|
func updateLayout() {
|
|
// Allow updates either when identifiers match OR when prevLayoutDetentIdentifier is nil (first real content update)
|
|
if self.prevLayoutDetentIdentifier == self.selectedDetentIdentifier || self.prevLayoutDetentIdentifier == nil,
|
|
let contentHeight = self.innerView?.subviews.first?.frame.size.height {
|
|
self.sheetVc?.updateDetents(contentHeight: self.clampHeight(contentHeight),
|
|
preventExpansion: self.preventExpansion)
|
|
self.selectedDetentIdentifier = self.sheetVc?.getCurrentDetentIdentifier()
|
|
}
|
|
self.prevLayoutDetentIdentifier = self.selectedDetentIdentifier
|
|
}
|
|
|
|
func dismiss() {
|
|
guard let sheetVc = self.sheetVc else {
|
|
return
|
|
}
|
|
|
|
self.isClosing = true
|
|
DispatchQueue.main.async {
|
|
sheetVc.dismiss(animated: true) { [weak self] in
|
|
self?.destroy()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Utils
|
|
|
|
private func clampHeight(_ height: CGFloat) -> CGFloat {
|
|
if height < self.minHeight {
|
|
return self.minHeight
|
|
} else if height > self.maxHeight {
|
|
return self.maxHeight
|
|
}
|
|
return height
|
|
}
|
|
|
|
// MARK: - UISheetPresentationControllerDelegate
|
|
|
|
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
|
|
self.onAttemptDismiss()
|
|
return !self.preventDismiss
|
|
}
|
|
|
|
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
|
|
self.isClosing = true
|
|
}
|
|
|
|
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
|
self.destroy()
|
|
}
|
|
|
|
func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) {
|
|
self.selectedDetentIdentifier = sheetPresentationController.selectedDetentIdentifier
|
|
}
|
|
}
|