From a3cf1624d0e70f0b91a6d6289bb52b388eadd16e Mon Sep 17 00:00:00 2001 From: Joe Vilches Date: Tue, 2 Jul 2024 13:44:58 -0700 Subject: [PATCH] Outset box shadow impl Summary: tisa. Algo is * Draw offscreen rect the size of the outset shadow (so accounting for spread) * Set g state to cast shadow in proper place ON screen * Clear out region in view The rects need to be offscreen for the following reasons * We need to account for spread radius, and CGContext shadows do not have support for this. So the only way to create a bigger shadow is the create a path that is the same size as the shadow we want * We cannot just position this rect onscreen with no offset (so the shadow is casting directly under it) since the blur will look unnatural * Offscreen means we do not see filled shadow rect but we do see the shadow it casts by offsetting it in the proper location Changelog: [Internal] Reviewed By: lenaic Differential Revision: D58881588 fbshipit-source-id: 2ea1b8945a3b9f182c4fb11668ac91a0ae7846ca --- .../View/RCTViewComponentView.mm | 20 +++ .../React/Fabric/Utils/RCTBoxShadow.h | 21 +++ .../React/Fabric/Utils/RCTBoxShadow.mm | 144 ++++++++++++++++++ .../components/view/ViewShadowNode.cpp | 2 +- 4 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 packages/react-native/React/Fabric/Utils/RCTBoxShadow.h create mode 100644 packages/react-native/React/Fabric/Utils/RCTBoxShadow.mm diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index 77072e5848e..89e659cd7d4 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -13,6 +13,7 @@ #import #import +#import #import #import #import @@ -29,6 +30,7 @@ using namespace facebook::react; @implementation RCTViewComponentView { UIColor *_backgroundColor; __weak CALayer *_borderLayer; + CALayer *_boxShadowLayer; CALayer *_filterLayer; BOOL _needsInvalidateLayer; BOOL _isJSResponder; @@ -394,6 +396,11 @@ using namespace facebook::react; _needsInvalidateLayer = YES; } + // `boxShadow` + if (oldViewProps.boxShadow != newViewProps.boxShadow) { + _needsInvalidateLayer = YES; + } + _needsInvalidateLayer = _needsInvalidateLayer || needsInvalidateLayer; _props = std::static_pointer_cast(props); @@ -761,6 +768,19 @@ static RCTBorderStyle RCTBorderStyleFromBorderStyle(BorderStyle borderStyle) _filterLayer.zPosition = CGFLOAT_MAX; [self.layer addSublayer:_filterLayer]; } + + _boxShadowLayer = nil; + if (!_props->boxShadow.empty()) { + _boxShadowLayer = [CALayer layer]; + [self.layer addSublayer:_boxShadowLayer]; + _boxShadowLayer.zPosition = CGFLOAT_MIN; + _boxShadowLayer.frame = RCTGetBoundingRect(_props->boxShadow, self.layer.frame.size); + + UIImage *boxShadowImage = + RCTGetBoxShadowImage(_props->boxShadow, RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii), layer); + + _boxShadowLayer.contents = (id)boxShadowImage.CGImage; + } } #pragma mark - Accessibility diff --git a/packages/react-native/React/Fabric/Utils/RCTBoxShadow.h b/packages/react-native/React/Fabric/Utils/RCTBoxShadow.h new file mode 100644 index 00000000000..9c432f2b6a5 --- /dev/null +++ b/packages/react-native/React/Fabric/Utils/RCTBoxShadow.h @@ -0,0 +1,21 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#import +#import +#import +#import + +RCT_EXTERN UIImage * +RCTGetBoxShadowImage(std::vector shadows, RCTCornerRadii cornerRadii, CALayer *layer); + +RCT_EXTERN CGRect +RCTGetBoxShadowRect(CGFloat offsetX, CGFloat offsetY, CGFloat blurRadius, CGFloat spreadRadius, CGSize layerSize); + +RCT_EXTERN CGRect RCTGetBoundingRect(std::vector boxShadows, CGSize layerSize); diff --git a/packages/react-native/React/Fabric/Utils/RCTBoxShadow.mm b/packages/react-native/React/Fabric/Utils/RCTBoxShadow.mm new file mode 100644 index 00000000000..74c642ec2c2 --- /dev/null +++ b/packages/react-native/React/Fabric/Utils/RCTBoxShadow.mm @@ -0,0 +1,144 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "RCTBoxShadow.h" + +#import +#import + +#import + +using namespace facebook::react; +// See https://drafts.csswg.org/css-backgrounds/#shadow-shape +static CGFloat adjustedCornerRadius(CGFloat cornerRadius, CGFloat spreadRadius) +{ + CGFloat adjustment = spreadRadius; + if (cornerRadius < spreadRadius) { + adjustment *= 1 + pow((cornerRadius / spreadRadius) - 1, 3); + } + + return cornerRadius + adjustment; +} + +static RCTCornerRadii cornerRadiiForBoxShadow(RCTCornerRadii cornerRadii, CGFloat spreadRadius) +{ + return { + adjustedCornerRadius(cornerRadii.topLeft, spreadRadius), + adjustedCornerRadius(cornerRadii.topRight, spreadRadius), + adjustedCornerRadius(cornerRadii.bottomLeft, spreadRadius), + adjustedCornerRadius(cornerRadii.bottomRight, spreadRadius)}; +} + +CGRect RCTGetBoxShadowRect(CGFloat offsetX, CGFloat offsetY, CGFloat blurRadius, CGFloat spreadRadius, CGSize layerSize) +{ + return CGRectMake( + offsetX - spreadRadius - blurRadius, + offsetY - spreadRadius - blurRadius, + layerSize.width + 2 * (spreadRadius + blurRadius), + layerSize.height + 2 * (spreadRadius + blurRadius)); +} + +// Returns the smallest CGRect that will contain all shadows and the layer itself. +// The origin represents the location of this box relative to the layer the shadows +// are attached to. +CGRect RCTGetBoundingRect(std::vector boxShadows, CGSize layerSize) +{ + CGFloat smallestX = 0; + CGFloat smallestY = 0; + CGFloat largestX = layerSize.width; + CGFloat largestY = layerSize.height; + for (const auto &boxShadow : boxShadows) { + CGFloat negativeXExtent = boxShadow.offsetX - boxShadow.spreadRadius - boxShadow.blurRadius; + smallestX = MIN(smallestX, negativeXExtent); + CGFloat negativeYExtent = boxShadow.offsetY - boxShadow.spreadRadius - boxShadow.blurRadius; + smallestY = MIN(smallestY, negativeYExtent); + CGFloat positiveXExtent = boxShadow.offsetX + boxShadow.spreadRadius + boxShadow.blurRadius + layerSize.width; + largestX = MAX(largestX, positiveXExtent); + CGFloat positiveYExtent = boxShadow.offsetY + boxShadow.spreadRadius + boxShadow.blurRadius + layerSize.height; + largestY = MAX(largestY, positiveYExtent); + } + + return CGRectMake(smallestX, smallestY, largestX - smallestX, largestY - smallestY); +} + +UIImage *RCTGetBoxShadowImage(std::vector shadows, RCTCornerRadii cornerRadii, CALayer *layer) +{ + CGRect boundingRect = RCTGetBoundingRect(shadows, layer.bounds.size); + UIGraphicsImageRendererFormat *const rendererFormat = [UIGraphicsImageRendererFormat defaultFormat]; + UIGraphicsImageRenderer *const renderer = [[UIGraphicsImageRenderer alloc] initWithSize:boundingRect.size + format:rendererFormat]; + // Core graphics has support for shadows that looks similar to web and are very + // fast to apply. The only issue is that this shadow does not take a spread + // radius like on web. To get around this, we draw the shadow rect (the rect + // that casts the shadow, not the shadow itself) offscreen. This shadow rect + // is correctly sized to account for spread radius. Then, when setting the + // shadow itself, we modify the offsetX/Y to account for the fact that our + // shadow rect is offscreen and position it where it needs to be in the image. + UIImage *const boxShadowImage = [renderer imageWithActions:^( + UIGraphicsImageRendererContext *_Nonnull rendererContext) { + const CGContextRef context = rendererContext.CGContext; + // Reverse iterator as shadows are stacked back to front + for (auto it = shadows.rbegin(); it != shadows.rend(); ++it) { + CGFloat offsetX = it->offsetX; + CGFloat offsetY = it->offsetY; + CGFloat blurRadius = it->blurRadius; + CGFloat spreadRadius = it->spreadRadius; + CGColorRef color = RCTUIColorFromSharedColor(it->color).CGColor; + + // First, define the shadow rect. This is the rect that will be filled + // and _cast_ the shadow. As a result, the size does not incorporate + // the blur radius since this rect is not the shadow itself. + const RCTCornerInsets shadowRectCornerInsets = + RCTGetCornerInsets(cornerRadiiForBoxShadow(cornerRadii, spreadRadius), UIEdgeInsetsZero); + CGSize shadowRectSize = + CGSizeMake(layer.bounds.size.width + 2 * spreadRadius, layer.bounds.size.height + 2 * spreadRadius); + // Ensure this is drawn offscreen and will not show in the image + CGRect shadowRect = CGRectMake(-shadowRectSize.width, 0, shadowRectSize.width, shadowRectSize.height); + CGPathRef shadowRectPath = RCTPathCreateWithRoundedRect(shadowRect, shadowRectCornerInsets, nil); + + // Second, set the shadow as graphics state so that when we fill our + // shadow rect it will actually cast a shadow. The offset of this + // shadow needs to compensate for the fact that the shadow rect is + // offscreen. Additionally, we take away the spread radius since spread + // grows in all directions but the origin of our shadow rect is just + // the negative width, which accounts for 2*spread radius. + CGContextSetShadowWithColor( + context, + CGSizeMake( + offsetX - boundingRect.origin.x - spreadRadius - shadowRect.origin.x, + offsetY - boundingRect.origin.y - spreadRadius), + blurRadius, + color); + + // Third, the Core Graphics functions to actually draw the shadow rect + // and thus the shadow itself. + CGContextAddPath(context, shadowRectPath); + // Color here does not matter, we just need something that has 1 for + // alpha so that the shadow is visible. The rect is purposely rendered + // outside of the context so it should not be visible. + CGContextSetFillColorWithColor(context, [UIColor blackColor].CGColor); + CGContextFillPath(context); + + CGPathRelease(shadowRectPath); + } + // Lastly, clear out the region inside the view so that the shadows do + // not cover its content + const RCTCornerInsets layerCornerInsets = + RCTGetCornerInsets(cornerRadiiForBoxShadow(cornerRadii, 0), UIEdgeInsetsZero); + CGPathRef shadowPathAlignedWithLayer = RCTPathCreateWithRoundedRect( + CGRectMake(-boundingRect.origin.x, -boundingRect.origin.y, layer.bounds.size.width, layer.bounds.size.height), + layerCornerInsets, + nil); + CGContextAddPath(context, shadowPathAlignedWithLayer); + CGContextSetBlendMode(context, kCGBlendModeClear); + CGContextFillPath(context); + + CGPathRelease(shadowPathAlignedWithLayer); + }]; + + return boxShadowImage; +} diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp index 666702d1625..9df83d2270b 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp @@ -66,7 +66,7 @@ void ViewShadowNode::initialize() noexcept { bool formsView = formsStackingContext || isColorMeaningful(viewProps.backgroundColor) || hasBorder() || - !viewProps.testId.empty() || + !viewProps.testId.empty() || !viewProps.boxShadow.empty() || HostPlatformViewTraitsInitializer::formsView(viewProps); if (formsView) {