From 9329595060878de532edce387e4ed32b7b475327 Mon Sep 17 00:00:00 2001 From: Viktor Lindberg Date: Wed, 23 Sep 2026 12:33:49 +0200 Subject: [PATCH] fix: install glass effect only once the view is visible 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. --- ios/LiquidGlassView.swift | 82 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/ios/LiquidGlassView.swift b/ios/LiquidGlassView.swift index 6ac3046..a943e5f 100644 --- a/ios/LiquidGlassView.swift +++ b/ios/LiquidGlassView.swift @@ -34,6 +34,10 @@ import UIKit private var hasConfiguredEffect: Bool = false private var hasLaidOutInCurrentWindow: Bool = false + private var visibilityLink: CADisplayLink? + + // Composited alpha at or below which UIKit will not build the glass effect. + private static let minimumRenderableAlpha: CGFloat = 0.02 @objc public var effectTintColor: UIColor? @objc public var interactive: Bool = false @@ -104,10 +108,21 @@ import UIKit } guard let preferredStyle = style.converted else { + stopWaitingForVisibility() applyEffect(nil) return } + // UIKit silently skips a glass effect created while the view is effectively + // transparent, and never retries it, so a view that fades in from 0 would + // keep rendering as a plain view. Wait until it is actually visible. + guard isEffectRenderable else { + waitForVisibility() + return + } + + stopWaitingForVisibility() + let glassEffect = UIGlassEffect(style: preferredStyle) glassEffect.isInteractive = interactive @@ -154,11 +169,65 @@ import UIKit } private func teardownEffect() { + stopWaitingForVisibility() setEffectWithoutAnimation(nil) hasConfiguredEffect = false hasLaidOutInCurrentWindow = false } + // The effect is rendered against the composited result of the whole superview + // chain, so an opaque view inside a parent that is fading in is skipped too. + private var isEffectRenderable: Bool { + var opacity: CGFloat = 1 + var view: UIView? = self + + while let current = view { + if current.isHidden { + return false + } + + opacity *= current.alpha + view = current.superview + } + + return opacity > Self.minimumRenderableAlpha + } + + private func waitForVisibility() { + guard visibilityLink == nil else { + return + } + + let link = CADisplayLink( + target: WeakDisplayLinkProxy(self), + selector: #selector(WeakDisplayLinkProxy.tick) + ) + + // A view that never becomes visible keeps this link alive, so do not ask for + // the display's full rate: 30Hz is imperceptible within a fade and a quarter + // of the wake-ups on a 120Hz screen. + link.preferredFrameRateRange = CAFrameRateRange(minimum: 15, maximum: 60, preferred: 30) + link.add(to: .main, forMode: .common) + visibilityLink = link + } + + fileprivate func onVisibilityTick() { + guard isEffectRenderable else { + return + } + + setupView() + } + + private func stopWaitingForVisibility() { + visibilityLink?.invalidate() + visibilityLink = nil + } + + deinit { + visibilityLink?.invalidate() + } + private func setEffectWithoutAnimation(_ effect: UIVisualEffect?) { UIView.performWithoutAnimation { self.effect = effect @@ -166,6 +235,19 @@ import UIKit } } +// CADisplayLink retains its target, which would keep the view alive forever. +private final class WeakDisplayLinkProxy: NSObject { + private weak var view: LiquidGlassViewImpl? + + init(_ view: LiquidGlassViewImpl) { + self.view = view + } + + @objc func tick() { + view?.onVisibilityTick() + } +} + #else @objc public class LiquidGlassViewImpl: UIView {}