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 8a074cd2b604..1a5f0a6cfd49 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -1485,7 +1485,8 @@ - (void)invalidateLayer CALayer *maskLayer = [self createMaskLayer:self.bounds cornerInsets:RCTGetCornerInsets( - RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii), UIEdgeInsetsZero)]; + RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii), UIEdgeInsetsZero) + borderCurve:borderMetrics.borderCurves.topLeft]; self.currentContainerView.layer.mask = maskLayer; } @@ -1497,7 +1498,9 @@ - (void)invalidateLayer // If the subview is an image view, we have to apply the mask directly to the image view's layer, // otherwise the image might overflow with the border radius. - subview.layer.mask = [self createMaskLayer:subview.bounds cornerInsets:cornerInsets]; + subview.layer.mask = [self createMaskLayer:subview.bounds + cornerInsets:cornerInsets + borderCurve:borderMetrics.borderCurves.topLeft]; } } } else if ( @@ -1506,7 +1509,8 @@ - (void)invalidateLayer CALayer *maskLayer = [self createMaskLayer:RCTCGRectFromRect(_layoutMetrics.getPaddingFrame()) cornerInsets:RCTGetCornerInsets( RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii), - RCTUIEdgeInsetsFromEdgeInsets(borderMetrics.borderWidths))]; + RCTUIEdgeInsetsFromEdgeInsets(borderMetrics.borderWidths)) + borderCurve:borderMetrics.borderCurves.topLeft]; self.currentContainerView.layer.mask = maskLayer; } else { self.currentContainerView.layer.cornerRadius = borderMetrics.borderRadii.topLeft.horizontal; @@ -1526,16 +1530,59 @@ - (void)shapeLayerToMatchView:(CALayer *)layer borderMetrics:(BorderMetrics)bord layer.cornerRadius = borderMetrics.borderRadii.topLeft.horizontal; layer.cornerCurve = CornerCurveFromBorderCurve(borderMetrics.borderCurves.topLeft); } else { - CAShapeLayer *maskLayer = [self + CALayer *maskLayer = [self createMaskLayer:self.bounds - cornerInsets:RCTGetCornerInsets(RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii), UIEdgeInsetsZero)]; + cornerInsets:RCTGetCornerInsets(RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii), UIEdgeInsetsZero) + borderCurve:borderMetrics.borderCurves.topLeft]; layer.mask = maskLayer; layer.cornerRadius = 0; } } -- (CAShapeLayer *)createMaskLayer:(CGRect)bounds cornerInsets:(RCTCornerInsets)cornerInsets -{ +- (CALayer *)createMaskLayer:(CGRect)bounds + cornerInsets:(RCTCornerInsets)cornerInsets + borderCurve:(BorderCurve)borderCurve +{ + if (borderCurve == BorderCurve::Continuous) { + const struct { + CGSize size; + CACornerMask mask; + } corners[] = { + {cornerInsets.topLeft, kCALayerMinXMinYCorner}, + {cornerInsets.topRight, kCALayerMaxXMinYCorner}, + {cornerInsets.bottomLeft, kCALayerMinXMaxYCorner}, + {cornerInsets.bottomRight, kCALayerMaxXMaxYCorner}}; + CGFloat radius = 0; + CACornerMask maskedCorners = 0; + bool useCoreAnimationMask = true; + for (const auto &corner : corners) { + if (CGSizeEqualToSize(corner.size, CGSizeZero)) { + continue; + } + if (corner.size.width != corner.size.height || (radius != 0 && corner.size.width != radius)) { + useCoreAnimationMask = false; + break; + } + radius = corner.size.width; + maskedCorners |= corner.mask; + } + + if (useCoreAnimationMask && radius > 0 && radius <= MIN(bounds.size.width, bounds.size.height) / 2) { + // Core Animation supports a shared radius on any subset of corners. A + // rounded-rect CGPath would replace the requested continuous curve with + // circular arcs, including when only the top or bottom corners are round. + // Larger asymmetric radii need the path renderer: a native layer distorts + // the square corners when its radius exceeds half the shorter dimension. + CALayer *maskLayer = [CALayer layer]; + maskLayer.frame = bounds; + maskLayer.backgroundColor = UIColor.blackColor.CGColor; + maskLayer.cornerRadius = radius; + maskLayer.cornerCurve = CornerCurveFromBorderCurve(borderCurve); + maskLayer.maskedCorners = maskedCorners; + return maskLayer; + } + } + CGPathRef path = RCTPathCreateWithRoundedRect(bounds, cornerInsets, nil, NO); CAShapeLayer *maskLayer = [CAShapeLayer layer]; maskLayer.path = path; diff --git a/packages/rn-tester/js/examples/View/ViewBorderCurveExample.js b/packages/rn-tester/js/examples/View/ViewBorderCurveExample.js new file mode 100644 index 000000000000..772212732bae --- /dev/null +++ b/packages/rn-tester/js/examples/View/ViewBorderCurveExample.js @@ -0,0 +1,176 @@ +/** + * 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. + * + * @flow strict-local + * @format + */ + +import type {ViewStyle} from 'react-native'; + +import RNTesterText from '../../components/RNTesterText'; +import * as React from 'react'; +import {Image, StyleSheet, View} from 'react-native'; + +type Example = { + label: string, + corners: Pick< + ViewStyle, + | 'borderRadius' + | 'borderTopLeftRadius' + | 'borderTopRightRadius' + | 'borderBottomLeftRadius' + | 'borderBottomRightRadius', + >, + clip: boolean, + size?: ViewStyle, + image?: boolean, + asymmetricBorder?: boolean, +}; + +export default function ViewBorderCurveExample({ + boundaryCases = false, + continuous = true, +}: { + boundaryCases?: boolean, + continuous?: boolean, +}): React.Node { + const [loadedImages, setLoadedImages] = React.useState>( + () => new Set(), + ); + const curve: 'continuous' | 'circular' = continuous + ? 'continuous' + : 'circular'; + const examples: ReadonlyArray = boundaryCases + ? [ + {label: 'One corner', corners: styles.single, clip: true}, + {label: 'Diagonal corners', corners: styles.diagonal, clip: true}, + {label: 'Unequal radii', corners: styles.unequal, clip: true}, + {label: 'Large radius', corners: styles.large, clip: true}, + { + label: 'Narrow box', + corners: styles.single, + clip: true, + size: styles.narrow, + }, + { + label: 'Image with border', + corners: styles.imageCorners, + clip: true, + image: true, + }, + { + label: 'Unequal image insets', + corners: styles.imageCorners, + clip: true, + image: true, + asymmetricBorder: true, + }, + ] + : [ + {label: 'All corners: background', corners: styles.all, clip: false}, + { + label: 'Bottom corners: background', + corners: styles.bottom, + clip: false, + }, + { + label: 'Bottom corners: clipped child', + corners: styles.bottom, + clip: true, + }, + {label: 'Top corners: clipped child', corners: styles.top, clip: true}, + ]; + + return ( + + Curve: {curve} + + Red marks the circular reference underneath. + + + {examples.map( + ({label, corners, clip, size, image, asymmetricBorder}) => { + const foregroundStyle = [ + styles.foreground, + corners, + {borderCurve: curve}, + clip && styles.clipped, + ]; + return ( + + {label} + + {image === true ? ( + + setLoadedImages(loaded => new Set(loaded).add(label)) + } + style={[ + foregroundStyle, + styles.image, + asymmetricBorder === true && styles.asymmetricBorder, + ]} + /> + ) : ( + + {clip && ( + + )} + + )} + + + ); + }, + )} + + {boundaryCases && loadedImages.size === 2 && ( + + Images loaded + + )} + + ); +} + +const styles = StyleSheet.create({ + container: {padding: 12, gap: 8, backgroundColor: 'white'}, + label: {color: 'black', fontSize: 12}, + list: {gap: 8}, + grid: {flexDirection: 'row', flexWrap: 'wrap', gap: 12}, + cell: {width: 150}, + reference: {width: 240, height: 110, backgroundColor: 'red'}, + compact: {width: 150, height: 76}, + narrow: {width: 36}, + foreground: {...StyleSheet.absoluteFill}, + all: {borderRadius: 36}, + bottom: {borderBottomLeftRadius: 36, borderBottomRightRadius: 36}, + top: {borderTopLeftRadius: 36, borderTopRightRadius: 36}, + single: {borderBottomLeftRadius: 24}, + diagonal: {borderTopLeftRadius: 24, borderBottomRightRadius: 24}, + unequal: {borderBottomLeftRadius: 12, borderBottomRightRadius: 24}, + large: {borderBottomLeftRadius: 60, borderBottomRightRadius: 60}, + imageCorners: {borderRadius: 24}, + clipped: {overflow: 'hidden'}, + background: {backgroundColor: '#527FE4'}, + image: { + width: '100%', + height: '100%', + tintColor: '#527FE4', + borderWidth: 4, + borderColor: '#222', + }, + asymmetricBorder: {borderLeftWidth: 10}, +}); diff --git a/packages/rn-tester/js/examples/View/ViewExample.js b/packages/rn-tester/js/examples/View/ViewExample.js index d72f0ec264a6..572793dec04c 100644 --- a/packages/rn-tester/js/examples/View/ViewExample.js +++ b/packages/rn-tester/js/examples/View/ViewExample.js @@ -13,6 +13,7 @@ import type {RNTesterModule} from '../../types/RNTesterTypes'; import RNTesterText from '../../components/RNTesterText'; +import ViewBorderCurveExample from './ViewBorderCurveExample'; import * as React from 'react'; import {useState} from 'react'; import { @@ -927,6 +928,34 @@ export default { ); }, }, + { + title: 'Border Curve (iOS)', + name: 'border-curve', + render(): React.Node { + return ; + }, + }, + { + title: 'Border Curve Circular Control (iOS)', + name: 'border-curve-circular', + render(): React.Node { + return ; + }, + }, + { + title: 'Border Curve Boundaries (iOS)', + name: 'border-curve-boundaries', + render(): React.Node { + return ; + }, + }, + { + title: 'Border Curve Boundaries Circular Control (iOS)', + name: 'border-curve-boundaries-circular', + render(): React.Node { + return ; + }, + }, { title: 'Border Style', name: 'border-style', diff --git a/packages/rn-tester/visual-tests/flows/view-border-curve.yml b/packages/rn-tester/visual-tests/flows/view-border-curve.yml new file mode 100644 index 000000000000..9126945b0485 --- /dev/null +++ b/packages/rn-tester/visual-tests/flows/view-border-curve.yml @@ -0,0 +1,26 @@ +appId: ${APP_ID} +--- +- runFlow: + when: + platform: iOS + commands: + - runFlow: + file: ../helpers/capture-border-curve.yml + env: + EXAMPLE: border-curve + WAIT_FOR_IMAGES: false + - runFlow: + file: ../helpers/capture-border-curve.yml + env: + EXAMPLE: border-curve-circular + WAIT_FOR_IMAGES: false + - runFlow: + file: ../helpers/capture-border-curve.yml + env: + EXAMPLE: border-curve-boundaries + WAIT_FOR_IMAGES: true + - runFlow: + file: ../helpers/capture-border-curve.yml + env: + EXAMPLE: border-curve-boundaries-circular + WAIT_FOR_IMAGES: true diff --git a/packages/rn-tester/visual-tests/helpers/capture-border-curve.yml b/packages/rn-tester/visual-tests/helpers/capture-border-curve.yml new file mode 100644 index 000000000000..705112f90a37 --- /dev/null +++ b/packages/rn-tester/visual-tests/helpers/capture-border-curve.yml @@ -0,0 +1,34 @@ +appId: ${APP_ID} +--- +- launchApp: + stopApp: true +- runFlow: + when: + visible: 'Open' + commands: + - tapOn: 'Open' +- extendedWaitUntil: + visible: 'Components' + timeout: 30000 +- openLink: rntester://example/ViewExample/${EXAMPLE} +- tapOn: + text: 'Open' + optional: true +- extendedWaitUntil: + visible: + id: 'view-border-curve-example' + timeout: 15000 +- runFlow: + when: + true: ${WAIT_FOR_IMAGES} + commands: + - extendedWaitUntil: + visible: + id: 'border-curve-image-loaded' + timeout: 15000 +- waitForAnimationToEnd: + timeout: 5000 +- takeScreenshot: + path: ${VISUAL_OUTPUT_DIR}/view/${EXAMPLE} + cropOn: + id: 'view-border-curve-example'