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 fullHeight = false 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 ]) } } } // 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, fullHeight: self.fullHeight) 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 if !self.fullHeight { 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. // Calls updateDetents directly with the observed height rather than going through // updateLayout(), which has a prevLayoutDetentIdentifier guard that can block // legitimate content-driven updates when detent identifiers drift during animations. 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 } let clampedHeight = self.clampHeight(newBounds.height) self.sheetVc?.updateDetents(contentHeight: clampedHeight, preventExpansion: self.preventExpansion) self.selectedDetentIdentifier = self.sheetVc?.getCurrentDetentIdentifier() } } 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 } }