Observe content view bounds natively to update sheet height on iOS

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
This commit is contained in:
Claude
2026-02-28 23:47:19 +00:00
committed by Samuel Newman
parent ae2dd8432b
commit f83662275d
2 changed files with 29 additions and 2 deletions
+27
View File
@@ -8,6 +8,9 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
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()
@@ -106,6 +109,8 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
}
private func destroy() {
self.contentHeightObservation?.invalidate()
self.contentHeightObservation = nil
self.isClosing = false
self.isOpen = false
self.sheetVc = nil
@@ -147,6 +152,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
self.sheetVc = sheetVc
self.isOpening = true
self.startObservingContentHeight()
rvc.present(sheetVc, animated: true) { [weak self] in
self?.isOpening = false
@@ -154,6 +160,27 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
}
}
// 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,
@@ -126,9 +126,9 @@ export class BottomSheetNativeComponent extends React.Component<
// a console.log() - just sending the log to the CLI is enough.
// TODO: Get to the bottom of this and fix it properly! -sfn
setTimeout(() => this.updateLayout())
} else {
this.updateLayout()
}
// iOS: content height changes are observed natively via KVO on
// the content view's bounds, so no JS bridge round-trip needed.
}}
/>
</Portal>