diff --git a/clients/shared/Features/Chat/MediaEmbeds/InlineImageEmbedView.swift b/clients/shared/Features/Chat/MediaEmbeds/InlineImageEmbedView.swift index 77d4845f1fb..c5c80e9ca59 100644 --- a/clients/shared/Features/Chat/MediaEmbeds/InlineImageEmbedView.swift +++ b/clients/shared/Features/Chat/MediaEmbeds/InlineImageEmbedView.swift @@ -15,10 +15,34 @@ import AppKit /// image at once. /// /// Tapping the image opens the URL in the user's default browser. -private struct ImageHeightKey: PreferenceKey { - static var defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = nextValue() + +/// Caps content height at a maximum using the Layout protocol (O(1)). +/// Unlike `.frame(maxHeight:)` which creates `_FlexFrameLayout` with its +/// O(n × depth) alignment cascade, this measures the child, caps the +/// reported height, and re-measures with the capped height so the child +/// can adjust its width (e.g. to preserve aspect ratio). +/// Unlike GeometryReader + PreferenceKey, this resolves in a single layout +/// pass — no @State round-trip, so no first-render flicker. +private struct HeightCapLayout: Layout { + let cap: CGFloat + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + guard let child = subviews.first else { return .zero } + let childSize = child.sizeThatFits(proposal) + guard childSize.height > cap else { return childSize } + // Re-measure with capped height so the child can adjust its width + // (e.g. a resizable image with .fit will narrow to preserve aspect ratio). + let cappedSize = child.sizeThatFits(ProposedViewSize(width: proposal.width, height: cap)) + return CGSize(width: cappedSize.width, height: min(cap, cappedSize.height)) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + guard let child = subviews.first else { return } + child.place( + at: bounds.origin, + anchor: .topLeading, + proposal: ProposedViewSize(width: bounds.width, height: bounds.height) + ) } } @@ -32,38 +56,31 @@ public struct InlineImageEmbedView: View { /// Flipped to `true` by `onAppear`; prevents eager network fetches /// for images that are off-screen in long chat histories. @State private var isVisible = false - /// Tracks intrinsic image height for capping without `_FlexFrameLayout`. - @State private var imageIntrinsicHeight: CGFloat = 0 public var body: some View { - Group { - if isVisible { - AsyncImage(url: url) { phase in - switch phase { - case .empty: - placeholderSkeleton - .overlay(ProgressView()) - case .success(let image): - image - .resizable() - .aspectRatio(contentMode: .fit) - case .failure: - EmptyView() - @unknown default: - EmptyView() + HeightCapLayout(cap: 300) { + Group { + if isVisible { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + placeholderSkeleton + .overlay(ProgressView()) + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fit) + case .failure: + EmptyView() + @unknown default: + EmptyView() + } } + } else { + placeholderSkeleton } - } else { - placeholderSkeleton } } - .background( - GeometryReader { geo in - Color.clear.preference(key: ImageHeightKey.self, value: geo.size.height) - } - ) - .onPreferenceChange(ImageHeightKey.self) { imageIntrinsicHeight = $0 } - .frame(height: imageIntrinsicHeight > 300 ? 300 : nil) .clipped() .clipShape(RoundedRectangle(cornerRadius: 8)) .onAppear { isVisible = true }