Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 100 additions & 2 deletions ios/AirshipEmbeddedViewWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,49 @@ 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) {
Comment thread
oristanovic marked this conversation as resolved.
// 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,
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.viewModel.sizeToContent else { return }
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),
Expand Down Expand Up @@ -45,6 +88,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]
Expand Down Expand Up @@ -108,12 +157,37 @@ struct ReactAirshipEmbeddedView: View {
var body: some View {

if let embeddedID = viewModel.embeddedID {
AirshipEmbeddedView(embeddedID: embeddedID,
AirshipEmbeddedView(
embeddedID: embeddedID,
embeddedSize: .init(
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
)
}
}
Expand All @@ -123,6 +197,13 @@ struct ReactAirshipEmbeddedView: View {
@Published var embeddedID: String?
@Published var size: CGSize?
@Published var selection: AirshipEmbeddedSelection = .priority
@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.
///
Expand All @@ -131,12 +212,29 @@ struct ReactAirshipEmbeddedView: View {
/// 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) }
var parentHeight: CGFloat? { Self.bound(self.size?.height) }

/// 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 Self.bound(self.size?.height)
}

private static func bound(_ length: CGFloat?) -> CGFloat? {
guard let length, length > 0 else { return nil }
return length
}

@MainActor
static func windowHeight() -> CGFloat? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first { $0.isKeyWindow }?
.screen.bounds.height
}
}

}
Expand Down
4 changes: 4 additions & 0 deletions ios/Bridge/RNAirshipBridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ NS_SWIFT_UI_ACTOR
NS_SWIFT_UI_ACTOR
@protocol RNAirshipEmbeddedViewBridge <NSObject>
- (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.
Expand Down
15 changes: 15 additions & 0 deletions ios/RNAirshipEmbeddedView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &
{
const auto &newProps = *std::static_pointer_cast<const RNAirshipEmbeddedViewProps>(props);
self.config = [NSString stringWithUTF8String:newProps.config.c_str()];
[self.wrapper setSizeToContent:newProps.sizeToContent];

[super updateProps:props oldProps:oldProps];
}
Expand All @@ -59,6 +60,10 @@ - (instancetype) init {
// Unclipped, a UIView paints that overflow over the app's own views.
self.clipsToBounds = YES;
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;
Expand All @@ -72,6 +77,16 @@ - (void)didMoveToWindow {
[super didMoveToWindow];
}

- (void)emitSizeChange:(CGFloat)height {
#ifdef RCT_NEW_ARCH_ENABLED
if (!_eventEmitter) {
return;
}
std::static_pointer_cast<const RNAirshipEmbeddedViewEventEmitter>(_eventEmitter)
->onSizeChange({.height = static_cast<double>(height)});
#endif
}

- (void)setConfig:(NSString *)config {
_config = config;
__weak RNAirshipEmbeddedView *weakSelf = self;
Expand Down
Loading