Skip to content

Commit 0ebd60d

Browse files
committed
fix(ios): correct liquid-glass accessibility, tint, corners and child mount
Addresses the PR 2 batch from the July 2026 audit. - IOS-03: the iOS 26 UIGlassEffect path ignored Reduce Transparency, leaving reducedTransparencyFallbackColor dead. updateEffect now routes to the fallback when Reduce Transparency is on, and a reduceTransparencyStatusDidChange observer re-evaluates when the user toggles it while mounted. - IOS-13: multiply the tint's own alpha by glassOpacity instead of replacing it, so a semi-transparent glassTintColor stays semi-transparent (the zero-alpha "no tint" guard for issue #113 is kept). - IOS-12 / IOS-02: apply all four corner radii via cornerConfiguration, and do it unconditionally so animating a radius to 0 or recycling a rounded view into an unrounded mount squares the corners instead of keeping the stale radius. Added setBorderRadii(topLeft:topRight:bottomLeft:bottomRight:). - IOS-05: implement ignoreSafeArea (false confines the glass to the safe area in layoutSubviews; safeAreaInsetsDidChange triggers relayout) instead of the empty didSet. - IOS-04: apply glassTintColor / reducedTransparencyFallbackColor even for an empty string so clearing a previously-set colour takes effect. - IOS-NEW-2: LiquidGlassContainer mounts children based on the runtime class (UIVisualEffectView -> contentView) rather than the OS version. Built with Xcode 26 it is a UIVisualEffectView even on iOS < 26, where adding subviews directly throws. Verified on an iOS 26 simulator (iPhone 17 Pro): the Liquid Glass screen renders rounded translucent glass (blue then purple tint at 80%), the glass container mounts its Clear/Regular children without crashing, and tint changes apply live; no redbox in Metro.
1 parent 2a986b7 commit 0ebd60d

3 files changed

Lines changed: 110 additions & 66 deletions

File tree

ios/ReactNativeLiquidGlassContainer.mm

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,13 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask
7272
const auto &props = *std::static_pointer_cast<ReactNativeLiquidGlassContainerProps const>(_props);
7373
const auto borderMetrics = props.resolveBorderMetrics(_layoutMetrics);
7474

75-
// Use topLeft.horizontal same as React Native RCTViewComponentView implementation
75+
// Use topLeft.horizontal same as React Native RCTViewComponentView
76+
// implementation. Applied unconditionally so a radius of 0 clears any stale
77+
// rounding rather than leaving it from a previous mount.
7678
CGFloat radius = borderMetrics.borderRadii.topLeft.horizontal;
7779

78-
if (radius > 0) {
79-
_containerView.layer.cornerRadius = radius;
80-
_containerView.layer.masksToBounds = YES;
81-
}
80+
_containerView.layer.cornerRadius = radius;
81+
_containerView.layer.masksToBounds = radius > 0;
8282
}
8383
}
8484

@@ -101,16 +101,19 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics oldLayoutMetric
101101

102102
- (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView index:(NSInteger)index
103103
{
104-
if (@available(iOS 26.0, *)) {
105-
// On iOS 26+, add children to the contentView if available
106-
if ([_containerView isKindOfClass:[UIVisualEffectView class]]) {
107-
UIVisualEffectView *effectView = (UIVisualEffectView *)_containerView;
108-
[effectView.contentView insertSubview:childComponentView atIndex:index];
109-
return;
110-
}
104+
// Route children into contentView whenever the container actually is a
105+
// UIVisualEffectView, decided by the runtime class rather than the OS
106+
// version. Built with Xcode 26, the Swift LiquidGlassContainer is a
107+
// UIVisualEffectView even on iOS < 26 (its effect just stays nil there), and
108+
// adding subviews straight to a UIVisualEffectView is forbidden by UIKit and
109+
// throws — contentView is always the correct target for one.
110+
if ([_containerView isKindOfClass:[UIVisualEffectView class]]) {
111+
UIVisualEffectView *effectView = (UIVisualEffectView *)_containerView;
112+
[effectView.contentView insertSubview:childComponentView atIndex:index];
113+
return;
111114
}
112115

113-
// Fallback: add directly to container view
116+
// Fallback: pre-Xcode-26 builds where LiquidGlassContainer is a plain UIView.
114117
[_containerView insertSubview:childComponentView atIndex:index];
115118
}
116119

ios/ReactNativeLiquidGlassView.mm

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -190,13 +190,13 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &
190190
const auto &oldViewProps = *std::static_pointer_cast<ReactNativeLiquidGlassViewProps const>(_props);
191191
const auto &newViewProps = *std::static_pointer_cast<ReactNativeLiquidGlassViewProps const>(props);
192192

193-
// Update glassTintColor if it has changed
193+
// Update glassTintColor if it has changed. Apply even for an empty string:
194+
// colorFromString maps "" to clear, and the view treats a zero-alpha tint as
195+
// "no tint", so clearing a previously-set tint actually takes effect.
194196
if (oldViewProps.glassTintColor != newViewProps.glassTintColor) {
195-
if (!newViewProps.glassTintColor.empty()) {
196-
NSString *glassTintColorString = [[NSString alloc] initWithUTF8String:newViewProps.glassTintColor.c_str()];
197-
UIColor *newGlassTintColor = [ReactNativeLiquidGlassView colorFromString:glassTintColorString];
198-
[ReactNativeLiquidGlassViewHelper updateLiquidGlassView:_liquidGlassView withGlassTintColor:newGlassTintColor];
199-
}
197+
NSString *glassTintColorString = [[NSString alloc] initWithUTF8String:newViewProps.glassTintColor.c_str()];
198+
UIColor *newGlassTintColor = [ReactNativeLiquidGlassView colorFromString:glassTintColorString];
199+
[ReactNativeLiquidGlassViewHelper updateLiquidGlassView:_liquidGlassView withGlassTintColor:newGlassTintColor];
200200
}
201201

202202
// Update glassOpacity if it has changed
@@ -223,13 +223,13 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &
223223
[ReactNativeLiquidGlassViewHelper updateLiquidGlassView:_liquidGlassView withIgnoringSafeArea:newViewProps.ignoreSafeArea];
224224
}
225225

226-
// Update reducedTransparencyFallbackColor if it has changed
226+
// Update reducedTransparencyFallbackColor if it has changed. Apply even when
227+
// empty so clearing the prop resets to the parsed default rather than
228+
// stranding the previous colour.
227229
if (oldViewProps.reducedTransparencyFallbackColor != newViewProps.reducedTransparencyFallbackColor) {
228-
if (!newViewProps.reducedTransparencyFallbackColor.empty()) {
229-
NSString *fallbackColorString = [[NSString alloc] initWithUTF8String:newViewProps.reducedTransparencyFallbackColor.c_str()];
230-
UIColor *fallbackColor = [ReactNativeLiquidGlassView colorFromString:fallbackColorString];
231-
[ReactNativeLiquidGlassViewHelper updateLiquidGlassView:_liquidGlassView withReducedTransparencyFallbackColor:fallbackColor];
232-
}
230+
NSString *fallbackColorString = [[NSString alloc] initWithUTF8String:newViewProps.reducedTransparencyFallbackColor.c_str()];
231+
UIColor *fallbackColor = [ReactNativeLiquidGlassView colorFromString:fallbackColorString];
232+
[ReactNativeLiquidGlassViewHelper updateLiquidGlassView:_liquidGlassView withReducedTransparencyFallbackColor:fallbackColor];
233233
}
234234

235235
// Store the new props
@@ -274,17 +274,18 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask
274274
{
275275
[super finalizeUpdates:updateMask];
276276

277-
// Apply border radius from layout metrics to the inner glass view (Callstack pattern)
277+
// Apply per-corner border radius from layout metrics to the inner glass view.
278+
// Applied unconditionally (including 0) so animating a radius down to 0 or
279+
// recycling a rounded view into an unrounded mount actually squares the
280+
// corners instead of keeping the stale radius.
278281
if (@available(iOS 26.0, *)) {
279282
const auto &props = *std::static_pointer_cast<ReactNativeLiquidGlassViewProps const>(_props);
280283
const auto borderMetrics = props.resolveBorderMetrics(_layoutMetrics);
281284

282-
// Use topLeft.horizontal same as React Native RCTViewComponentView implementation
283-
CGFloat radius = borderMetrics.borderRadii.topLeft.horizontal;
284-
285-
if (radius > 0) {
286-
[_liquidGlassView setBorderRadius:radius];
287-
}
285+
[_liquidGlassView setBorderRadiiWithTopLeft:borderMetrics.borderRadii.topLeft.horizontal
286+
topRight:borderMetrics.borderRadii.topRight.horizontal
287+
bottomLeft:borderMetrics.borderRadii.bottomLeft.horizontal
288+
bottomRight:borderMetrics.borderRadii.bottomRight.horizontal];
288289
}
289290
}
290291

ios/Views/LiquidGlassContainerView.swift

Lines changed: 74 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -37,31 +37,37 @@ import UIKit
3737
}
3838
}
3939

40+
// false confines the glass to the safe area; true (the JS-side default) lets
41+
// it fill the whole view. Applied in layoutSubviews.
4042
@objc public var ignoreSafeArea: Bool = false {
4143
didSet {
42-
// Not used in UIKit approach
44+
if ignoreSafeArea != oldValue {
45+
setNeedsLayout()
46+
}
4347
}
4448
}
4549

46-
// Border radius storage for React Native's style system
50+
// Per-corner radii in points. The iOS 26 path applies all four via
51+
// cornerConfiguration; the pre-26 / reduced-transparency fallback can only
52+
// render a uniform CALayer cornerRadius, so it uses topLeftRadius.
4753
private var topLeftRadius: CGFloat = 0
4854
private var topRightRadius: CGFloat = 0
4955
private var bottomLeftRadius: CGFloat = 0
5056
private var bottomRightRadius: CGFloat = 0
51-
private var allBorderRadius: CGFloat = 0
5257

5358
private var foregroundObserver: NSObjectProtocol?
59+
private var reduceTransparencyObserver: NSObjectProtocol?
5460

5561
public override init(frame: CGRect) {
5662
super.init(frame: frame)
5763
setupView()
58-
registerForegroundObserver()
64+
registerObservers()
5965
}
6066

6167
required init?(coder: NSCoder) {
6268
super.init(coder: coder)
6369
setupView()
64-
registerForegroundObserver()
70+
registerObservers()
6571
}
6672

6773
// The glass/blur effect is assigned to the UIVisualEffectView once. UIKit
@@ -76,65 +82,96 @@ import UIKit
7682
}
7783
}
7884

79-
private func registerForegroundObserver() {
85+
private func registerObservers() {
8086
foregroundObserver = NotificationCenter.default.addObserver(
8187
forName: UIApplication.willEnterForegroundNotification,
8288
object: nil,
8389
queue: .main
8490
) { [weak self] _ in
8591
self?.updateEffect()
8692
}
93+
94+
// Re-evaluate the glass/fallback state when the user toggles Reduce
95+
// Transparency while the view is mounted, so the accessibility fallback
96+
// takes effect immediately instead of only on the next prop change.
97+
reduceTransparencyObserver = NotificationCenter.default.addObserver(
98+
forName: UIAccessibility.reduceTransparencyStatusDidChangeNotification,
99+
object: nil,
100+
queue: .main
101+
) { [weak self] _ in
102+
self?.updateEffect()
103+
}
87104
}
88105

89106
deinit {
90107
if let foregroundObserver {
91108
NotificationCenter.default.removeObserver(foregroundObserver)
92109
}
110+
if let reduceTransparencyObserver {
111+
NotificationCenter.default.removeObserver(reduceTransparencyObserver)
112+
}
93113
}
94114

95115
private func setupView() {
96116
let effectView = UIVisualEffectView()
97117
effectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
98118
effectView.frame = bounds
99-
119+
100120
// Don't clip bounds to allow interactive glass animations to be visible
101121
effectView.clipsToBounds = false
102-
122+
103123
addSubview(effectView)
104124
self.glassEffectView = effectView
105-
125+
106126
updateEffect()
107127
}
108128

109129
// MARK: - Border Radius Methods (called by React Native's style system)
110-
130+
111131
@objc public func setBorderRadius(_ radius: CGFloat) {
112-
allBorderRadius = radius
132+
setBorderRadii(topLeft: radius, topRight: radius, bottomLeft: radius, bottomRight: radius)
133+
}
134+
135+
@objc public func setBorderRadii(topLeft: CGFloat, topRight: CGFloat, bottomLeft: CGFloat, bottomRight: CGFloat) {
136+
topLeftRadius = topLeft
137+
topRightRadius = topRight
138+
bottomLeftRadius = bottomLeft
139+
bottomRightRadius = bottomRight
113140
updateBorderRadius()
114141
}
115142

116143
private func updateEffect() {
144+
// Honor Reduce Transparency on every path, including the iOS 26 glass API,
145+
// which otherwise renders full glass and leaves reducedTransparencyFallbackColor
146+
// dead (the accessibility setting was never observed either).
147+
if UIAccessibility.isReduceTransparencyEnabled {
148+
updateFallback()
149+
return
150+
}
151+
117152
// Check if we can use the new API (iOS 26+)
118153
if #available(iOS 26.0, *) {
119154
#if compiler(>=6.2)
120155
let style: UIGlassEffect.Style = glassType == "regular" ? .regular : .clear
121-
156+
122157
// Always create a new effect to ensure proper rendering
123158
let effect = UIGlassEffect(style: style)
124-
// A zero-alpha tint means "no tint". Running it through
125-
// withAlphaComponent(glassOpacity) resurrects UIColor.clear (black at
126-
// alpha 0) as opaque black (issue #113) — leave the glass untinted.
127-
if glassTintColor.cgColor.alpha == 0 {
159+
// A zero-alpha tint means "no tint": running UIColor.clear (black at
160+
// alpha 0) through withAlphaComponent would resurrect it as opaque black
161+
// (issue #113). Otherwise multiply the tint's own alpha by glassOpacity
162+
// instead of replacing it, so a semi-transparent tint stays semi-transparent.
163+
let tintAlpha = glassTintColor.cgColor.alpha
164+
if tintAlpha == 0 {
128165
effect.tintColor = nil
129166
} else {
130-
effect.tintColor = glassTintColor.withAlphaComponent(glassOpacity)
167+
effect.tintColor = glassTintColor.withAlphaComponent(tintAlpha * glassOpacity)
131168
}
132169
effect.isInteractive = isInteractive
133-
170+
134171
glassEffectView?.effect = effect
135172
glassEffect = effect
136173
currentGlassStyle = glassType
137-
174+
138175
updateBorderRadius()
139176
#else
140177
// Fallback for older compilers (Xcode < 16.x) even on newer iOS
@@ -169,39 +206,42 @@ import UIKit
169206
glassEffectView?.contentView.backgroundColor = .clear
170207
}
171208

172-
layer.cornerRadius = allBorderRadius
173-
glassEffectView?.layer.cornerRadius = allBorderRadius
209+
// Fallback layers can only render a uniform corner radius.
210+
layer.cornerRadius = topLeftRadius
211+
glassEffectView?.layer.cornerRadius = topLeftRadius
174212
glassEffectView?.layer.masksToBounds = true
175213
}
176214

177215
private func updateBorderRadius() {
178216
if #available(iOS 26.0, *) {
179217
#if compiler(>=6.2)
180-
let topLeft = UICornerRadius(floatLiteral: Double(allBorderRadius))
181-
let topRight = UICornerRadius(floatLiteral: Double(allBorderRadius))
182-
let bottomLeft = UICornerRadius(floatLiteral: Double(allBorderRadius))
183-
let bottomRight = UICornerRadius(floatLiteral: Double(allBorderRadius))
184-
185218
glassEffectView?.cornerConfiguration = .corners(
186-
topLeftRadius: topLeft,
187-
topRightRadius: topRight,
188-
bottomLeftRadius: bottomLeft,
189-
bottomRightRadius: bottomRight
219+
topLeftRadius: UICornerRadius(floatLiteral: Double(topLeftRadius)),
220+
topRightRadius: UICornerRadius(floatLiteral: Double(topRightRadius)),
221+
bottomLeftRadius: UICornerRadius(floatLiteral: Double(bottomLeftRadius)),
222+
bottomRightRadius: UICornerRadius(floatLiteral: Double(bottomRightRadius))
190223
)
191224
#else
192-
layer.cornerRadius = allBorderRadius
225+
layer.cornerRadius = topLeftRadius
193226
layer.masksToBounds = true
194227
#endif
195228
} else {
196-
layer.cornerRadius = allBorderRadius
229+
layer.cornerRadius = topLeftRadius
197230
}
198231
}
199232

200233
public override func layoutSubviews() {
201234
super.layoutSubviews()
202-
glassEffectView?.frame = bounds
235+
// ignoreSafeArea == false confines the glass to the safe area; true (the
236+
// JS-side default) lets it fill the whole view.
237+
glassEffectView?.frame = ignoreSafeArea ? bounds : bounds.inset(by: safeAreaInsets)
203238
}
204-
239+
240+
public override func safeAreaInsetsDidChange() {
241+
super.safeAreaInsetsDidChange()
242+
setNeedsLayout()
243+
}
244+
205245
// For child view mounting
206246
@objc public func getContentView() -> UIView? {
207247
if #available(iOS 26.0, *) {

0 commit comments

Comments
 (0)