From 21941cca3e29d4f1c137c45eed216e86379eae01 Mon Sep 17 00:00:00 2001 From: Kamil Paradowski Date: Wed, 26 Nov 2025 10:38:01 +0100 Subject: [PATCH 1/6] feat: add clip-path support for Android --- .../uimanager/BackgroundStyleApplicator.kt | 73 +++++ .../react/uimanager/BaseViewManager.java | 6 + .../uimanager/BaseViewManagerDelegate.kt | 2 + .../com/facebook/react/uimanager/ViewProps.kt | 1 + .../react/uimanager/style/ClipPath.kt | 255 ++++++++++++++++++ .../react/uimanager/style/ClipPathUtils.kt | 216 +++++++++++++++ .../react/views/image/ReactImageView.kt | 8 + .../views/text/PreparedLayoutTextView.kt | 7 +- .../react/views/text/ReactTextView.java | 10 + .../react/views/textinput/ReactEditText.kt | 9 + .../react/views/view/GeometryBoxUtil.kt | 178 ++++++++++++ .../react/views/view/ReactViewGroup.kt | 8 + .../react/views/view/ReactViewManager.kt | 87 ++++++ .../main/res/views/uimanager/values/ids.xml | 3 + 14 files changed, 861 insertions(+), 2 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ClipPath.kt create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ClipPathUtils.kt create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/GeometryBoxUtil.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt index a84dd86fbc4fa1..7c5a4ce2df56cb 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt @@ -20,7 +20,9 @@ import android.os.Build import android.view.View import android.widget.ImageView import androidx.annotation.ColorInt +import com.facebook.react.R import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap import com.facebook.react.common.annotations.UnstableReactNativeAPI import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags import com.facebook.react.uimanager.PixelUtil.dpToPx @@ -45,8 +47,12 @@ import com.facebook.react.uimanager.style.BorderRadiusProp import com.facebook.react.uimanager.style.BorderRadiusStyle import com.facebook.react.uimanager.style.BorderStyle import com.facebook.react.uimanager.style.BoxShadow +import com.facebook.react.uimanager.style.ClipPath +import com.facebook.react.uimanager.style.ClipPathUtils import com.facebook.react.uimanager.style.LogicalEdge import com.facebook.react.uimanager.style.OutlineStyle +import com.facebook.react.views.view.GeometryBoxUtil +import com.facebook.react.views.view.GeometryBoxUtil.getGeometryBoxBounds /** * Utility object responsible for applying backgrounds, borders, and related visual effects to @@ -463,6 +469,73 @@ public object BackgroundStyleApplicator { BackgroundStyleApplicator.setBoxShadow(view, shadowStyles) } + @JvmStatic + public fun setClipPath(view: View, clipPathMap: ReadableMap?) { + if (ViewUtil.getUIManagerType(view) != UIManagerType.FABRIC) { + return + } + + val clipPath = ClipPath.parse(clipPathMap) + view.setTag(R.id.clip_path, clipPath) + view.invalidate() + } + + @JvmStatic + public fun applyClipPathIfPresent(view: View, canvas: Canvas) { + val clipPath = view.getTag(R.id.clip_path) as? ClipPath ?: return + val bounds = getGeometryBoxBounds(view, clipPath.geometryBox, getComputedBorderInsets(view)) + val drawingRect = Rect() + view.getDrawingRect(drawingRect) + + val path: Path? = if (clipPath.shape != null) { + ClipPathUtils.createPathFromBasicShape(clipPath.shape, bounds) + } else if (clipPath.geometryBox != null) { + val composite = getCompositeBackgroundDrawable(view) + val borderRadius = composite?.borderRadius + val computedBorderInsets = + composite?.borderInsets?.resolve(composite.layoutDirection, view.context) + + if (borderRadius != null) { + val adjustedBorderRadius = GeometryBoxUtil.adjustBorderRadiusForGeometryBox( + clipPath.geometryBox, + borderRadius.resolve( + composite.layoutDirection, + view.context, + PixelUtil.toDIPFromPixel(drawingRect.width().toFloat()), + PixelUtil.toDIPFromPixel(drawingRect.height().toFloat()) + ), + computedBorderInsets, + view + ) + + if (adjustedBorderRadius != null) { + ClipPathUtils.createRoundedRectPath(bounds, adjustedBorderRadius) + } else { + null + } + } else { + null + } + } else { + null + } + + if (path != null) { + canvas.clipPath(path) + } else { + canvas.clipRect(bounds) + } + } + + @JvmStatic + public fun getComputedBorderInsets(view: View): RectF? { + val composite = getCompositeBackgroundDrawable(view) + if (composite == null) { + return null + } + return composite.borderInsets?.resolve(composite.layoutDirection, view.context) + } + /** * Sets a feedback underlay drawable for the view. * diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java index 9455615e0d5ff9..c7388799564cc7 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java @@ -129,6 +129,7 @@ public BaseViewManager(@Nullable ReactApplicationContext reactContext) { view.setTag(R.id.use_hardware_layer, null); view.setTag(R.id.filter, null); view.setTag(R.id.mix_blend_mode, null); + view.setTag(R.id.clip_path, null); LayerEffectsHelper.apply(view, null, null); // setShadowColor @@ -860,6 +861,11 @@ public void setBoxShadow(T view, @Nullable ReadableArray shadows) { BackgroundStyleApplicator.setBoxShadow(view, shadows); } + @ReactProp(name = ViewProps.CLIP_PATH, customType = "ClipPath") + public void setClipPath(T view, @Nullable ReadableMap clipPath) { + BackgroundStyleApplicator.setClipPath(view, clipPath); + } + private void logUnsupportedPropertyWarning(String propName) { FLog.w(ReactConstants.TAG, "%s doesn't support property '%s'", getName(), propName); } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerDelegate.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerDelegate.kt index 9fca177f3b149a..6f6c5f63abc4a2 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerDelegate.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerDelegate.kt @@ -82,6 +82,8 @@ public abstract class BaseViewManagerDelegate< ViewProps.BOX_SHADOW -> mViewManager.setBoxShadow(view, value as ReadableArray?) + ViewProps.CLIP_PATH -> mViewManager.setClipPath(view, value as ReadableMap?) + ViewProps.ELEVATION -> mViewManager.setElevation(view, (value as Double?)?.toFloat() ?: 0.0f) ViewProps.FILTER -> mViewManager.setFilter(view, value as ReadableArray?) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt index 16176a0f29c245..c612ca22b533d2 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt @@ -137,6 +137,7 @@ public object ViewProps { public const val BORDER_START_COLOR: String = "borderStartColor" public const val BORDER_END_COLOR: String = "borderEndColor" public const val BOX_SHADOW: String = "boxShadow" + public const val CLIP_PATH: String = "clipPath" public const val FILTER: String = "filter" public const val MIX_BLEND_MODE: String = "mixBlendMode" public const val OUTLINE_COLOR: String = "outlineColor" diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ClipPath.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ClipPath.kt new file mode 100644 index 00000000000000..7fcbc5a30fb4d6 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ClipPath.kt @@ -0,0 +1,255 @@ +/* + * 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. + */ + +package com.facebook.react.uimanager.style + +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType +import com.facebook.react.uimanager.LengthPercentage + +private fun getOptionalLengthPercentage(map: ReadableMap, key: String): LengthPercentage? { + return if (map.hasKey(key)) { + LengthPercentage.setFromDynamic(map.getDynamic(key)) + } else { + null + } +} + +public data class CircleShape( + val r: LengthPercentage? = null, + val cx: LengthPercentage? = null, + val cy: LengthPercentage? = null, +) { + public companion object { + public fun parse(map: ReadableMap): CircleShape? { + val r = getOptionalLengthPercentage(map, "r") + val cx = getOptionalLengthPercentage(map, "cx") + val cy = getOptionalLengthPercentage(map, "cy") + return CircleShape(r, cx, cy) + } + } +} + +public data class EllipseShape( + val rx: LengthPercentage? = null, + val ry: LengthPercentage? = null, + val cx: LengthPercentage? = null, + val cy: LengthPercentage? = null, +) { + public companion object { + public fun parse(map: ReadableMap): EllipseShape? { + val rx = getOptionalLengthPercentage(map, "rx") + val ry = getOptionalLengthPercentage(map, "ry") + val cx = getOptionalLengthPercentage(map, "cx") + val cy = getOptionalLengthPercentage(map, "cy") + return EllipseShape(rx, ry, cx, cy) + } + } +} + +public data class InsetShape( + val top: LengthPercentage, + val right: LengthPercentage, + val bottom: LengthPercentage, + val left: LengthPercentage, + val borderRadius: LengthPercentage? = null, +) { + public companion object { + public fun parse(map: ReadableMap): InsetShape? { + val top = getOptionalLengthPercentage(map, "top") ?: return null + val right = getOptionalLengthPercentage(map, "right") ?: return null + val bottom = getOptionalLengthPercentage(map, "bottom") ?: return null + val left = getOptionalLengthPercentage(map, "left") ?: return null + val borderRadius = getOptionalLengthPercentage(map, "borderRadius") + return InsetShape(top, right, bottom, left, borderRadius) + } + } +} + +public enum class FillRule { + NonZero, + EvenOdd; + + public companion object { + public fun fromString(value: String): FillRule { + return when (value.lowercase()) { + "nonzero" -> NonZero + "evenodd" -> EvenOdd + else -> NonZero + } + } + } +} + +public data class PolygonShape( + val points: List>, + val fillRule: FillRule? = null, +) { + public companion object { + public fun parse(map: ReadableMap): PolygonShape? { + if (!map.hasKey("points")) return null + val pointsArray = map.getArray("points") ?: return null + val points = mutableListOf>() + + for (i in 0 until pointsArray.size()) { + val pointMap = pointsArray.getMap(i) ?: continue + val x = getOptionalLengthPercentage(pointMap, "x") ?: continue + val y = getOptionalLengthPercentage(pointMap, "y") ?: continue + points.add(Pair(x, y)) + } + + val fillRule = + if (map.hasKey("fillRule")) { + FillRule.fromString(map.getString("fillRule") ?: "nonzero") + } else { + null + } + + return PolygonShape(points, fillRule) + } + } +} + +public data class RectShape( + val top: LengthPercentage, + val right: LengthPercentage, + val bottom: LengthPercentage, + val left: LengthPercentage, + val borderRadius: LengthPercentage? = null, +) { + public companion object { + public fun parse(map: ReadableMap): RectShape? { + val top = getOptionalLengthPercentage(map, "top") ?: return null + val right = getOptionalLengthPercentage(map, "right") ?: return null + val bottom = getOptionalLengthPercentage(map, "bottom") ?: return null + val left = getOptionalLengthPercentage(map, "left") ?: return null + val borderRadius = getOptionalLengthPercentage(map, "borderRadius") + return RectShape(top, right, bottom, left, borderRadius) + } + } +} + +public data class XywhShape( + val x: LengthPercentage, + val y: LengthPercentage, + val width: LengthPercentage, + val height: LengthPercentage, + val borderRadius: LengthPercentage? = null, +) { + public companion object { + public fun parse(map: ReadableMap): XywhShape? { + val x = getOptionalLengthPercentage(map, "x") ?: return null + val y = getOptionalLengthPercentage(map, "y") ?: return null + val width = getOptionalLengthPercentage(map, "width") ?: return null + val height = getOptionalLengthPercentage(map, "height") ?: return null + val borderRadius = getOptionalLengthPercentage(map, "borderRadius") + return XywhShape(x, y, width, height, borderRadius) + } + } +} + +public sealed class BasicShape { + public data class Circle(val shape: CircleShape) : BasicShape() + + public data class Ellipse(val shape: EllipseShape) : BasicShape() + + public data class Inset(val shape: InsetShape) : BasicShape() + + public data class Polygon(val shape: PolygonShape) : BasicShape() + + public data class Rect(val shape: RectShape) : BasicShape() + + public data class Xywh(val shape: XywhShape) : BasicShape() + + public companion object { + public fun parse(map: ReadableMap): BasicShape? { + if (!map.hasKey("type")) return null + val type = map.getString("type") ?: return null + + return when (type.lowercase()) { + "circle" -> { + val circle = CircleShape.parse(map) ?: return null + Circle(circle) + } + "ellipse" -> { + val ellipse = EllipseShape.parse(map) ?: return null + Ellipse(ellipse) + } + "inset" -> { + val inset = InsetShape.parse(map) ?: return null + Inset(inset) + } + "polygon" -> { + val polygon = PolygonShape.parse(map) ?: return null + Polygon(polygon) + } + "rect" -> { + val rect = RectShape.parse(map) ?: return null + Rect(rect) + } + "xywh" -> { + val xywh = XywhShape.parse(map) ?: return null + Xywh(xywh) + } + else -> null + } + } + } +} + +public enum class GeometryBox { + MarginBox, + BorderBox, + ContentBox, + PaddingBox, + FillBox, + StrokeBox, + ViewBox; + + public companion object { + public fun fromString(value: String): GeometryBox { + return when (value.lowercase()) { + "margin-box" -> MarginBox + "border-box" -> BorderBox + "content-box" -> ContentBox + "padding-box" -> PaddingBox + "fill-box" -> FillBox + "stroke-box" -> StrokeBox + "view-box" -> ViewBox + else -> BorderBox + } + } + } +} + +public data class ClipPath( + val shape: BasicShape? = null, + val geometryBox: GeometryBox? = null, +) { + public companion object { + public fun parse(map: ReadableMap?): ClipPath? { + if (map == null) return null + + val shape = + if (map.hasKey("shape") && map.getType("shape") == ReadableType.Map) { + val shapeMap = map.getMap("shape") + if (shapeMap != null) BasicShape.parse(shapeMap) else null + } else { + null + } + + val geometryBox = + if (map.hasKey("geometryBox")) { + GeometryBox.fromString(map.getString("geometryBox") ?: "border-box") + } else { + null + } + + return ClipPath(shape, geometryBox) + } + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ClipPathUtils.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ClipPathUtils.kt new file mode 100644 index 00000000000000..2867a37503b11d --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ClipPathUtils.kt @@ -0,0 +1,216 @@ +/* + * 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. + */ + +package com.facebook.react.uimanager.style + +import android.graphics.Path +import android.graphics.Path.FillType +import android.graphics.RectF +import com.facebook.react.uimanager.LengthPercentage +import com.facebook.react.uimanager.LengthPercentageType +import com.facebook.react.uimanager.PixelUtil + +public object ClipPathUtils { + + private fun resolveLengthPercentage(lengthPercentage: LengthPercentage, referenceDimension: Float): Float { + return when (lengthPercentage.type) { + LengthPercentageType.POINT -> PixelUtil.toPixelFromDIP(lengthPercentage.resolve(1f)) + LengthPercentageType.PERCENT -> lengthPercentage.resolve(referenceDimension) + } + } + + private fun addRectWithOptionalBorderRadius( + path: Path, + rect: RectF, + borderRadius: LengthPercentage?, + ) { + if (borderRadius != null) { + val referenceDimension = minOf(rect.width(), rect.height()) + val radius = resolveLengthPercentage(borderRadius, referenceDimension) + path.addRoundRect(rect, radius, radius, Path.Direction.CW) + } else { + path.addRect(rect, Path.Direction.CW) + } + } + + public fun createPathFromBasicShape(basicShape: BasicShape, bounds: RectF): Path? { + return when (basicShape) { + is BasicShape.Circle -> createCirclePath(basicShape.shape, bounds) + is BasicShape.Ellipse -> createEllipsePath(basicShape.shape, bounds) + is BasicShape.Inset -> createInsetPath(basicShape.shape, bounds) + is BasicShape.Polygon -> createPolygonPath(basicShape.shape, bounds) + is BasicShape.Rect -> createRectPath(basicShape.shape, bounds) + is BasicShape.Xywh -> createXywhPath(basicShape.shape, bounds) + } + } + + private fun createCirclePath(circle: CircleShape, bounds: RectF): Path { + val path = Path() + + // Resolve radius (use smaller dimension as reference for percentages, matching CSS closest-side) + // Default to 50% of closest-side if radius is not specified + val referenceDimension = minOf(bounds.width(), bounds.height()) + val radius = if (circle.r != null) { + resolveLengthPercentage(circle.r, referenceDimension) + } else { + // Default to 50% of closest-side (min(width, height) / 2) + referenceDimension / 2.0f + } + + // Resolve center (default to center of bounds) + val cx = + if (circle.cx != null) { + bounds.left + resolveLengthPercentage(circle.cx, bounds.width()) + } else { + bounds.centerX() + } + + val cy = + if (circle.cy != null) { + bounds.top + resolveLengthPercentage(circle.cy, bounds.height()) + } else { + bounds.centerY() + } + + path.addCircle(cx, cy, radius, Path.Direction.CW) + return path + } + + private fun createEllipsePath(ellipse: EllipseShape, bounds: RectF): Path { + val path = Path() + + // Resolve radii (default to 50% if not specified) + val rx = if (ellipse.rx != null) { + resolveLengthPercentage(ellipse.rx, bounds.width()) + } else { + bounds.width() / 2.0f + } + val ry = if (ellipse.ry != null) { + resolveLengthPercentage(ellipse.ry, bounds.height()) + } else { + bounds.height() / 2.0f + } + + // Resolve center (default to center of bounds) + val cx = + if (ellipse.cx != null) { + bounds.left + resolveLengthPercentage(ellipse.cx, bounds.width()) + } else { + bounds.centerX() + } + + val cy = + if (ellipse.cy != null) { + bounds.top + resolveLengthPercentage(ellipse.cy, bounds.height()) + } else { + bounds.centerY() + } + + val oval = RectF(cx - rx, cy - ry, cx + rx, cy + ry) + path.addOval(oval, Path.Direction.CW) + return path + } + + private fun createInsetPath(inset: InsetShape, bounds: RectF): Path? { + val path = Path() + + val top = bounds.top + resolveLengthPercentage(inset.top, bounds.height()) + val right = bounds.right - resolveLengthPercentage(inset.right, bounds.width()) + val bottom = bounds.bottom - resolveLengthPercentage(inset.bottom, bounds.height()) + val left = bounds.left + resolveLengthPercentage(inset.left, bounds.width()) + + val rect = RectF(left, top, right, bottom) + if (rect.width() < 0f || rect.height() < 0f) { + return null + } + + addRectWithOptionalBorderRadius(path, rect, inset.borderRadius) + return path + } + + private fun createPolygonPath(polygon: PolygonShape, bounds: RectF): Path? { + val path = Path() + + if (polygon.points.isEmpty()) { + return null + } + + when (polygon.fillRule) { + FillRule.EvenOdd -> path.fillType = FillType.EVEN_ODD + FillRule.NonZero, + null -> path.fillType = FillType.WINDING + } + + val firstPoint = polygon.points[0] + val firstX = bounds.left + resolveLengthPercentage(firstPoint.first, bounds.width()) + val firstY = bounds.top + resolveLengthPercentage(firstPoint.second, bounds.height()) + path.moveTo(firstX, firstY) + + for (i in 1 until polygon.points.size) { + val point = polygon.points[i] + val x = bounds.left + resolveLengthPercentage(point.first, bounds.width()) + val y = bounds.top + resolveLengthPercentage(point.second, bounds.height()) + path.lineTo(x, y) + } + + path.close() + return path + } + + private fun createRectPath(rect: RectShape, bounds: RectF): Path? { + val path = Path() + + val top = bounds.top + resolveLengthPercentage(rect.top, bounds.height()) + val right = bounds.left + resolveLengthPercentage(rect.right, bounds.width()) + val bottom = bounds.top + resolveLengthPercentage(rect.bottom, bounds.height()) + val left = bounds.left + resolveLengthPercentage(rect.left, bounds.width()) + + val rectF = RectF(left, top, right, bottom) + if (rectF.width() < 0f || rectF.height() < 0f) { + return null + } + + addRectWithOptionalBorderRadius(path, rectF, rect.borderRadius) + return path + } + + private fun createXywhPath(xywh: XywhShape, bounds: RectF): Path? { + val path = Path() + + val x = bounds.left + resolveLengthPercentage(xywh.x, bounds.width()) + val y = bounds.top + resolveLengthPercentage(xywh.y, bounds.height()) + val width = resolveLengthPercentage(xywh.width, bounds.width()) + val height = resolveLengthPercentage(xywh.height, bounds.height()) + + val rect = RectF(x, y, x + width, y + height) + if (rect.width() < 0f || rect.height() < 0f) { + return null + } + + addRectWithOptionalBorderRadius(path, rect, xywh.borderRadius) + return path + } + + public fun createRoundedRectPath(bounds: RectF, borderRadius: ComputedBorderRadius): Path { + val path = Path() + + val topLeftRadii = borderRadius.topLeft + val topRightRadii = borderRadius.topRight + val bottomRightRadii = borderRadius.bottomRight + val bottomLeftRadii = borderRadius.bottomLeft + + val radii = floatArrayOf( + topLeftRadii.horizontal, topLeftRadii.vertical, + topRightRadii.horizontal, topRightRadii.vertical, + bottomRightRadii.horizontal, bottomRightRadii.vertical, + bottomLeftRadii.horizontal, bottomLeftRadii.vertical + ) + + path.addRoundRect(bounds, radii, Path.Direction.CW) + return path + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.kt index ecbcc9b3083b24..b516f0bcc7d8a1 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.kt @@ -22,6 +22,7 @@ import android.graphics.Shader.TileMode import android.graphics.drawable.Animatable import android.graphics.drawable.Drawable import android.net.Uri +import androidx.core.graphics.withSave import com.facebook.common.references.CloseableReference import com.facebook.common.util.UriUtil import com.facebook.drawee.backends.pipeline.Fresco @@ -371,6 +372,13 @@ public class ReactImageView( // or outline which may draw outside of bounds. public override fun hasOverlappingRendering(): Boolean = false + public override fun draw(canvas: Canvas) { + canvas.withSave { + BackgroundStyleApplicator.applyClipPathIfPresent(this@ReactImageView, this) + super.draw(this) + } + } + public override fun onDraw(canvas: Canvas) { BackgroundStyleApplicator.clipToPaddingBoxWithAntiAliasing(this, canvas) { try { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index 38570086f38e1f..ebb3000e39410b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -22,6 +22,7 @@ import android.view.ViewGroup import androidx.annotation.ColorInt import androidx.annotation.DoNotInline import androidx.annotation.RequiresApi +import androidx.core.graphics.withSave import androidx.core.view.ViewCompat import com.facebook.proguard.annotations.DoNotStrip import com.facebook.react.uimanager.BackgroundStyleApplicator @@ -105,8 +106,10 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re if (overflow != Overflow.VISIBLE) { BackgroundStyleApplicator.clipToPaddingBox(this, canvas) } - - super.onDraw(canvas) + canvas.withSave { + BackgroundStyleApplicator.applyClipPathIfPresent(this@PreparedLayoutTextView, this) + super.onDraw(canvas) + } canvas.translate( paddingLeft.toFloat(), paddingTop.toFloat() + (preparedLayout?.verticalOffset ?: 0f), diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index 906dfbce04905d..d6ba3db062ae6c 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -33,6 +33,7 @@ import com.facebook.common.logging.FLog; import com.facebook.infer.annotation.Assertions; import com.facebook.infer.annotation.Nullsafe; +import com.facebook.react.R; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.ReactContext; import com.facebook.react.bridge.WritableMap; @@ -50,6 +51,7 @@ import com.facebook.react.uimanager.common.ViewUtil; import com.facebook.react.uimanager.style.BorderRadiusProp; import com.facebook.react.uimanager.style.BorderStyle; +import com.facebook.react.uimanager.style.ClipPath; import com.facebook.react.uimanager.style.LogicalEdge; import com.facebook.react.uimanager.style.Overflow; import com.facebook.react.views.text.internal.span.ReactTagSpan; @@ -364,7 +366,15 @@ protected void onDraw(Canvas canvas) { BackgroundStyleApplicator.clipToPaddingBox(this, canvas); } + ClipPath clipPath = (ClipPath) getTag(R.id.clip_path); + if (clipPath != null) { + canvas.save(); + BackgroundStyleApplicator.applyClipPathIfPresent(this, canvas); + } super.onDraw(canvas); + if (clipPath != null) { + canvas.restore(); + } } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt index fb8f4b1bf79ca6..c9155e290d5876 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt @@ -49,6 +49,7 @@ import com.facebook.react.common.ReactConstants import com.facebook.react.common.build.ReactBuildConfig import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags +import com.facebook.react.uimanager.BackgroundStyleApplicator import com.facebook.react.uimanager.BackgroundStyleApplicator.clipToPaddingBox import com.facebook.react.uimanager.BackgroundStyleApplicator.getBackgroundColor import com.facebook.react.uimanager.BackgroundStyleApplicator.getBorderColor @@ -90,6 +91,7 @@ import com.facebook.react.views.text.internal.span.TextInlineImageSpan import java.util.concurrent.CopyOnWriteArrayList import kotlin.math.max import kotlin.math.min +import androidx.core.graphics.withSave /** * A wrapper around the EditText that lets us better control what happens when an EditText gets @@ -1203,6 +1205,13 @@ public open class ReactEditText public constructor(context: Context) : AppCompat invalidate() } + public override fun draw(canvas: Canvas) { + canvas.withSave { + BackgroundStyleApplicator.applyClipPathIfPresent(this@ReactEditText, this) + super.draw(this) + } + } + public override fun onDraw(canvas: Canvas) { if (overflow != Overflow.VISIBLE) { clipToPaddingBox(this, canvas) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/GeometryBoxUtil.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/GeometryBoxUtil.kt new file mode 100644 index 00000000000000..4f18e31a9aebba --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/GeometryBoxUtil.kt @@ -0,0 +1,178 @@ +/* + * 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. + */ + +package com.facebook.react.views.view + +import android.graphics.RectF +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.MarginLayoutParams +import com.facebook.react.uimanager.PixelUtil +import com.facebook.react.uimanager.PixelUtil.dpToPx +import com.facebook.react.uimanager.style.ComputedBorderRadius +import com.facebook.react.uimanager.style.CornerRadii +import com.facebook.react.uimanager.style.GeometryBox +import kotlin.math.max +import kotlin.math.roundToInt + +internal object GeometryBoxUtil { + + @JvmStatic + fun adjustBorderRadiusForGeometryBox( + geometryBox: GeometryBox?, + borderRadius: ComputedBorderRadius?, + computedBorderInsets: RectF?, + view: View + ): ComputedBorderRadius? { + if (borderRadius == null) { + return null + } + + val params = view.layoutParams as? MarginLayoutParams + + return when (geometryBox) { + GeometryBox.MarginBox -> { + // margin-box: extend border-radius by margin amount + val marginLeft = params?.leftMargin?.toFloat() ?: 0f + val marginTop = params?.topMargin?.toFloat() ?: 0f + val marginRight = params?.rightMargin?.toFloat() ?: 0f + val marginBottom = params?.bottomMargin?.toFloat() ?: 0f + + ComputedBorderRadius( + topLeft = CornerRadii( + horizontal = borderRadius.topLeft.horizontal + marginLeft, + vertical = borderRadius.topLeft.vertical + marginTop + ), + topRight = CornerRadii( + horizontal = borderRadius.topRight.horizontal + marginRight, + vertical = borderRadius.topRight.vertical + marginTop + ), + bottomLeft = CornerRadii( + horizontal = borderRadius.bottomLeft.horizontal + marginLeft, + vertical = borderRadius.bottomLeft.vertical + marginBottom + ), + bottomRight = CornerRadii( + horizontal = borderRadius.bottomRight.horizontal + marginRight, + vertical = borderRadius.bottomRight.vertical + marginBottom + ) + ) + } + + GeometryBox.BorderBox, null -> { + // border-box: use border-radius as-is (this is the reference) + ComputedBorderRadius( + topLeft = borderRadius.topLeft.toPixelFromDIP(), + topRight = borderRadius.topRight.toPixelFromDIP(), + bottomLeft = borderRadius.bottomLeft.toPixelFromDIP(), + bottomRight = borderRadius.bottomRight.toPixelFromDIP() + ) + } + + GeometryBox.PaddingBox -> { + // padding-box: reduce border-radius by border width + val borderLeft = computedBorderInsets?.left ?: 0f + val borderTop = computedBorderInsets?.top ?: 0f + val borderRight = computedBorderInsets?.right ?: 0f + val borderBottom = computedBorderInsets?.bottom ?: 0f + + ComputedBorderRadius( + topLeft = CornerRadii( + horizontal = max(0f, borderRadius.topLeft.horizontal - borderLeft).dpToPx(), + vertical = max(0f, borderRadius.topLeft.vertical - borderTop).dpToPx() + ), + topRight = CornerRadii( + horizontal = max(0f, borderRadius.topRight.horizontal - borderRight).dpToPx(), + vertical = max(0f, borderRadius.topRight.vertical - borderTop).dpToPx() + ), + bottomLeft = CornerRadii( + horizontal = max(0f, borderRadius.bottomLeft.horizontal - borderLeft).dpToPx(), + vertical = max(0f, borderRadius.bottomLeft.vertical - borderBottom).dpToPx() + ), + bottomRight = CornerRadii( + horizontal = max(0f, borderRadius.bottomRight.horizontal - borderRight).dpToPx(), + vertical = max(0f, borderRadius.bottomRight.vertical - borderBottom).dpToPx() + ) + ) + } + + GeometryBox.ContentBox -> { + // content-box: reduce border-radius by border width + padding + // padding already includes border width + val paddingLeft = PixelUtil.toDIPFromPixel(view.paddingLeft.toFloat()).roundToInt() + val paddingTop = PixelUtil.toDIPFromPixel(view.paddingTop.toFloat()).roundToInt() + val paddingRight = PixelUtil.toDIPFromPixel(view.paddingRight.toFloat()).roundToInt() + val paddingBottom = PixelUtil.toDIPFromPixel(view.paddingBottom.toFloat()).roundToInt() + + ComputedBorderRadius( + topLeft = CornerRadii( + horizontal = max(0f, borderRadius.topLeft.horizontal - paddingLeft).dpToPx(), + vertical = max(0f, borderRadius.topLeft.vertical - paddingTop).dpToPx() + ), + topRight = CornerRadii( + horizontal = max(0f, borderRadius.topRight.horizontal - paddingRight).dpToPx(), + vertical = max(0f, borderRadius.topRight.vertical - paddingTop).dpToPx() + ), + bottomLeft = CornerRadii( + horizontal = max(0f, borderRadius.bottomLeft.horizontal - paddingLeft).dpToPx(), + vertical = max(0f, borderRadius.bottomLeft.vertical - paddingBottom).dpToPx() + ), + bottomRight = CornerRadii( + horizontal = max(0f, borderRadius.bottomRight.horizontal - paddingRight).dpToPx(), + vertical = max(0f, borderRadius.bottomRight.vertical - paddingBottom).dpToPx() + ) + ) + } + + else -> borderRadius // StrokeBox, ViewBox, FillBox - use border-box as fallback + } + } + + @JvmStatic + fun getGeometryBoxBounds(view: View, geometryBox: GeometryBox?, computedBorderInsets: RectF?): RectF { + val bounds = RectF(0f, 0f, view.width.toFloat(), view.height.toFloat()) + val params = view.layoutParams as? MarginLayoutParams + val box = when (geometryBox) { + GeometryBox.ContentBox -> { + // ContentBox = BorderBox + padding + RectF( + bounds.left + view.paddingLeft, + bounds.top + view.paddingTop, + bounds.right - view.paddingRight, + bounds.bottom - view.paddingBottom + ) + } + + GeometryBox.PaddingBox -> { + // PaddingBox = BorderBox - border + RectF( + bounds.left + (computedBorderInsets?.left?.dpToPx() ?: 0f), + bounds.top + (computedBorderInsets?.top?.dpToPx() ?: 0f), + bounds.right - (computedBorderInsets?.right?.dpToPx() ?: 0f), + bounds.bottom - (computedBorderInsets?.bottom?.dpToPx() ?: 0f) + ) + } + + GeometryBox.MarginBox -> { + // MarginBox = BorderBox + margin + RectF( + bounds.left - (params?.leftMargin?.dpToPx() ?: 0f), + bounds.top - (params?.topMargin?.dpToPx() ?: 0f), + bounds.right + (params?.rightMargin?.dpToPx() ?: 0f), + bounds.bottom + (params?.bottomMargin?.dpToPx() ?: 0f) + ) + } + + GeometryBox.BorderBox, null -> { + // BorderBox = view bounds + bounds + } + + else -> bounds // StrokeBox, ViewBox - use border-box as fallback + } + return box + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt index 396958473d9f02..7c971ff9ce8b5c 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt @@ -36,6 +36,7 @@ import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags import com.facebook.react.touch.OnInterceptTouchEventListener import com.facebook.react.touch.ReactHitSlopView import com.facebook.react.touch.ReactInterceptingViewGroup +import com.facebook.react.uimanager.BackgroundStyleApplicator import com.facebook.react.uimanager.BackgroundStyleApplicator.clipToPaddingBox import com.facebook.react.uimanager.BackgroundStyleApplicator.setBackgroundColor import com.facebook.react.uimanager.BackgroundStyleApplicator.setBorderColor @@ -213,6 +214,9 @@ public open class ReactViewGroup public constructor(context: Context?) : // Reset background, borders updateBackgroundDrawable(null) + // Reset clip path + setTag(R.id.clip_path, null) + resetPointerEvents() // In case a focus was attempted but the view never attached, reset to false @@ -910,9 +914,11 @@ public open class ReactViewGroup public constructor(context: Context?) : (height + -overflowInset.bottom).toFloat(), null, ) + BackgroundStyleApplicator.applyClipPathIfPresent(this, canvas) super.draw(canvas) canvas.restore() } else { + BackgroundStyleApplicator.applyClipPathIfPresent(this, canvas) super.draw(canvas) } } @@ -921,6 +927,8 @@ public open class ReactViewGroup public constructor(context: Context?) : if (_overflow != Overflow.VISIBLE || getTag(R.id.filter) != null) { clipToPaddingBox(this, canvas) } + + BackgroundStyleApplicator.applyClipPathIfPresent(this, canvas) super.dispatchDraw(canvas) } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt index 64b6ce3a2e80dc..0cb60e134d1750 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt @@ -9,6 +9,9 @@ package com.facebook.react.views.view import android.graphics.Rect import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams +import android.view.ViewGroup.MarginLayoutParams import com.facebook.common.logging.FLog import com.facebook.react.bridge.Dynamic import com.facebook.react.bridge.DynamicFromObject @@ -46,6 +49,22 @@ import com.facebook.react.uimanager.style.LogicalEdge @ReactModule(name = ReactViewManager.REACT_CLASS) public open class ReactViewManager : ReactClippingViewManager() { + private enum class MarginIndex { + ALL, + VERTICAL, + HORIZONTAL, + LEFT, + RIGHT, + TOP, + BOTTOM, + START, + END; + + companion object { + fun fromIndex(index: Int): MarginIndex? = values().getOrNull(index) + } + } + public companion object { public const val REACT_CLASS: String = ViewProps.VIEW_CLASS_NAME @@ -212,6 +231,13 @@ public open class ReactViewManager : ReactClippingViewManager() } } + @ReactProp(name = ViewProps.CLIP_PATH, customType = "ClipPath") + public override fun setClipPath(view: ReactViewGroup, clipPath: ReadableMap?) { + if (ViewUtil.getUIManagerType(view) == UIManagerType.FABRIC) { + BackgroundStyleApplicator.setClipPath(view, clipPath) + } + } + @ReactProp(name = "nextFocusDown", defaultInt = View.NO_ID) public open fun nextFocusDown(view: ReactViewGroup, viewId: Int) { view.nextFocusDownId = viewId @@ -344,6 +370,67 @@ public open class ReactViewManager : ReactClippingViewManager() view.setNeedsOffscreenAlphaCompositing(needsOffscreenAlphaCompositing) } +@ReactPropGroup( + names = + [ + ViewProps.MARGIN, + ViewProps.MARGIN_VERTICAL, + ViewProps.MARGIN_HORIZONTAL, + ViewProps.MARGIN_LEFT, + ViewProps.MARGIN_RIGHT, + ViewProps.MARGIN_TOP, + ViewProps.MARGIN_BOTTOM, + ViewProps.MARGIN_START, + ViewProps.MARGIN_END + ], + defaultFloat = Float.NaN, + ) + public open fun setMargin(view: ReactViewGroup, index: Int, margin: Float) { + + val layoutParams = view.layoutParams as? MarginLayoutParams ?: MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + val leftMargin = layoutParams.leftMargin; + val topMargin = layoutParams.topMargin; + val rightMargin = layoutParams.rightMargin; + val bottomMargin = layoutParams.bottomMargin; + when (MarginIndex.fromIndex(index)) { + MarginIndex.ALL -> { + layoutParams.setMargins(margin.toInt(), margin.toInt(), margin.toInt(), margin.toInt()) + } + MarginIndex.VERTICAL -> { + layoutParams.setMargins(leftMargin, margin.toInt(), rightMargin, margin.toInt()) + } + MarginIndex.HORIZONTAL -> { + layoutParams.setMargins(margin.toInt(), topMargin, margin.toInt(), bottomMargin) + } + MarginIndex.LEFT -> { + layoutParams.setMargins(margin.toInt(), topMargin, rightMargin, bottomMargin) + } + MarginIndex.RIGHT -> { + layoutParams.setMargins(leftMargin, topMargin, margin.toInt(), bottomMargin) + } + MarginIndex.TOP -> { + layoutParams.setMargins(leftMargin, margin.toInt(), rightMargin, bottomMargin) + } + MarginIndex.BOTTOM -> { + layoutParams.setMargins(leftMargin, topMargin, rightMargin, margin.toInt()) + } + MarginIndex.START -> { + layoutParams.setMargins(margin.toInt(), topMargin, rightMargin, bottomMargin) + } + MarginIndex.END -> { + layoutParams.setMargins(leftMargin, topMargin, margin.toInt(), bottomMargin) + } + null -> { + // Unknown index, do nothing + } + } + view.layoutParams = layoutParams + } + + override fun setPadding(view: ReactViewGroup, left: Int, top: Int, right: Int, bottom: Int) { + view.setPadding(left, top, right, bottom) + } + @ReactPropGroup( names = [ diff --git a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml index a7faf3fd60ccba..4237a954730835 100644 --- a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml +++ b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml @@ -74,4 +74,7 @@ + + + From 867e08f795fd1e60aa8c79528906e57da89bfcf6 Mon Sep 17 00:00:00 2001 From: Kamil Paradowski Date: Tue, 2 Dec 2025 13:21:53 +0100 Subject: [PATCH 2/6] refactor: change visibility to internal --- .../react/uimanager/style/ClipPath.kt | 26 +++++++++---------- .../react/uimanager/style/ClipPathUtils.kt | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ClipPath.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ClipPath.kt index 7fcbc5a30fb4d6..6e4a7371ca97fa 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ClipPath.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ClipPath.kt @@ -19,7 +19,7 @@ private fun getOptionalLengthPercentage(map: ReadableMap, key: String): LengthPe } } -public data class CircleShape( +internal data class CircleShape( val r: LengthPercentage? = null, val cx: LengthPercentage? = null, val cy: LengthPercentage? = null, @@ -34,7 +34,7 @@ public data class CircleShape( } } -public data class EllipseShape( +internal data class EllipseShape( val rx: LengthPercentage? = null, val ry: LengthPercentage? = null, val cx: LengthPercentage? = null, @@ -51,7 +51,7 @@ public data class EllipseShape( } } -public data class InsetShape( +internal data class InsetShape( val top: LengthPercentage, val right: LengthPercentage, val bottom: LengthPercentage, @@ -85,7 +85,7 @@ public enum class FillRule { } } -public data class PolygonShape( +internal data class PolygonShape( val points: List>, val fillRule: FillRule? = null, ) { @@ -114,7 +114,7 @@ public data class PolygonShape( } } -public data class RectShape( +internal data class RectShape( val top: LengthPercentage, val right: LengthPercentage, val bottom: LengthPercentage, @@ -133,7 +133,7 @@ public data class RectShape( } } -public data class XywhShape( +internal data class XywhShape( val x: LengthPercentage, val y: LengthPercentage, val width: LengthPercentage, @@ -153,17 +153,17 @@ public data class XywhShape( } public sealed class BasicShape { - public data class Circle(val shape: CircleShape) : BasicShape() + internal data class Circle(val shape: CircleShape) : BasicShape() - public data class Ellipse(val shape: EllipseShape) : BasicShape() + internal data class Ellipse(val shape: EllipseShape) : BasicShape() - public data class Inset(val shape: InsetShape) : BasicShape() + internal data class Inset(val shape: InsetShape) : BasicShape() - public data class Polygon(val shape: PolygonShape) : BasicShape() + internal data class Polygon(val shape: PolygonShape) : BasicShape() - public data class Rect(val shape: RectShape) : BasicShape() + internal data class Rect(val shape: RectShape) : BasicShape() - public data class Xywh(val shape: XywhShape) : BasicShape() + internal data class Xywh(val shape: XywhShape) : BasicShape() public companion object { public fun parse(map: ReadableMap): BasicShape? { @@ -226,7 +226,7 @@ public enum class GeometryBox { } } -public data class ClipPath( +internal data class ClipPath( val shape: BasicShape? = null, val geometryBox: GeometryBox? = null, ) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ClipPathUtils.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ClipPathUtils.kt index 2867a37503b11d..2655a26397574a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ClipPathUtils.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ClipPathUtils.kt @@ -14,7 +14,7 @@ import com.facebook.react.uimanager.LengthPercentage import com.facebook.react.uimanager.LengthPercentageType import com.facebook.react.uimanager.PixelUtil -public object ClipPathUtils { +internal object ClipPathUtils { private fun resolveLengthPercentage(lengthPercentage: LengthPercentage, referenceDimension: Float): Float { return when (lengthPercentage.type) { From adb425afcd499a5b1cfbdac7d4415ed3c8f363db Mon Sep 17 00:00:00 2001 From: Kamil Paradowski Date: Tue, 2 Dec 2025 13:27:41 +0100 Subject: [PATCH 3/6] refactor: update drawing methods to always use draw() --- .../views/text/PreparedLayoutTextView.kt | 11 ++++++---- .../react/views/text/ReactTextView.java | 22 ++++++++++++------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index ebb3000e39410b..7d8b701078532f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -102,14 +102,17 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re overflow = Overflow.HIDDEN } - override fun onDraw(canvas: Canvas) { - if (overflow != Overflow.VISIBLE) { - BackgroundStyleApplicator.clipToPaddingBox(this, canvas) - } + override fun draw(canvas: Canvas) { canvas.withSave { BackgroundStyleApplicator.applyClipPathIfPresent(this@PreparedLayoutTextView, this) super.onDraw(canvas) } + } + + override fun onDraw(canvas: Canvas) { + if (overflow != Overflow.VISIBLE) { + BackgroundStyleApplicator.clipToPaddingBox(this, canvas) + } canvas.translate( paddingLeft.toFloat(), paddingTop.toFloat() + (preparedLayout?.verticalOffset ?: 0f), diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index d6ba3db062ae6c..8e4ab223ea82c2 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -337,6 +337,20 @@ protected void onLayout( } } + + @Override + public void draw(Canvas canvas) { + ClipPath clipPath = (ClipPath) getTag(R.id.clip_path); + if (clipPath != null) { + canvas.save(); + BackgroundStyleApplicator.applyClipPathIfPresent(this, canvas); + } + super.draw(canvas); + if (clipPath != null) { + canvas.restore(); + } + } + @Override protected void onDraw(Canvas canvas) { try (SystraceSection s = new SystraceSection("ReactTextView.onDraw")) { @@ -366,15 +380,7 @@ protected void onDraw(Canvas canvas) { BackgroundStyleApplicator.clipToPaddingBox(this, canvas); } - ClipPath clipPath = (ClipPath) getTag(R.id.clip_path); - if (clipPath != null) { - canvas.save(); - BackgroundStyleApplicator.applyClipPathIfPresent(this, canvas); - } super.onDraw(canvas); - if (clipPath != null) { - canvas.restore(); - } } } From 36fd052002daa66b02b1087b9b432cc8c8f5455b Mon Sep 17 00:00:00 2001 From: Kamil Paradowski Date: Tue, 2 Dec 2025 13:21:25 +0100 Subject: [PATCH 4/6] refactor: move file to correct dir --- .../facebook/react/uimanager/BackgroundStyleApplicator.kt | 4 ++-- .../{views/view => uimanager/style}/GeometryBoxUtil.kt | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) rename packages/react-native/ReactAndroid/src/main/java/com/facebook/react/{views/view => uimanager/style}/GeometryBoxUtil.kt (95%) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt index 7c5a4ce2df56cb..9d5cbba8dcfb0a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt @@ -49,10 +49,10 @@ import com.facebook.react.uimanager.style.BorderStyle import com.facebook.react.uimanager.style.BoxShadow import com.facebook.react.uimanager.style.ClipPath import com.facebook.react.uimanager.style.ClipPathUtils +import com.facebook.react.uimanager.style.GeometryBoxUtil +import com.facebook.react.uimanager.style.GeometryBoxUtil.getGeometryBoxBounds import com.facebook.react.uimanager.style.LogicalEdge import com.facebook.react.uimanager.style.OutlineStyle -import com.facebook.react.views.view.GeometryBoxUtil -import com.facebook.react.views.view.GeometryBoxUtil.getGeometryBoxBounds /** * Utility object responsible for applying backgrounds, borders, and related visual effects to diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/GeometryBoxUtil.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/GeometryBoxUtil.kt similarity index 95% rename from packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/GeometryBoxUtil.kt rename to packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/GeometryBoxUtil.kt index 4f18e31a9aebba..b0fd6c0509d786 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/GeometryBoxUtil.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/GeometryBoxUtil.kt @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -package com.facebook.react.views.view +package com.facebook.react.uimanager.style import android.graphics.RectF import android.view.View @@ -109,15 +109,15 @@ internal object GeometryBoxUtil { ComputedBorderRadius( topLeft = CornerRadii( - horizontal = max(0f, borderRadius.topLeft.horizontal - paddingLeft).dpToPx(), + horizontal = max(0f, borderRadius.topLeft.horizontal - paddingLeft).dpToPx(), vertical = max(0f, borderRadius.topLeft.vertical - paddingTop).dpToPx() ), topRight = CornerRadii( - horizontal = max(0f, borderRadius.topRight.horizontal - paddingRight).dpToPx(), + horizontal = max(0f, borderRadius.topRight.horizontal - paddingRight).dpToPx(), vertical = max(0f, borderRadius.topRight.vertical - paddingTop).dpToPx() ), bottomLeft = CornerRadii( - horizontal = max(0f, borderRadius.bottomLeft.horizontal - paddingLeft).dpToPx(), + horizontal = max(0f, borderRadius.bottomLeft.horizontal - paddingLeft).dpToPx(), vertical = max(0f, borderRadius.bottomLeft.vertical - paddingBottom).dpToPx() ), bottomRight = CornerRadii( From 295c373abc1a077ca9bbe6590c8b95f49d540875 Mon Sep 17 00:00:00 2001 From: Kamil Paradowski Date: Tue, 2 Dec 2025 15:58:40 +0100 Subject: [PATCH 5/6] fix: offset bounds by drawingRect topLeft --- .../com/facebook/react/uimanager/BackgroundStyleApplicator.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt index 9d5cbba8dcfb0a..9d90b01fac82a6 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt @@ -486,6 +486,7 @@ public object BackgroundStyleApplicator { val bounds = getGeometryBoxBounds(view, clipPath.geometryBox, getComputedBorderInsets(view)) val drawingRect = Rect() view.getDrawingRect(drawingRect) + bounds.offset(drawingRect.left.toFloat(), drawingRect.top.toFloat()) val path: Path? = if (clipPath.shape != null) { ClipPathUtils.createPathFromBasicShape(clipPath.shape, bounds) From aa37081d2c077e58fe37b44d7b74cf3d6147a80b Mon Sep 17 00:00:00 2001 From: Kamil Paradowski Date: Tue, 2 Dec 2025 16:19:19 +0100 Subject: [PATCH 6/6] refactor: run canvas save/restore conditionally --- .../react/uimanager/BackgroundStyleApplicator.kt | 5 ++++- .../com/facebook/react/uimanager/style/ClipPath.kt | 2 +- .../facebook/react/views/image/ReactImageView.kt | 14 ++++++++++---- .../react/views/text/PreparedLayoutTextView.kt | 14 ++++++++++---- .../react/views/textinput/ReactEditText.kt | 14 ++++++++++---- .../facebook/react/views/view/ReactViewGroup.kt | 11 ++++++++++- 6 files changed, 45 insertions(+), 15 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt index 9d90b01fac82a6..f6c79b1c3eef42 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt @@ -480,9 +480,12 @@ public object BackgroundStyleApplicator { view.invalidate() } + @JvmStatic + public fun getClipPath(view: View): ClipPath? = view.getTag(R.id.clip_path) as? ClipPath + @JvmStatic public fun applyClipPathIfPresent(view: View, canvas: Canvas) { - val clipPath = view.getTag(R.id.clip_path) as? ClipPath ?: return + val clipPath = getClipPath(view) ?: return val bounds = getGeometryBoxBounds(view, clipPath.geometryBox, getComputedBorderInsets(view)) val drawingRect = Rect() view.getDrawingRect(drawingRect) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ClipPath.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ClipPath.kt index 6e4a7371ca97fa..db2e91c1ed0b26 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ClipPath.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ClipPath.kt @@ -226,7 +226,7 @@ public enum class GeometryBox { } } -internal data class ClipPath( +public data class ClipPath( val shape: BasicShape? = null, val geometryBox: GeometryBox? = null, ) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.kt index b516f0bcc7d8a1..9667ff80d80fb3 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.kt @@ -22,7 +22,6 @@ import android.graphics.Shader.TileMode import android.graphics.drawable.Animatable import android.graphics.drawable.Drawable import android.net.Uri -import androidx.core.graphics.withSave import com.facebook.common.references.CloseableReference import com.facebook.common.util.UriUtil import com.facebook.drawee.backends.pipeline.Fresco @@ -373,9 +372,16 @@ public class ReactImageView( public override fun hasOverlappingRendering(): Boolean = false public override fun draw(canvas: Canvas) { - canvas.withSave { - BackgroundStyleApplicator.applyClipPathIfPresent(this@ReactImageView, this) - super.draw(this) + val clipPath = BackgroundStyleApplicator.getClipPath(this) + if (clipPath != null) { + canvas.save() + BackgroundStyleApplicator.applyClipPathIfPresent(this, canvas) + } + + super.onDraw(canvas) + + if (clipPath != null) { + canvas.restore() } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index 7d8b701078532f..86c01a3652c937 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -22,7 +22,6 @@ import android.view.ViewGroup import androidx.annotation.ColorInt import androidx.annotation.DoNotInline import androidx.annotation.RequiresApi -import androidx.core.graphics.withSave import androidx.core.view.ViewCompat import com.facebook.proguard.annotations.DoNotStrip import com.facebook.react.uimanager.BackgroundStyleApplicator @@ -103,9 +102,16 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re } override fun draw(canvas: Canvas) { - canvas.withSave { - BackgroundStyleApplicator.applyClipPathIfPresent(this@PreparedLayoutTextView, this) - super.onDraw(canvas) + val clipPath = BackgroundStyleApplicator.getClipPath(this) + if (clipPath != null) { + canvas.save() + BackgroundStyleApplicator.applyClipPathIfPresent(this, canvas) + } + + super.draw(canvas) + + if (clipPath != null) { + canvas.restore() } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt index c9155e290d5876..a3b36e8dd48eb0 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt @@ -91,7 +91,6 @@ import com.facebook.react.views.text.internal.span.TextInlineImageSpan import java.util.concurrent.CopyOnWriteArrayList import kotlin.math.max import kotlin.math.min -import androidx.core.graphics.withSave /** * A wrapper around the EditText that lets us better control what happens when an EditText gets @@ -1206,9 +1205,16 @@ public open class ReactEditText public constructor(context: Context) : AppCompat } public override fun draw(canvas: Canvas) { - canvas.withSave { - BackgroundStyleApplicator.applyClipPathIfPresent(this@ReactEditText, this) - super.draw(this) + val clipPath = BackgroundStyleApplicator.getClipPath(this) + if (clipPath != null) { + canvas.save() + BackgroundStyleApplicator.applyClipPathIfPresent(this, canvas) + } + + super.draw(canvas) + + if (clipPath != null) { + canvas.restore() } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt index 7c971ff9ce8b5c..2c6840808d50b9 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt @@ -928,8 +928,17 @@ public open class ReactViewGroup public constructor(context: Context?) : clipToPaddingBox(this, canvas) } - BackgroundStyleApplicator.applyClipPathIfPresent(this, canvas) + val clipPath = BackgroundStyleApplicator.getClipPath(this) + if (clipPath != null) { + canvas.save() + BackgroundStyleApplicator.applyClipPathIfPresent(this, canvas) + } + super.dispatchDraw(canvas) + + if (clipPath != null) { + canvas.restore() + } } override fun drawChild(canvas: Canvas, child: View, drawingTime: Long): Boolean {