fix: install glass effect only once the view is visible - #49
Open
vilindberg wants to merge 1 commit into
Open
vilindberg wants to merge 1 commit into
vilindberg wants to merge 1 commit into
Conversation
UIKit silently skips a UIGlassEffect created while the view is effectively transparent, and never retries it. A LiquidGlassView that fades in from opacity 0 - directly or through any ancestor - therefore renders as a plain view for the rest of its life, even after it is fully opaque. Before creating the effect, walk the superview chain multiplying alpha. If the composited opacity is at or below 0.02, or any ancestor is hidden, defer and start a CADisplayLink that re-checks each tick and sets the effect up once the view becomes visible. The guard sits in setupView() rather than layoutSubviews() so that a style change on a view that is still transparent is covered too. The link is held through a weak proxy so it never retains the view, runs at 30Hz rather than the display's full rate because a view that never becomes visible would otherwise keep waking the main thread at 120Hz, and is invalidated once the effect lands, on teardown and in deinit. Only the install is deferred - an existing effect survives the view returning to zero.
vilindberg
marked this pull request as ready for review
September 23, 2026 10:41
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A
LiquidGlassViewthat fades in fromopacity: 0- directly, or through any ancestor - renders as a plain view for the rest of its life, even once it is fully opaque.UIKit silently refuses to build a
UIGlassEffectfor a view whose composited alpha is effectively zero, and never retries. Since the effect is created on the first layout pass, a view laid out while its fade is still at 0 misses its only chance to get one.This is easy to hit: any entrance animation, any screen that mounts behind a cross-fade, any
Animated.Viewwrapper starting at 0.Before / after
Same example app, same JS bundle, same simulator - only the native change differs. The top row is a control that mounts at
opacity: 1; the rows below fade0 → 1on mount.Before - unpatched
before.mp4
After - patched
after.mp4
In the "before" column the labels still render - the views are present and laid out correctly. Only the glass material is missing.
Reproduction
The card ends at
opacity: 1with no glass material. Remove theAnimated.Viewwrapper and it renders correctly.The fix
Before creating the effect, walk the superview chain multiplying
alpha. If the composited opacity is at or below0.02, or any ancestor is hidden, defer and start aCADisplayLinkthat re-checks each tick and sets the view up once it becomes visible.The guard sits in
setupView()rather thanlayoutSubviews(), sincesetupView()is also reached from prop updates - that way a style change on a view that is still transparent is covered by the same path.The link is held through a weak proxy so it never retains the view, and is invalidated once the effect lands, on
teardownEffect(window change) and indeinit. It runs at 30Hz rather than the display's full rate: a view that never becomes visible would otherwise keep waking the main thread at 120Hz on a ProMotion screen for nothing, and 30Hz is imperceptible inside a fade.Only the install is deferred. An existing effect survives the view returning to zero and coming back, so nothing needs to keep watching - verified by looping a mounted view
1 → 0 → 1:That is why the link is stopped as soon as the effect lands.
Test plan
yarn typecheck- cleanyarn lint- cleaneffectflipsnone → regularwhile it is still transparent.opacity: 0and returns.CONTRIBUTING.mddocumentsyarn test, but there is notestscript inpackage.jsonand no test files in the repo, so there was nothing to extend. Happy to add a harness if you'd like one.Notes
0.02threshold is a small epsilon rather than a tuned value;> 0also works, but a little slack avoids re-running setup for alphas that still will not render.