From 32da4eeac665640875cd473fc61702e247b5151e Mon Sep 17 00:00:00 2001 From: Ognjen Ristanovic Date: Tue, 13 Oct 2026 14:40:05 +0200 Subject: [PATCH 1/2] Measure and report embedded content height on iOS Wires the sizeToContent spec to a real measurement on iOS. A GeometryReader behind the embedded view reports its height on every layout pass, and the wrapper forwards changes to onSizeChange after a dead band. Three things worth a reviewer's attention: - Height comes from a background GeometryReader rather than a SwiftUI preference. A preference misses the placeholder-to-content transition, which is exactly the case that has to reset a stale height. - parentWidth/parentHeight now return nil when the host has not sized the view, instead of substituting screen bounds. Nil is the SDK's signal for "no parent stated", so it resolves percentages against the real proposal. While self sizing, parentHeight falls back to the window height: it must not depend on the box the host just set, or measurement and layout feed each other. - Unavailability is signalled from a zero-sized placeholder rather than from a measured zero, which is ambiguous mid-layout. Verified on a simulator: content self-sizes, and dismissing it collapses the view instead of leaving a stale gap. Co-Authored-By: Claude Opus 5 --- ios/AirshipEmbeddedViewWrapper.swift | 119 +++++++++++++++++++++++---- ios/Bridge/RNAirshipBridge.h | 4 + ios/RNAirshipEmbeddedView.mm | 15 ++++ 3 files changed, 121 insertions(+), 17 deletions(-) diff --git a/ios/AirshipEmbeddedViewWrapper.swift b/ios/AirshipEmbeddedViewWrapper.swift index 90e51b41..839927ed 100644 --- a/ios/AirshipEmbeddedViewWrapper.swift +++ b/ios/AirshipEmbeddedViewWrapper.swift @@ -17,6 +17,45 @@ public final class AirshipEmbeddedViewWrapper: UIView, RNAirshipEmbeddedViewBrid public let viewController: UIViewController public var isAdded: Bool = false + /// Last height handed to the component view, in points. Negative means none yet. + private var reportedHeight: CGFloat = -1 + + /// Called with the content height in points whenever it changes. + @objc + public var onSizeChange: ((CGFloat) -> Void)? + + @objc(setSizeToContent:) + public func setSizeToContent(_ value: Bool) { + guard self.viewModel.sizeToContent != value else { return } + // The next report measures against a different proposal, so not a duplicate. + self.reportedHeight = -1 + self.viewModel.sizeToContent = value + } + + private func reportContentHeight(_ height: CGFloat) { + // A height at or below the floor means the content could not resolve one, not + // that it is empty; hold the last real value rather than collapsing the view. + guard height > Self.minContentHeight, + abs(height - self.reportedHeight) > Self.heightEpsilon else { + return + } + self.reportedHeight = height + self.onSizeChange?(height) + } + + /// Reports that there is no content, clearing any height already handed over. + /// + /// Zero bypasses the measurement floor: it is an availability signal, not a + /// measurement, and the floor exists only to absorb transient layout zeros. + private func reportContentGone() { + guard self.reportedHeight != 0 else { return } + self.reportedHeight = 0 + self.onSizeChange?(0) + } + + private static let minContentHeight: CGFloat = 1.0 + private static let heightEpsilon: CGFloat = 0.5 + @objc(setConfig:) public func setConfig(_ json: String?) { guard let json, let data = json.data(using: .utf8), @@ -45,6 +84,12 @@ public final class AirshipEmbeddedViewWrapper: UIView, RNAirshipEmbeddedViewBrid self.viewController.view.backgroundColor = UIColor.clear super.init(frame: frame) + self.viewModel.onHeightChange = { [weak self] height in + self?.reportContentHeight(height) + } + self.viewModel.onContentUnavailable = { [weak self] in + self?.reportContentGone() + } self.translatesAutoresizingMaskIntoConstraints = false self.addSubview(self.viewController.view) self.viewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] @@ -108,12 +153,37 @@ struct ReactAirshipEmbeddedView: View { var body: some View { if let embeddedID = viewModel.embeddedID { - AirshipEmbeddedView(embeddedID: embeddedID, + AirshipEmbeddedView( + embeddedID: embeddedID, embeddedSize: .init( - parentWidth: viewModel.width, - parentHeight: viewModel.height + parentWidth: viewModel.parentWidth, + parentHeight: viewModel.parentHeight ), selection: viewModel.selection + ) { [viewModel] in + // A measured zero is ambiguous during layout, so unavailability is signalled here. + // Zero sized on purpose: a greedy placeholder would feed back the height it cleared. + Color.clear + .frame(width: 0, height: 0) + .onAppear { viewModel.onContentUnavailable?() } + } + // Reported on every layout pass rather than through a preference, which + // misses the placeholder-to-content transition. The wrapper dedupes. + .background( + GeometryReader { [viewModel] proxy -> Color in + let height = proxy.size.height + DispatchQueue.main.async { + viewModel.onHeightChange?(height) + } + return Color.clear + } + ) + // While self sizing the host is taller than the visible box, so pin the + // content to the top instead of letting SwiftUI centre it out of view. + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: viewModel.sizeToContent ? .top : .center ) } } @@ -123,28 +193,43 @@ struct ReactAirshipEmbeddedView: View { @Published var embeddedID: String? @Published var size: CGSize? @Published var selection: AirshipEmbeddedSelection = .priority - - var height: CGFloat { - guard let height = self.size?.height, height > 0 else { - return Self.defaultScreenBounds.height + @Published var sizeToContent: Bool = false + + var onHeightChange: ((CGFloat) -> Void)? + + /// Called when the content goes away, so a stale height cannot size what + /// arrives next. + var onContentUnavailable: (() -> Void)? + + /// The host's bound on each axis, or nil while layout has not supplied one. + /// + /// Nil is the SDK's own signal for "no parent stated": it resolves a percent + /// placement against the SwiftUI proposal instead, which is the real box. A + /// stand-in value overrides that and sizes content against space the app + /// never gave the view. In React Native an unsized view is 0, not unknown. + var parentWidth: CGFloat? { Self.bound(self.size?.width) } + + /// While self sizing the window height stands in: measurement must not depend + /// on the box the host just set, or the two feed each other. + var parentHeight: CGFloat? { + if sizeToContent { + return Self.windowHeight() } - return height + return Self.bound(self.size?.height) } - var width: CGFloat { - guard let width = self.size?.width, width > 0 else { - return Self.defaultScreenBounds.width - } - return width + private static func bound(_ length: CGFloat?) -> CGFloat? { + guard let length, length > 0 else { return nil } + return length } @MainActor - private static var defaultScreenBounds: CGRect { - let window = UIApplication.shared.connectedScenes + static func windowHeight() -> CGFloat? { + UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } .flatMap { $0.windows } - .first { $0.isKeyWindow } ?? UIApplication.shared.delegate?.window ?? nil - return window?.screen.bounds ?? CGRect(x: 0, y: 0, width: 500, height: 500) + .first { $0.isKeyWindow }? + .screen.bounds.height } } diff --git a/ios/Bridge/RNAirshipBridge.h b/ios/Bridge/RNAirshipBridge.h index d79bb04e..5d99c438 100644 --- a/ios/Bridge/RNAirshipBridge.h +++ b/ios/Bridge/RNAirshipBridge.h @@ -130,6 +130,10 @@ NS_SWIFT_UI_ACTOR NS_SWIFT_UI_ACTOR @protocol RNAirshipEmbeddedViewBridge - (void)setConfig:(nullable NSString *)json; +- (void)setSizeToContent:(BOOL)sizeToContent; + +/// Called with the content height in points whenever it changes. +@property (nonatomic, copy, nullable) void (^onSizeChange)(CGFloat height); @end /// The Swift `AirshipReactNative` class. diff --git a/ios/RNAirshipEmbeddedView.mm b/ios/RNAirshipEmbeddedView.mm index 0ea6732c..96dfac71 100644 --- a/ios/RNAirshipEmbeddedView.mm +++ b/ios/RNAirshipEmbeddedView.mm @@ -41,6 +41,7 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & { const auto &newProps = *std::static_pointer_cast(props); self.config = [NSString stringWithUTF8String:newProps.config.c_str()]; + [self.wrapper setSizeToContent:newProps.sizeToContent]; [super updateProps:props oldProps:oldProps]; } @@ -56,6 +57,10 @@ - (instancetype) init { self = [self initWithFrame:CGRectZero]; if (self) { self.wrapper = [[RNAirshipEmbeddedViewBridgeClass() alloc] initWithFrame:self.bounds]; + __weak RNAirshipEmbeddedView *weakSelf = self; + self.wrapper.onSizeChange = ^(CGFloat height) { + [weakSelf emitSizeChange:height]; + }; [self addSubview:self.wrapper]; } return self; @@ -69,6 +74,16 @@ - (void)didMoveToWindow { [super didMoveToWindow]; } +- (void)emitSizeChange:(CGFloat)height { +#ifdef RCT_NEW_ARCH_ENABLED + if (!_eventEmitter) { + return; + } + std::static_pointer_cast(_eventEmitter) + ->onSizeChange({.height = static_cast(height)}); +#endif +} + - (void)setConfig:(NSString *)config { _config = config; __weak RNAirshipEmbeddedView *weakSelf = self; From dcba535169dd609c8c6817784e3cf99d0c288e91 Mon Sep 17 00:00:00 2001 From: Ognjen Ristanovic Date: Thu, 15 Oct 2026 10:01:55 +0200 Subject: [PATCH 2/2] Report the embedded height only while sizing to content The measurement runs on every layout pass, so a fixed-size view was emitting onSizeChange too. The spec documents the event as emitted only while sizing to content, and Android only listens then. Co-Authored-By: Claude Opus 5 --- ios/AirshipEmbeddedViewWrapper.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ios/AirshipEmbeddedViewWrapper.swift b/ios/AirshipEmbeddedViewWrapper.swift index 839927ed..152ae5a5 100644 --- a/ios/AirshipEmbeddedViewWrapper.swift +++ b/ios/AirshipEmbeddedViewWrapper.swift @@ -33,6 +33,9 @@ public final class AirshipEmbeddedViewWrapper: UIView, RNAirshipEmbeddedViewBrid } private func reportContentHeight(_ height: CGFloat) { + // The measurement runs on every layout pass; only a self-sizing view reports it. + guard self.viewModel.sizeToContent else { return } + // A height at or below the floor means the content could not resolve one, not // that it is empty; hold the last real value rather than collapsing the view. guard height > Self.minContentHeight, @@ -48,6 +51,7 @@ public final class AirshipEmbeddedViewWrapper: UIView, RNAirshipEmbeddedViewBrid /// Zero bypasses the measurement floor: it is an availability signal, not a /// measurement, and the floor exists only to absorb transient layout zeros. private func reportContentGone() { + guard self.viewModel.sizeToContent else { return } guard self.reportedHeight != 0 else { return } self.reportedHeight = 0 self.onSizeChange?(0)