Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 26 additions & 5 deletions ios/LiquidGlassContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +19 to +23
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
}
}
}

Expand Down
94 changes: 76 additions & 18 deletions ios/LiquidGlassView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand All @@ -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
}

Expand All @@ -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
Expand Down
Loading