From cf0b5d798ab2ca764a42ddd1471eec6394626337 Mon Sep 17 00:00:00 2001 From: Satyajit Sahoo Date: Tue, 18 Aug 2026 15:50:11 +0200 Subject: [PATCH] fix: keep the effect only when view belongs to a window --- ios/LiquidGlassContainerView.swift | 31 ++++++++-- ios/LiquidGlassView.swift | 94 ++++++++++++++++++++++++------ 2 files changed, 102 insertions(+), 23 deletions(-) diff --git a/ios/LiquidGlassContainerView.swift b/ios/LiquidGlassContainerView.swift index 4c4f799..8e57df1 100644 --- a/ios/LiquidGlassContainerView.swift +++ b/ios/LiquidGlassContainerView.swift @@ -2,22 +2,43 @@ import UIKit #if compiler(>=6.2) -@available(iOS 26.0, tvOS 26.0, *) @objc public class LiquidGlassConatinerViewImpl: UIVisualEffectView { + private var needsInitialSetup: Bool = true + @objc public var spacing: CGFloat = 0 { didSet { - setupView() + if spacing != oldValue, effect != nil { + setupView() + } } } - + public override func layoutSubviews() { + super.layoutSubviews() + + guard needsInitialSetup else { + return + } + + needsInitialSetup = false setupView() } - + private func setupView() { + guard #available(iOS 26.0, tvOS 26.0, *) else { + return + } + + guard NSClassFromString("UIGlassContainerEffect") != nil else { + return + } + let effect = UIGlassContainerEffect() effect.spacing = spacing - self.effect = effect + + UIView.performWithoutAnimation { + self.effect = effect + } } } diff --git a/ios/LiquidGlassView.swift b/ios/LiquidGlassView.swift index 902fbe2..6ac3046 100644 --- a/ios/LiquidGlassView.swift +++ b/ios/LiquidGlassView.swift @@ -23,9 +23,17 @@ import UIKit #if compiler(>=6.2) -@available(iOS 26.0, tvOS 26.0, *) @objc public class LiquidGlassViewImpl: UIVisualEffectView { - private var hasAppliedEffect: Bool = false + private static let isGlassEffectAvailable: Bool = { + guard let glassEffectClass = NSClassFromString("UIGlassEffect") as? NSObject.Type else { + return false + } + + return glassEffectClass.responds(to: #selector(UIBlurEffect.init(style:))) + }() + + private var hasConfiguredEffect: Bool = false + private var hasLaidOutInCurrentWindow: Bool = false @objc public var effectTintColor: UIColor? @objc public var interactive: Bool = false @@ -34,27 +42,64 @@ import UIKit @objc public var animationDuration: CGFloat = 0 @objc public var hasAnimationDuration: Bool = false + public override func willMove(toWindow newWindow: UIWindow?) { + super.willMove(toWindow: newWindow) + + // We keep the effect only when the view belongs to a window + // When interactive is enabled, UIKit may register pointer-interaction + // or subtree-monitoring state to the view's UIWindow + // Any stale reference to the detached view after unmount may crash + // So we cleanup the effect when the window changes while the old window is available + if let currentWindow = window, currentWindow !== newWindow { + teardownEffect() + } + } + + public override func didMoveToWindow() { + super.didMoveToWindow() + + guard window != nil else { + return + } + + // The view may keep the same bounds when it returns to a window, which + // means UIKit does not otherwise need to run layout again. + setNeedsLayout() + } + public override func layoutSubviews() { - if hasAppliedEffect { return } + super.layoutSubviews() + + guard window != nil, !hasLaidOutInCurrentWindow else { + return + } + + hasLaidOutInCurrentWindow = true + // Assigning an empty effect forces UIKit to discard stale glass state + // before a new effect is created for the current window. + setEffectWithoutAnimation(UIVisualEffect()) setupView() - } + if !hasConfiguredEffect { + setEffectWithoutAnimation(nil) + } + } @objc public func setupView() { - guard #available(iOS 26.0, tvOS 26.0, *) else { + // We shouldn't add the effect before the view's first layout in a window + // React Native may call setupView() through updateProps before mount + // So we need to explicitly check that layout has completed + guard hasLaidOutInCurrentWindow else { return } - // Runtime check to ensure UIGlassEffect is available - // This handles cases where early iOS 26 beta releases may not have this API - guard let glassEffectClass = NSClassFromString("UIGlassEffect") as? NSObject.Type else { + guard #available(iOS 26.0, tvOS 26.0, *) else { return } - // Verify that the effectWithStyle: selector is available - // This provides an additional safety check for early beta versions - guard glassEffectClass.responds(to: Selector(("effectWithStyle:"))) else { + // Early iOS 26 beta releases may not have a usable UIGlassEffect API. + guard Self.isGlassEffectAvailable else { return } @@ -73,26 +118,27 @@ import UIKit // UIGlassEffect can reconfigure the internal contentView in a way that // disables user interaction when no subviews are present at the time the // effect is applied. In React Native (Fabric), child component views may - // be mounted into contentView *after* layoutSubviews triggers setupView(), + // be mounted into contentView *after* setupView() has applied the effect, // leaving contentView with userInteractionEnabled == false for the // lifetime of this view. Force it back on so touches always reach children. self.contentView.isUserInteractionEnabled = true } @objc public func resetInteractiveEffect() { - guard hasAppliedEffect else { return } + guard hasConfiguredEffect else { return } // Changing isInteractive on an existing instance doesn't do anything // So we need to set it to nil and reapply it with the new interactive value - self.effect = nil - hasAppliedEffect = false + setEffectWithoutAnimation(nil) + hasConfiguredEffect = false setupView() } + @available(iOS 26.0, tvOS 26.0, *) private func applyEffect(_ effect: UIVisualEffect?) { - if !hasAppliedEffect || !animated { - self.effect = effect - hasAppliedEffect = true + if !hasConfiguredEffect || !animated { + setEffectWithoutAnimation(effect) + hasConfiguredEffect = true return } @@ -106,6 +152,18 @@ import UIKit } } } + + private func teardownEffect() { + setEffectWithoutAnimation(nil) + hasConfiguredEffect = false + hasLaidOutInCurrentWindow = false + } + + private func setEffectWithoutAnimation(_ effect: UIVisualEffect?) { + UIView.performWithoutAnimation { + self.effect = effect + } + } } #else