Skip to content

Commit b365e26

Browse files
feat(iOS) - blur filter using SwiftUI (#52495)
Summary: As per the discussion on the previous [PR thread](#52028 (comment)), this PR uses `SwiftUI` to implement blur filter on iOS. ## Approach: To implement blur filter on iOS, we have two options: 1. Use `CAFilter` (private API, app can get rejected/API can break). Earlier [PR](#52028) was using that approach. Thanks to Nick for suggesting SwiftUI API. 2. Use `SwiftUI`. Wrap the view in a SwiftUI view and apply [blur](https://developer.apple.com/documentation/swiftui/view/blur(radius:opaque:)). This PR builds on top of that approach. This also enables a way to add `SwiftUI` only features like this one. Additional filters (grayscale, saturate, contrast, hueRotate) can also be added. There are a few ways we can implement the SwiftUI approach: 1. Create a new `RCTSwiftUIComponentView` -> do style flattening in View -> check if `filter` is present and conditionally render the `RCTSwiftUIComponentView` on iOS, wrap children with a `SwiftUI` view. Tradeoff with this approach is that it adds `StyleSheet.flatten` overhead on JS side. 2. Add a `SwiftUI` container view inside of `RCTViewComponentView`. Tradeoff with this approach is that it complicates `RCTViewComponentView` a bit. I decided to go with **2** to avoid the flattening tradeoff and try to minimize complicating `RCTViewComponentView`. it only adds the wrapper if it's required and removes if not (in this PR, blur filter style will add the wrapper, it will get removed if blur filter styling gets removed). It uses the existing container view pattern. ## Changelog: [IOS][ADDED] - Filter blur <!-- Help reviewers and the release process by writing your own changelog entry. Pick one each for the category and type tags: [ANDROID|GENERAL|IOS|INTERNAL] [BREAKING|ADDED|CHANGED|DEPRECATED|REMOVED|FIXED|SECURITY] - Message For more details, see: https://reactnative.dev/contributing/changelogs-in-pull-requests Pull Request resolved: #52495 Test Plan: Test filter blur example on iOS. SwiftUI view should be added to the hierarchy. <img src="https://github.com/user-attachments/assets/742539f4-a96d-45f4-94ba-5eb588d0ad5a" width="300px" /> ## Aside: - This PR also adds a new swift podspec. Creating a new podspec felt the right approach as adding swift in existing ones were adding some complexity. But open for changes here. Also, need some eyes on the podspec configs. cc - chrfalch 🙏 this might also affect the SPM migration. - Unrelated: Existing brightness filter has some inconsistency compared to android and web, it uses [self.layer.opacity](https://github.com/facebook/react-native/blob/6892dde36373bbef2d0afe535ae818b1a7164f08/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm#L1008) so transparent background color do not blend well unless the view has an opacity. One solution would be to calculate true background color by using brightness or else use the `SwiftUI`'s [brightness](https://developer.apple.com/documentation/swiftui/view/brightness(_:)), which would be cleaner imo (tested and it works). Reviewed By: cipolleschi Differential Revision: D79666764 Pulled By: joevilches fbshipit-source-id: 05e43d75ce7b6f25b67b4eed632524a559ea1c2e
1 parent 7e85653 commit b365e26

32 files changed

+768
-176
lines changed

packages/react-native/Package.swift

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ let reactOSCompat = RNTarget(
6868
path: "ReactCommon/oscompat"
6969
)
7070

71+
let rctSwiftUI = RNTarget(
72+
name: .rctSwiftUI,
73+
path: "ReactApple/RCTSwiftUI"
74+
)
75+
76+
let rctSwiftUIWrapper = RNTarget(
77+
name: .rctSwiftUIWrapper,
78+
path: "ReactApple/RCTSwiftUIWrapper",
79+
dependencies: [.rctSwiftUI]
80+
)
81+
7182
// React-rendererconsistency.podspec
7283
let reactRendererConsistency = RNTarget(
7384
name: .reactRendererConsistency,
@@ -439,7 +450,7 @@ let reactFabric = RNTarget(
439450
let reactRCTFabric = RNTarget(
440451
name: .reactRCTFabric,
441452
path: "React/Fabric",
442-
dependencies: [.reactNativeDependencies, .reactCore, .reactRCTImage, .yoga, .reactRCTText, .jsi, .reactFabricComponents, .reactGraphics, .reactImageManager, .reactDebug, .reactUtils, .reactPerformanceTimeline, .reactRendererDebug, .reactRendererConsistency, .reactRuntimeScheduler, .reactRCTAnimation, .reactJsInspector, .reactJsInspectorNetwork, .reactJsInspectorTracing, .reactFabric, .reactFabricImage]
453+
dependencies: [.reactNativeDependencies, .reactCore, .reactRCTImage, .yoga, .reactRCTText, .jsi, .reactFabricComponents, .reactGraphics, .reactImageManager, .reactDebug, .reactUtils, .reactPerformanceTimeline, .reactRendererDebug, .reactRendererConsistency, .reactRuntimeScheduler, .reactRCTAnimation, .reactJsInspector, .reactJsInspectorNetwork, .reactJsInspectorTracing, .reactFabric, .reactFabricImage, .rctSwiftUIWrapper]
443454
)
444455

445456
/// React-FabricComponents.podspec
@@ -579,6 +590,8 @@ let targets = [
579590
reactCore,
580591
reactCoreRCTWebsocket,
581592
reactFabric,
593+
rctSwiftUI,
594+
rctSwiftUIWrapper,
582595
reactRCTFabric,
583596
reactFabricComponents,
584597
reactFabricImage,
@@ -728,6 +741,9 @@ extension String {
728741
static let logger = "React-logger"
729742
static let mapbuffer = "React-Mapbuffer"
730743

744+
static let rctSwiftUI = "RCTSwiftUI"
745+
static let rctSwiftUIWrapper = "RCTSwiftUIWrapper"
746+
731747
static let rctDeprecation = "RCT-Deprecation"
732748
static let yoga = "Yoga"
733749
static let reactUtils = "React-utils"

packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm

Lines changed: 159 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#import <objc/runtime.h>
1313
#import <ranges>
1414

15+
#import <RCTSwiftUIWrapper/RCTSwiftUIContainerViewWrapper.h>
1516
#import <React/RCTAssert.h>
1617
#import <React/RCTBorderDrawing.h>
1718
#import <React/RCTBoxShadow.h>
@@ -50,6 +51,7 @@ @implementation RCTViewComponentView {
5051
UIView *_containerView;
5152
BOOL _useCustomContainerView;
5253
NSMutableSet<NSString *> *_accessibilityOrderNativeIDs;
54+
RCTSwiftUIContainerViewWrapper *_swiftUIWrapper;
5355
}
5456

5557
#ifdef RCT_DYNAMIC_FRAMEWORKS
@@ -576,6 +578,10 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics
576578
auto newTransform = _props->resolveTransform(layoutMetrics);
577579
self.layer.transform = RCTCATransform3DFromTransformMatrix(newTransform);
578580
}
581+
582+
if (_swiftUIWrapper != nullptr) {
583+
[_swiftUIWrapper updateLayoutWithBounds:self.bounds];
584+
}
579585
}
580586

581587
- (BOOL)isJSResponder
@@ -793,43 +799,95 @@ - (BOOL)styleWouldClipOverflowInk
793799
((!_props->boxShadow.empty() || (clipToPaddingBox && nonZeroBorderWidth)) || _props->outlineWidth != 0);
794800
}
795801

802+
// The view that is used as the receiver for all styling (borders, background,
803+
// etc.). Most of the time, this is just `self`. When a view has a filter like
804+
// `blur` applied, we need to wrap it in a SwiftUI view to render the effect.
805+
// In this case, `effectiveContentView` will be the content view inside the
806+
// SwiftUI wrapper.
807+
- (UIView *)effectiveContentView
808+
{
809+
if (!ReactNativeFeatureFlags::enableSwiftUIBasedFilters()) {
810+
return self;
811+
}
812+
813+
UIView *effectiveContentView = self;
814+
815+
if (self.styleNeedsSwiftUIContainer) {
816+
if (_swiftUIWrapper == nullptr) {
817+
_swiftUIWrapper = [RCTSwiftUIContainerViewWrapper new];
818+
UIView *swiftUIContentView = [[UIView alloc] init];
819+
for (UIView *subview = nullptr in self.subviews) {
820+
[swiftUIContentView addSubview:subview];
821+
}
822+
swiftUIContentView.clipsToBounds = self.clipsToBounds;
823+
self.clipsToBounds = NO;
824+
swiftUIContentView.layer.mask = self.layer.mask;
825+
self.layer.mask = nil;
826+
[_swiftUIWrapper updateContentView:swiftUIContentView];
827+
[_swiftUIWrapper updateLayoutWithBounds:self.bounds];
828+
[self addSubview:_swiftUIWrapper.hostingView];
829+
830+
[self transferVisualPropertiesFromView:self toView:swiftUIContentView];
831+
}
832+
833+
effectiveContentView = _swiftUIWrapper.contentView;
834+
} else {
835+
if (_swiftUIWrapper != nullptr) {
836+
UIView *swiftUIContentView = _swiftUIWrapper.contentView;
837+
for (UIView *subview = nullptr in swiftUIContentView.subviews) {
838+
[self addSubview:subview];
839+
}
840+
self.clipsToBounds = swiftUIContentView.clipsToBounds;
841+
self.layer.mask = swiftUIContentView.layer.mask;
842+
843+
[self transferVisualPropertiesFromView:swiftUIContentView toView:self];
844+
845+
[_swiftUIWrapper.hostingView removeFromSuperview];
846+
_swiftUIWrapper = nil;
847+
}
848+
}
849+
850+
return effectiveContentView;
851+
}
852+
796853
// This UIView is the UIView that holds all subviews. It is sometimes not self
797854
// because we want to render "overflow ink" that extends beyond the bounds of
798855
// the view and is not affected by clipping.
799856
- (UIView *)currentContainerView
800857
{
858+
UIView *effectiveContentView = self.effectiveContentView;
859+
801860
if (_useCustomContainerView) {
802861
if (!_containerView) {
803862
_containerView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.frame.size.width, self.frame.size.height)];
804-
for (UIView *subview in self.subviews) {
863+
for (UIView *subview = nullptr in effectiveContentView.subviews) {
805864
[_containerView addSubview:subview];
806865
}
807-
_containerView.clipsToBounds = self.clipsToBounds;
808-
self.clipsToBounds = NO;
809-
_containerView.layer.mask = self.layer.mask;
810-
self.layer.mask = nil;
811-
[self addSubview:_containerView];
866+
_containerView.clipsToBounds = effectiveContentView.clipsToBounds;
867+
effectiveContentView.clipsToBounds = NO;
868+
_containerView.layer.mask = effectiveContentView.layer.mask;
869+
effectiveContentView.layer.mask = nil;
870+
[effectiveContentView addSubview:_containerView];
812871
}
813872

814-
return _containerView;
873+
effectiveContentView = _containerView;
815874
} else {
816875
if (_containerView) {
817876
for (UIView *subview in _containerView.subviews) {
818-
[self addSubview:subview];
877+
[effectiveContentView addSubview:subview];
819878
}
820-
self.clipsToBounds = _containerView.clipsToBounds;
821-
self.layer.mask = _containerView.layer.mask;
879+
effectiveContentView.clipsToBounds = _containerView.clipsToBounds;
880+
effectiveContentView.layer.mask = _containerView.layer.mask;
822881
[_containerView removeFromSuperview];
823882
_containerView = nil;
824883
}
825-
826-
return self;
827884
}
885+
return effectiveContentView;
828886
}
829887

830888
- (void)invalidateLayer
831889
{
832-
CALayer *layer = self.layer;
890+
CALayer *layer = self.effectiveContentView.layer;
833891

834892
if (CGSizeEqualToSize(layer.bounds.size, CGSizeZero)) {
835893
return;
@@ -910,7 +968,7 @@ - (void)invalidateLayer
910968
if (!_backgroundColorLayer) {
911969
_backgroundColorLayer = [CALayer layer];
912970
_backgroundColorLayer.zPosition = BACKGROUND_COLOR_ZPOSITION;
913-
[self.layer addSublayer:_backgroundColorLayer];
971+
[layer addSublayer:_backgroundColorLayer];
914972
}
915973
[self shapeLayerToMatchView:_backgroundColorLayer borderMetrics:borderMetrics];
916974
_backgroundColorLayer.backgroundColor = backgroundColor.CGColor;
@@ -986,31 +1044,43 @@ - (void)invalidateLayer
9861044
// filter
9871045
[_filterLayer removeFromSuperlayer];
9881046
_filterLayer = nil;
1047+
if (_swiftUIWrapper != nullptr) {
1048+
[_swiftUIWrapper updateBlurRadius:@(0)];
1049+
}
9891050
self.layer.opacity = (float)_props->opacity;
9901051
if (!_props->filter.empty()) {
9911052
float multiplicativeBrightness = 1;
1053+
bool hasBrightnessFilter = false;
9921054
for (const auto &primitive : _props->filter) {
9931055
if (std::holds_alternative<Float>(primitive.parameters)) {
9941056
if (primitive.type == FilterType::Brightness) {
9951057
multiplicativeBrightness *= std::get<Float>(primitive.parameters);
1058+
hasBrightnessFilter = true;
9961059
} else if (primitive.type == FilterType::Opacity) {
9971060
self.layer.opacity *= std::get<Float>(primitive.parameters);
1061+
} else if (primitive.type == FilterType::Blur) {
1062+
if (_swiftUIWrapper != nullptr) {
1063+
Float blurRadius = std::get<Float>(primitive.parameters);
1064+
[_swiftUIWrapper updateBlurRadius:@(blurRadius)];
1065+
}
9981066
}
9991067
}
10001068
}
10011069

1002-
_filterLayer = [CALayer layer];
1003-
[self shapeLayerToMatchView:_filterLayer borderMetrics:borderMetrics];
1004-
_filterLayer.compositingFilter = @"multiplyBlendMode";
1005-
_filterLayer.backgroundColor = [UIColor colorWithRed:multiplicativeBrightness
1006-
green:multiplicativeBrightness
1007-
blue:multiplicativeBrightness
1008-
alpha:self.layer.opacity]
1009-
.CGColor;
1010-
// So that this layer is always above any potential sublayers this view may
1011-
// add
1012-
_filterLayer.zPosition = CGFLOAT_MAX;
1013-
[self.layer addSublayer:_filterLayer];
1070+
if (hasBrightnessFilter) {
1071+
_filterLayer = [CALayer layer];
1072+
[self shapeLayerToMatchView:_filterLayer borderMetrics:borderMetrics];
1073+
_filterLayer.compositingFilter = @"multiplyBlendMode";
1074+
_filterLayer.backgroundColor = [UIColor colorWithRed:multiplicativeBrightness
1075+
green:multiplicativeBrightness
1076+
blue:multiplicativeBrightness
1077+
alpha:self.layer.opacity]
1078+
.CGColor;
1079+
// So that this layer is always above any potential sublayers this view may
1080+
// add
1081+
_filterLayer.zPosition = CGFLOAT_MAX;
1082+
[layer addSublayer:_filterLayer];
1083+
}
10141084
}
10151085

10161086
// background image
@@ -1025,7 +1095,7 @@ - (void)invalidateLayer
10251095
[self shapeLayerToMatchView:backgroundImageLayer borderMetrics:borderMetrics];
10261096
backgroundImageLayer.masksToBounds = YES;
10271097
backgroundImageLayer.zPosition = BACKGROUND_COLOR_ZPOSITION;
1028-
[self.layer addSublayer:backgroundImageLayer];
1098+
[layer addSublayer:backgroundImageLayer];
10291099
[_backgroundImageLayers addObject:backgroundImageLayer];
10301100
} else if (std::holds_alternative<RadialGradient>(backgroundImage)) {
10311101
const auto &radialGradient = std::get<RadialGradient>(backgroundImage);
@@ -1034,7 +1104,7 @@ - (void)invalidateLayer
10341104
[self shapeLayerToMatchView:backgroundImageLayer borderMetrics:borderMetrics];
10351105
backgroundImageLayer.masksToBounds = YES;
10361106
backgroundImageLayer.zPosition = BACKGROUND_COLOR_ZPOSITION;
1037-
[self.layer addSublayer:backgroundImageLayer];
1107+
[layer addSublayer:backgroundImageLayer];
10381108
[_backgroundImageLayers addObject:backgroundImageLayer];
10391109
}
10401110
}
@@ -1056,7 +1126,7 @@ - (void)invalidateLayer
10561126
RCTUIEdgeInsetsFromEdgeInsets(borderMetrics.borderWidths),
10571127
self.layer.bounds.size);
10581128
shadowLayer.zPosition = _borderLayer.zPosition;
1059-
[self.layer addSublayer:shadowLayer];
1129+
[layer addSublayer:shadowLayer];
10601130
[_boxShadowLayers addObject:shadowLayer];
10611131
}
10621132
}
@@ -1410,6 +1480,66 @@ - (NSString *)componentViewName_DO_NOT_USE_THIS_IS_BROKEN
14101480
return RCTNSStringFromString([[self class] componentDescriptorProvider].name);
14111481
}
14121482

1483+
- (BOOL)styleNeedsSwiftUIContainer
1484+
{
1485+
if (!_props->filter.empty()) {
1486+
for (const auto &primitive : _props->filter) {
1487+
if (primitive.type == FilterType::Blur) {
1488+
return YES;
1489+
}
1490+
}
1491+
}
1492+
return NO;
1493+
}
1494+
1495+
- (void)transferVisualPropertiesFromView:(UIView *)sourceView toView:(UIView *)destinationView
1496+
{
1497+
// shadow
1498+
destinationView.layer.shadowColor = sourceView.layer.shadowColor;
1499+
sourceView.layer.shadowColor = nil;
1500+
destinationView.layer.shadowOffset = sourceView.layer.shadowOffset;
1501+
sourceView.layer.shadowOffset = CGSizeZero;
1502+
destinationView.layer.shadowOpacity = sourceView.layer.shadowOpacity;
1503+
sourceView.layer.shadowOpacity = 0;
1504+
destinationView.layer.shadowRadius = sourceView.layer.shadowRadius;
1505+
sourceView.layer.shadowRadius = 0;
1506+
1507+
// background
1508+
destinationView.layer.backgroundColor = sourceView.layer.backgroundColor;
1509+
sourceView.layer.backgroundColor = nil;
1510+
if (_backgroundColorLayer != nullptr) {
1511+
[destinationView.layer addSublayer:_backgroundColorLayer];
1512+
}
1513+
1514+
// border
1515+
destinationView.layer.borderColor = sourceView.layer.borderColor;
1516+
sourceView.layer.borderColor = nil;
1517+
destinationView.layer.borderWidth = sourceView.layer.borderWidth;
1518+
sourceView.layer.borderWidth = 0;
1519+
1520+
// corner
1521+
destinationView.layer.cornerRadius = sourceView.layer.cornerRadius;
1522+
sourceView.layer.cornerRadius = 0;
1523+
destinationView.layer.cornerCurve = sourceView.layer.cornerCurve;
1524+
1525+
// custom layers
1526+
if (_borderLayer != nullptr) {
1527+
[destinationView.layer addSublayer:_borderLayer];
1528+
}
1529+
if (_outlineLayer != nullptr) {
1530+
[destinationView.layer addSublayer:_outlineLayer];
1531+
}
1532+
if (_filterLayer != nullptr) {
1533+
[destinationView.layer addSublayer:_filterLayer];
1534+
}
1535+
for (CALayer *layer = nullptr in _backgroundImageLayers) {
1536+
[destinationView.layer addSublayer:layer];
1537+
}
1538+
for (CALayer *layer = nullptr in _boxShadowLayers) {
1539+
[destinationView.layer addSublayer:layer];
1540+
}
1541+
}
1542+
14131543
@end
14141544

14151545
#ifdef __cplusplus

packages/react-native/React/React-RCTFabric.podspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ Pod::Spec.new do |s|
6363
s.dependency "Yoga"
6464
s.dependency "React-RCTText"
6565
s.dependency "React-jsi"
66+
s.dependency "RCTSwiftUIWrapper"
6667

6768
add_dependency(s, "React-FabricImage")
6869
add_dependency(s, "React-Fabric", :additional_framework_paths => [

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<af10f4eea240ae4a228de9bbc4b78b7e>>
7+
* @generated SignedSource<<f089964c958dfcac00f83c3371ccbefc>>
88
*/
99

1010
/**
@@ -252,6 +252,12 @@ public object ReactNativeFeatureFlags {
252252
@JvmStatic
253253
public fun enableResourceTimingAPI(): Boolean = accessor.enableResourceTimingAPI()
254254

255+
/**
256+
* When enabled, it will use SwiftUI for filter effects like blur on iOS.
257+
*/
258+
@JvmStatic
259+
public fun enableSwiftUIBasedFilters(): Boolean = accessor.enableSwiftUIBasedFilters()
260+
255261
/**
256262
* Enables View Culling: as soon as a view goes off screen, it can be reused anywhere in the UI and pieced together with other items to create new UI elements.
257263
*/

0 commit comments

Comments
 (0)