Skip to content

fix: replace GeometryReader height cap with HeightCapLayout to eliminate flicker#24595

Closed
devin-ai-integration[bot] wants to merge 3 commits into
mainfrom
devin/1775764553-fix-image-height-oscillation
Closed

fix: replace GeometryReader height cap with HeightCapLayout to eliminate flicker#24595
devin-ai-integration[bot] wants to merge 3 commits into
mainfrom
devin/1775764553-fix-image-height-oscillation

Conversation

@devin-ai-integration
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot commented Apr 9, 2026

Summary

Replaces the GeometryReader + PreferenceKey height-capping pattern in InlineImageEmbedView (introduced in #24589) with a custom HeightCapLayout using the Layout protocol.

The previous approach required a @State round-trip: first render starts with imageIntrinsicHeight = 0, so the image renders at full natural height, then GeometryReader measures and triggers a second render with the cap — visible as a single-frame vertical flicker for tall images. It also had an oscillation bug (> vs >= boundary).

HeightCapLayout resolves both issues in a single layout pass:

  1. Asks the child for its ideal size via sizeThatFits
  2. If the child's height exceeds the 300pt cap, re-measures with the capped height so the child can adjust its width (e.g. a resizable image with .fit narrows to preserve aspect ratio)
  3. Places the child within the capped bounds

No @State, no PreferenceKey, no vertical flicker, no horizontal settling, no oscillation.

This follows the same pattern as WidthCapLayout in InlineSurfaceRouter.swift (also from #24589).

Review & Testing Checklist for Human

⚠️ CI skips macOS builds — must be verified locally in Xcode.

  • Verify tall images render at 300pt without any flicker — send or find an inline image taller than 300pt and confirm it renders stably clipped at 300pt on first appearance, with no visible flash of the full-height image and no horizontal width adjustment
  • Verify short images render at natural height — images under 300pt should render at their intrinsic size without any empty space below them
  • Verify placeholder and loading states — the 120pt skeleton placeholder and the loading spinner overlay should still render correctly within the layout
  • Verify aspect ratio is preserved for capped images — tall images should appear correctly proportioned (not stretched or squished) at the capped height

Notes

  • HeightCapLayout is private to InlineImageEmbedView.swift. The two-pass measurement in sizeThatFits is needed because when height is capped, the width from the uncapped measurement doesn't account for aspect ratio at the new height. Re-measuring with the capped height lets the child (.aspectRatio(contentMode: .fit)) report the correct narrower width.
  • .clipped() is still applied after the layout as a safety net, though the image should already be sized correctly by the proposal.
  • The guard uses > (not >=): images exactly at 300pt don't need capping, and since there's no state feedback loop, there's no oscillation risk.

Link to Devin session: https://app.devin.ai/sessions/bca2b6aae31f43afb41df0dae571ae1d
Requested by: @Jasonnnz


Open with Devin

The > operator causes an infinite feedback loop for images exactly 300pt
tall: GeometryReader reports 300 → 300 > 300 is false → frame(height: nil)
→ image expands to natural height → GeometryReader reports >300 →
frame(height: 300) → GeometryReader reports 300 → loop repeats.

Using >= stabilizes the loop: once capped at 300, 300 >= 300 stays true,
so the frame stays at 300 and GeometryReader reports no change.

Co-Authored-By: Jason Zhou <jasonczhou3@gmail.com>
@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

Copy link
Copy Markdown
Contributor Author

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Copy link
Copy Markdown
Contributor Author

@devin-ai-integration devin-ai-integration Bot Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Pre-existing accessibility gap: onTapGesture without VoiceOver traits

The .onTapGesture at lines 87-93 opens the image URL in the browser but lacks .accessibilityAddTraits(.isButton), .accessibilityLabel(), and .accessibilityAction {} required by the project's accessibility rules in clients/AGENTS.md. This is a pre-existing issue not introduced by this PR (the tap gesture code is unchanged), but worth noting since the view body was restructured. A follow-up could add proper accessibility traits so VoiceOver users can activate the image link.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed this is a valid accessibility gap, but as noted it's entirely pre-existing and unrelated to this one-character fix. Worth tracking for a future accessibility pass across inline media views.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — this is pre-existing and out of scope for this PR. The .onTapGesture should have .accessibilityAddTraits(.isButton) and an .accessibilityLabel for VoiceOver. Good candidate for a follow-up accessibility pass.

…ate flicker

The GeometryReader + PreferenceKey pattern requires a state round-trip:
first render has imageIntrinsicHeight=0, so the image renders at full
natural height, then GeometryReader measures and triggers a second
render with the cap applied — visible as a single-frame flicker.

HeightCapLayout uses the Layout protocol to measure the child once,
cap the reported height, and place within capped bounds — all in a
single layout pass. No @State, no PreferenceKey, no flicker.

This also removes the >= vs > oscillation bug entirely since there
is no feedback loop to oscillate.

Co-Authored-By: Jason Zhou <jasonczhou3@gmail.com>
@devin-ai-integration devin-ai-integration Bot changed the title fix: use >= comparison in image height cap to prevent layout oscillation fix: replace GeometryReader height cap with HeightCapLayout to eliminate flicker Apr 9, 2026
When HeightCapLayout caps a tall image, the width from the uncapped
measurement doesn't account for aspect ratio at the capped height.
A resizable image with .fit needs to narrow when height is reduced.

Now sizeThatFits re-measures the child with the capped height as the
proposal, so the child can report the correct width for its aspect
ratio in the same layout pass — no horizontal settling.

Co-Authored-By: Jason Zhou <jasonczhou3@gmail.com>
@Jasonnnz Jasonnnz closed this Apr 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant