Skip to content

Commit 0728b30

Browse files
feat: add backdrop-filter ios support
1 parent 83eb67a commit 0728b30

7 files changed

Lines changed: 513 additions & 0 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
#pragma once
9+
10+
#include <react/renderer/graphics/Filter.h>
11+
12+
#import <QuartzCore/QuartzCore.h>
13+
#import <UIKit/UIKit.h>
14+
15+
NS_ASSUME_NONNULL_BEGIN
16+
17+
/**
18+
* A CALayer that renders CSS backdrop-filter on iOS using a Metal pipeline.
19+
*
20+
* On each display, CARenderer captures the composited content behind the host
21+
* view into a MTLTexture (GPU-only), then a Metal-backed CIContext applies the
22+
* CSS filter chain. The result is set as the layer's contents and inserted
23+
* as the first sublayer of the host view's layer so it renders behind all
24+
* of the view's own content.
25+
*
26+
* Call setNeedsDisplay whenever the backdrop or filter functions change.
27+
*/
28+
@interface RCTBackdropFilterLayer : CALayer
29+
30+
@property (nonatomic) std::vector<facebook::react::FilterFunction> filterFunctions;
31+
32+
/**
33+
* The UIView whose superview provides the backdrop source.
34+
* Must be set before the layer renders.
35+
*/
36+
@property (nonatomic, weak, nullable) UIView *hostView;
37+
@property (nonatomic, weak, nullable) CALayer *hostBackgroundColorLayer;
38+
39+
@end
40+
41+
NS_ASSUME_NONNULL_END
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
#import "RCTBackdropFilterLayer.h"
9+
10+
#import <Metal/Metal.h>
11+
#import <React/RCTBackdropFilterUtils.h>
12+
#import <UIKit/UIKit.h>
13+
14+
// Layers whose host view was off-screen when -display fired. Stored as weak refs so
15+
// deallocated layers vanish automatically. A single CADisplayLink polls their visibility
16+
// every frame and calls -setNeedsDisplay once a layer scrolls into view, then stops
17+
// itself when the set is empty (no CPU cost while idle).
18+
static NSHashTable<RCTBackdropFilterLayer *> *sPendingLayers;
19+
static CADisplayLink *sVisibilityLink;
20+
21+
// Shared CIContext backed by Metal. CIContext creation (MTLCreateSystemDefaultDevice +
22+
// contextWithMTLDevice:) can take 200–500 ms on first call; pre-warming in +initialize
23+
// on a background queue ensures it is ready before the first -display fires.
24+
static CIContext *sSharedCIContext;
25+
26+
@implementation RCTBackdropFilterLayer
27+
28+
+ (void)initialize
29+
{
30+
if (self == [RCTBackdropFilterLayer class]) {
31+
sPendingLayers = [NSHashTable weakObjectsHashTable];
32+
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
33+
// CSS filter functions are defined to operate in sRGB. Forcing the working colour space
34+
// to sRGB here matches Android (ColorMatrix in sRGB) and avoids vivid/saturated artefacts
35+
// that occur when Core Image processes in its default linear/Display-P3 working space.
36+
CGColorSpaceRef srgb = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
37+
NSDictionary *options = @{
38+
kCIContextWorkingColorSpace : (__bridge id)srgb,
39+
kCIContextOutputColorSpace : (__bridge id)srgb,
40+
};
41+
CGColorSpaceRelease(srgb);
42+
id<MTLDevice> device = MTLCreateSystemDefaultDevice();
43+
CIContext *ctx =
44+
device ? [CIContext contextWithMTLDevice:device options:options] : [CIContext contextWithOptions:options];
45+
// Pointer-sized store is atomic on ARM64; benign race — worst case another thread
46+
// creates a second context and stores it, both are valid.
47+
sSharedCIContext = ctx;
48+
});
49+
}
50+
}
51+
52+
static BOOL IsHostVisible(UIView *host)
53+
{
54+
if (!host.window) {
55+
return NO;
56+
}
57+
// Check visibility relative to the nearest scroll view ancestor so that
58+
// navigation slide-in transitions (which transform the whole screen in
59+
// window coordinates) don't incorrectly defer on-screen content.
60+
for (UIView *v = host.superview; v != nil; v = v.superview) {
61+
if ([v isKindOfClass:[UIWindow class]]) {
62+
break;
63+
}
64+
if ([v isKindOfClass:[UIScrollView class]]) {
65+
CGRect hostInSV = [host convertRect:host.bounds toView:v];
66+
return CGRectIntersectsRect(hostInSV, v.bounds);
67+
}
68+
}
69+
return YES;
70+
}
71+
72+
+ (void)_visibilityLinkFired:(CADisplayLink *)__unused link
73+
{
74+
for (RCTBackdropFilterLayer *layer in sPendingLayers.allObjects) {
75+
UIView *host = layer.hostView;
76+
if (host && IsHostVisible(host)) {
77+
[sPendingLayers removeObject:layer];
78+
[layer setNeedsDisplay];
79+
}
80+
}
81+
if (sPendingLayers.count == 0) {
82+
[sVisibilityLink invalidate];
83+
sVisibilityLink = nil;
84+
}
85+
}
86+
87+
- (void)_deferUntilVisible
88+
{
89+
self.contents = nil;
90+
[sPendingLayers addObject:self];
91+
if (!sVisibilityLink) {
92+
sVisibilityLink = [CADisplayLink displayLinkWithTarget:[self class] selector:@selector(_visibilityLinkFired:)];
93+
[sVisibilityLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
94+
}
95+
}
96+
97+
- (CIContext *)_ciContext
98+
{
99+
if (!sSharedCIContext) {
100+
// Background pre-warm hasn't finished yet; create synchronously and cache.
101+
CGColorSpaceRef srgb = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
102+
NSDictionary *options = @{
103+
kCIContextWorkingColorSpace : (__bridge id)srgb,
104+
kCIContextOutputColorSpace : (__bridge id)srgb,
105+
};
106+
CGColorSpaceRelease(srgb);
107+
id<MTLDevice> device = MTLCreateSystemDefaultDevice();
108+
sSharedCIContext =
109+
device ? [CIContext contextWithMTLDevice:device options:options] : [CIContext contextWithOptions:options];
110+
}
111+
return sSharedCIContext;
112+
}
113+
114+
- (void)_flushPendingDisplayInSubtree:(CALayer *)layer
115+
renderRootLayer:(CALayer *)rootLayer
116+
captureRect:(CGRect)captureRect
117+
{
118+
if (layer == self) {
119+
return;
120+
}
121+
122+
NSString *delegateClass =
123+
layer.delegate ? NSStringFromClass([layer.delegate class]) : NSStringFromClass([layer class]);
124+
// Match both legacy RCTParagraphTextView and Fabric RCTParagraphComponentView.
125+
BOOL isTextLayer = [delegateClass containsString:@"Paragraph"];
126+
127+
if (isTextLayer) {
128+
// Force display regardless of dirty state: recycled/first-render text views may have
129+
// needsDisplay=NO with nil or stale contents. Calling display directly triggers
130+
// drawRect:, which renders fresh content or clears the stale backing store.
131+
[layer display];
132+
} else if (layer.needsDisplay && ![layer isKindOfClass:[RCTBackdropFilterLayer class]]) {
133+
[layer displayIfNeeded];
134+
}
135+
for (CALayer *sub in layer.sublayers) {
136+
[self _flushPendingDisplayInSubtree:sub renderRootLayer:rootLayer captureRect:captureRect];
137+
}
138+
}
139+
140+
- (void)display
141+
{
142+
UIView *host = self.hostView;
143+
144+
if (!host || !host.superview || !self.superlayer) {
145+
self.contents = nil;
146+
return;
147+
}
148+
149+
CGSize captureSize = self.bounds.size;
150+
151+
if (captureSize.width <= 0 || captureSize.height <= 0) {
152+
self.contents = nil;
153+
return;
154+
}
155+
156+
// Lazy rendering: skip the expensive capture+filter work when the host is off-screen
157+
// (e.g. scrolled below the fold). The display link re-triggers -setNeedsDisplay once
158+
// the host scrolls into view, spreading cost over time instead of paying it all upfront.
159+
if (!IsHostVisible(host)) {
160+
[self _deferUntilVisible];
161+
return;
162+
}
163+
164+
UIView *renderRoot = host.superview;
165+
for (UIView *candidate = host.superview.superview; candidate != nil; candidate = candidate.superview) {
166+
// A clipping ancestor already constrains what can be seen behind the host —
167+
// nothing outside renderRoot.bounds can bleed through, so there is no point
168+
// walking further up the tree (and renderInContext: on a huge parent is expensive).
169+
if (renderRoot.clipsToBounds) {
170+
break;
171+
}
172+
if ([candidate isKindOfClass:[UIScrollView class]] || [candidate isKindOfClass:[UIWindow class]]) {
173+
break;
174+
}
175+
if ([candidate.nextResponder isKindOfClass:[UIViewController class]]) {
176+
break;
177+
}
178+
UIView *parentView = candidate.superview;
179+
if (parentView) {
180+
CGRect hostRectInParent = [host convertRect:host.bounds toView:parentView];
181+
for (UIView *sibling in parentView.subviews) {
182+
if (sibling != candidate && CGRectIntersectsRect(sibling.frame, hostRectInParent)) {
183+
goto doneWalking;
184+
}
185+
}
186+
}
187+
renderRoot = candidate;
188+
}
189+
doneWalking:;
190+
191+
CGFloat scale = UIScreen.mainScreen.scale;
192+
CGRect hostRectInRoot = [host convertRect:host.bounds toView:renderRoot];
193+
194+
[self _flushPendingDisplayInSubtree:renderRoot.layer renderRootLayer:renderRoot.layer captureRect:hostRectInRoot];
195+
196+
// Find the opaque background colour visible behind renderRoot so that transparent gaps
197+
// in the captured image are filled with the actual screen background rather than (0,0,0,0).
198+
// Without this, filters such as invert() incorrectly keep transparent areas transparent,
199+
// which composites as white on the iOS screen — matching what Android (pre-filled white) does.
200+
UIColor *backdropFill = nil;
201+
for (UIView *ancestor = renderRoot.superview; ancestor != nil; ancestor = ancestor.superview) {
202+
CGColorRef bg = ancestor.layer.backgroundColor;
203+
if (bg != NULL && CGColorGetAlpha(bg) >= 1.0f - 1e-4f) {
204+
backdropFill = [UIColor colorWithCGColor:bg];
205+
break;
206+
}
207+
if ([ancestor isKindOfClass:[UIWindow class]]) {
208+
break;
209+
}
210+
}
211+
if (!backdropFill) {
212+
backdropFill = UIColor.systemBackgroundColor;
213+
}
214+
215+
host.layer.hidden = YES;
216+
217+
UIGraphicsImageRendererFormat *captureFmt = [UIGraphicsImageRendererFormat preferredFormat];
218+
captureFmt.scale = scale;
219+
UIGraphicsImageRenderer *captureRenderer = [[UIGraphicsImageRenderer alloc] initWithSize:captureSize
220+
format:captureFmt];
221+
UIImage *backdropImage = [captureRenderer imageWithActions:^(UIGraphicsImageRendererContext *rendererCtx) {
222+
[backdropFill setFill];
223+
UIRectFill(CGRectMake(0, 0, captureSize.width, captureSize.height));
224+
CGContextTranslateCTM(rendererCtx.CGContext, -hostRectInRoot.origin.x, -hostRectInRoot.origin.y);
225+
[renderRoot.layer renderInContext:rendererCtx.CGContext];
226+
}];
227+
228+
host.layer.hidden = NO;
229+
230+
CIContext *ciContext = [self _ciContext];
231+
CIImage *ciImage = [[CIImage alloc] initWithImage:backdropImage];
232+
CIImage *filtered = [RCTBackdropFilterUtils applyCIFilterFunctions:_filterFunctions toImage:ciImage scale:scale];
233+
234+
CGColorRef bgColor = host.layer.backgroundColor;
235+
if (!bgColor) {
236+
bgColor = self.hostBackgroundColorLayer.backgroundColor;
237+
}
238+
if (bgColor && CGColorGetAlpha(bgColor) > 0) {
239+
CIColor *ciColor = [[CIColor alloc] initWithCGColor:bgColor];
240+
CIImage *tint = [[CIImage imageWithColor:ciColor] imageByCroppingToRect:filtered.extent];
241+
CIFilter *over = [CIFilter filterWithName:@"CISourceOverCompositing"];
242+
[over setValue:tint forKey:kCIInputImageKey];
243+
[over setValue:filtered forKey:kCIInputBackgroundImageKey];
244+
filtered = over.outputImage;
245+
}
246+
247+
CGImageRef cgImage = [ciContext createCGImage:filtered fromRect:filtered.extent];
248+
249+
if (!cgImage) {
250+
self.contents = nil;
251+
return;
252+
}
253+
254+
self.contents = (__bridge id)cgImage;
255+
self.contentsScale = scale;
256+
CGImageRelease(cgImage);
257+
}
258+
259+
@end
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
#pragma once
9+
10+
#include <react/renderer/graphics/Filter.h>
11+
12+
#import <CoreImage/CoreImage.h>
13+
14+
NS_ASSUME_NONNULL_BEGIN
15+
16+
@interface RCTBackdropFilterUtils : NSObject
17+
18+
/**
19+
* Applies filterFunctions as a chained CIFilter pipeline to image and returns
20+
* the result. scale is the display scale factor used to convert CSS pixel
21+
* values to device pixels (e.g. blur radius). Returns the original image if
22+
* no applicable filters are present.
23+
*/
24+
+ (CIImage *)applyCIFilterFunctions:(const std::vector<facebook::react::FilterFunction> &)filterFunctions
25+
toImage:(CIImage *)image
26+
scale:(CGFloat)scale;
27+
28+
@end
29+
30+
NS_ASSUME_NONNULL_END

0 commit comments

Comments
 (0)