Skip to content

Commit d88ccd2

Browse files
committed
feat: add clip-path support for Android
1 parent 2ac5488 commit d88ccd2

File tree

14 files changed

+861
-2
lines changed

14 files changed

+861
-2
lines changed

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import android.os.Build
2020
import android.view.View
2121
import android.widget.ImageView
2222
import androidx.annotation.ColorInt
23+
import com.facebook.react.R
2324
import com.facebook.react.bridge.ReadableArray
25+
import com.facebook.react.bridge.ReadableMap
2426
import com.facebook.react.common.annotations.UnstableReactNativeAPI
2527
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
2628
import com.facebook.react.uimanager.PixelUtil.dpToPx
@@ -45,8 +47,12 @@ import com.facebook.react.uimanager.style.BorderRadiusProp
4547
import com.facebook.react.uimanager.style.BorderRadiusStyle
4648
import com.facebook.react.uimanager.style.BorderStyle
4749
import com.facebook.react.uimanager.style.BoxShadow
50+
import com.facebook.react.uimanager.style.ClipPath
51+
import com.facebook.react.uimanager.style.ClipPathUtils
4852
import com.facebook.react.uimanager.style.LogicalEdge
4953
import com.facebook.react.uimanager.style.OutlineStyle
54+
import com.facebook.react.views.view.GeometryBoxUtil
55+
import com.facebook.react.views.view.GeometryBoxUtil.getGeometryBoxBounds
5056

5157
/**
5258
* Utility object responsible for applying backgrounds, borders, and related visual effects to
@@ -463,6 +469,73 @@ public object BackgroundStyleApplicator {
463469
BackgroundStyleApplicator.setBoxShadow(view, shadowStyles)
464470
}
465471

472+
@JvmStatic
473+
public fun setClipPath(view: View, clipPathMap: ReadableMap?) {
474+
if (ViewUtil.getUIManagerType(view) != UIManagerType.FABRIC) {
475+
return
476+
}
477+
478+
val clipPath = ClipPath.parse(clipPathMap)
479+
view.setTag(R.id.clip_path, clipPath)
480+
view.invalidate()
481+
}
482+
483+
@JvmStatic
484+
public fun applyClipPathIfPresent(view: View, canvas: Canvas) {
485+
val clipPath = view.getTag(R.id.clip_path) as? ClipPath ?: return
486+
val bounds = getGeometryBoxBounds(view, clipPath.geometryBox, getComputedBorderInsets(view))
487+
val drawingRect = Rect()
488+
view.getDrawingRect(drawingRect)
489+
490+
val path: Path? = if (clipPath.shape != null) {
491+
ClipPathUtils.createPathFromBasicShape(clipPath.shape, bounds)
492+
} else if (clipPath.geometryBox != null) {
493+
val composite = getCompositeBackgroundDrawable(view)
494+
val borderRadius = composite?.borderRadius
495+
val computedBorderInsets =
496+
composite?.borderInsets?.resolve(composite.layoutDirection, view.context)
497+
498+
if (borderRadius != null) {
499+
val adjustedBorderRadius = GeometryBoxUtil.adjustBorderRadiusForGeometryBox(
500+
clipPath.geometryBox,
501+
borderRadius.resolve(
502+
composite.layoutDirection,
503+
view.context,
504+
PixelUtil.toDIPFromPixel(drawingRect.width().toFloat()),
505+
PixelUtil.toDIPFromPixel(drawingRect.height().toFloat())
506+
),
507+
computedBorderInsets,
508+
view
509+
)
510+
511+
if (adjustedBorderRadius != null) {
512+
ClipPathUtils.createRoundedRectPath(bounds, adjustedBorderRadius)
513+
} else {
514+
null
515+
}
516+
} else {
517+
null
518+
}
519+
} else {
520+
null
521+
}
522+
523+
if (path != null) {
524+
canvas.clipPath(path)
525+
} else {
526+
canvas.clipRect(bounds)
527+
}
528+
}
529+
530+
@JvmStatic
531+
public fun getComputedBorderInsets(view: View): RectF? {
532+
val composite = getCompositeBackgroundDrawable(view)
533+
if (composite == null) {
534+
return null
535+
}
536+
return composite.borderInsets?.resolve(composite.layoutDirection, view.context)
537+
}
538+
466539
/**
467540
* Sets a feedback underlay drawable for the view.
468541
*

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ public BaseViewManager(@Nullable ReactApplicationContext reactContext) {
128128
view.setTag(R.id.use_hardware_layer, null);
129129
view.setTag(R.id.filter, null);
130130
view.setTag(R.id.mix_blend_mode, null);
131+
view.setTag(R.id.clip_path, null);
131132
LayerEffectsHelper.apply(view, null, null);
132133

133134
// setShadowColor
@@ -858,6 +859,11 @@ public void setBoxShadow(T view, @Nullable ReadableArray shadows) {
858859
BackgroundStyleApplicator.setBoxShadow(view, shadows);
859860
}
860861

862+
@ReactProp(name = ViewProps.CLIP_PATH, customType = "ClipPath")
863+
public void setClipPath(T view, @Nullable ReadableMap clipPath) {
864+
BackgroundStyleApplicator.setClipPath(view, clipPath);
865+
}
866+
861867
private void logUnsupportedPropertyWarning(String propName) {
862868
FLog.w(ReactConstants.TAG, "%s doesn't support property '%s'", getName(), propName);
863869
}

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerDelegate.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ public abstract class BaseViewManagerDelegate<
8282

8383
ViewProps.BOX_SHADOW -> mViewManager.setBoxShadow(view, value as ReadableArray?)
8484

85+
ViewProps.CLIP_PATH -> mViewManager.setClipPath(view, value as ReadableMap?)
86+
8587
ViewProps.ELEVATION -> mViewManager.setElevation(view, (value as Double?)?.toFloat() ?: 0.0f)
8688

8789
ViewProps.FILTER -> mViewManager.setFilter(view, value as ReadableArray?)

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ public object ViewProps {
137137
public const val BORDER_START_COLOR: String = "borderStartColor"
138138
public const val BORDER_END_COLOR: String = "borderEndColor"
139139
public const val BOX_SHADOW: String = "boxShadow"
140+
public const val CLIP_PATH: String = "clipPath"
140141
public const val FILTER: String = "filter"
141142
public const val MIX_BLEND_MODE: String = "mixBlendMode"
142143
public const val OUTLINE_COLOR: String = "outlineColor"
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.uimanager.style
9+
10+
import com.facebook.react.bridge.ReadableMap
11+
import com.facebook.react.bridge.ReadableType
12+
import com.facebook.react.uimanager.LengthPercentage
13+
14+
private fun getOptionalLengthPercentage(map: ReadableMap, key: String): LengthPercentage? {
15+
return if (map.hasKey(key)) {
16+
LengthPercentage.setFromDynamic(map.getDynamic(key))
17+
} else {
18+
null
19+
}
20+
}
21+
22+
public data class CircleShape(
23+
val r: LengthPercentage? = null,
24+
val cx: LengthPercentage? = null,
25+
val cy: LengthPercentage? = null,
26+
) {
27+
public companion object {
28+
public fun parse(map: ReadableMap): CircleShape? {
29+
val r = getOptionalLengthPercentage(map, "r")
30+
val cx = getOptionalLengthPercentage(map, "cx")
31+
val cy = getOptionalLengthPercentage(map, "cy")
32+
return CircleShape(r, cx, cy)
33+
}
34+
}
35+
}
36+
37+
public data class EllipseShape(
38+
val rx: LengthPercentage? = null,
39+
val ry: LengthPercentage? = null,
40+
val cx: LengthPercentage? = null,
41+
val cy: LengthPercentage? = null,
42+
) {
43+
public companion object {
44+
public fun parse(map: ReadableMap): EllipseShape? {
45+
val rx = getOptionalLengthPercentage(map, "rx")
46+
val ry = getOptionalLengthPercentage(map, "ry")
47+
val cx = getOptionalLengthPercentage(map, "cx")
48+
val cy = getOptionalLengthPercentage(map, "cy")
49+
return EllipseShape(rx, ry, cx, cy)
50+
}
51+
}
52+
}
53+
54+
public data class InsetShape(
55+
val top: LengthPercentage,
56+
val right: LengthPercentage,
57+
val bottom: LengthPercentage,
58+
val left: LengthPercentage,
59+
val borderRadius: LengthPercentage? = null,
60+
) {
61+
public companion object {
62+
public fun parse(map: ReadableMap): InsetShape? {
63+
val top = getOptionalLengthPercentage(map, "top") ?: return null
64+
val right = getOptionalLengthPercentage(map, "right") ?: return null
65+
val bottom = getOptionalLengthPercentage(map, "bottom") ?: return null
66+
val left = getOptionalLengthPercentage(map, "left") ?: return null
67+
val borderRadius = getOptionalLengthPercentage(map, "borderRadius")
68+
return InsetShape(top, right, bottom, left, borderRadius)
69+
}
70+
}
71+
}
72+
73+
public enum class FillRule {
74+
NonZero,
75+
EvenOdd;
76+
77+
public companion object {
78+
public fun fromString(value: String): FillRule {
79+
return when (value.lowercase()) {
80+
"nonzero" -> NonZero
81+
"evenodd" -> EvenOdd
82+
else -> NonZero
83+
}
84+
}
85+
}
86+
}
87+
88+
public data class PolygonShape(
89+
val points: List<Pair<LengthPercentage, LengthPercentage>>,
90+
val fillRule: FillRule? = null,
91+
) {
92+
public companion object {
93+
public fun parse(map: ReadableMap): PolygonShape? {
94+
if (!map.hasKey("points")) return null
95+
val pointsArray = map.getArray("points") ?: return null
96+
val points = mutableListOf<Pair<LengthPercentage, LengthPercentage>>()
97+
98+
for (i in 0 until pointsArray.size()) {
99+
val pointMap = pointsArray.getMap(i) ?: continue
100+
val x = getOptionalLengthPercentage(pointMap, "x") ?: continue
101+
val y = getOptionalLengthPercentage(pointMap, "y") ?: continue
102+
points.add(Pair(x, y))
103+
}
104+
105+
val fillRule =
106+
if (map.hasKey("fillRule")) {
107+
FillRule.fromString(map.getString("fillRule") ?: "nonzero")
108+
} else {
109+
null
110+
}
111+
112+
return PolygonShape(points, fillRule)
113+
}
114+
}
115+
}
116+
117+
public data class RectShape(
118+
val top: LengthPercentage,
119+
val right: LengthPercentage,
120+
val bottom: LengthPercentage,
121+
val left: LengthPercentage,
122+
val borderRadius: LengthPercentage? = null,
123+
) {
124+
public companion object {
125+
public fun parse(map: ReadableMap): RectShape? {
126+
val top = getOptionalLengthPercentage(map, "top") ?: return null
127+
val right = getOptionalLengthPercentage(map, "right") ?: return null
128+
val bottom = getOptionalLengthPercentage(map, "bottom") ?: return null
129+
val left = getOptionalLengthPercentage(map, "left") ?: return null
130+
val borderRadius = getOptionalLengthPercentage(map, "borderRadius")
131+
return RectShape(top, right, bottom, left, borderRadius)
132+
}
133+
}
134+
}
135+
136+
public data class XywhShape(
137+
val x: LengthPercentage,
138+
val y: LengthPercentage,
139+
val width: LengthPercentage,
140+
val height: LengthPercentage,
141+
val borderRadius: LengthPercentage? = null,
142+
) {
143+
public companion object {
144+
public fun parse(map: ReadableMap): XywhShape? {
145+
val x = getOptionalLengthPercentage(map, "x") ?: return null
146+
val y = getOptionalLengthPercentage(map, "y") ?: return null
147+
val width = getOptionalLengthPercentage(map, "width") ?: return null
148+
val height = getOptionalLengthPercentage(map, "height") ?: return null
149+
val borderRadius = getOptionalLengthPercentage(map, "borderRadius")
150+
return XywhShape(x, y, width, height, borderRadius)
151+
}
152+
}
153+
}
154+
155+
public sealed class BasicShape {
156+
public data class Circle(val shape: CircleShape) : BasicShape()
157+
158+
public data class Ellipse(val shape: EllipseShape) : BasicShape()
159+
160+
public data class Inset(val shape: InsetShape) : BasicShape()
161+
162+
public data class Polygon(val shape: PolygonShape) : BasicShape()
163+
164+
public data class Rect(val shape: RectShape) : BasicShape()
165+
166+
public data class Xywh(val shape: XywhShape) : BasicShape()
167+
168+
public companion object {
169+
public fun parse(map: ReadableMap): BasicShape? {
170+
if (!map.hasKey("type")) return null
171+
val type = map.getString("type") ?: return null
172+
173+
return when (type.lowercase()) {
174+
"circle" -> {
175+
val circle = CircleShape.parse(map) ?: return null
176+
Circle(circle)
177+
}
178+
"ellipse" -> {
179+
val ellipse = EllipseShape.parse(map) ?: return null
180+
Ellipse(ellipse)
181+
}
182+
"inset" -> {
183+
val inset = InsetShape.parse(map) ?: return null
184+
Inset(inset)
185+
}
186+
"polygon" -> {
187+
val polygon = PolygonShape.parse(map) ?: return null
188+
Polygon(polygon)
189+
}
190+
"rect" -> {
191+
val rect = RectShape.parse(map) ?: return null
192+
Rect(rect)
193+
}
194+
"xywh" -> {
195+
val xywh = XywhShape.parse(map) ?: return null
196+
Xywh(xywh)
197+
}
198+
else -> null
199+
}
200+
}
201+
}
202+
}
203+
204+
public enum class GeometryBox {
205+
MarginBox,
206+
BorderBox,
207+
ContentBox,
208+
PaddingBox,
209+
FillBox,
210+
StrokeBox,
211+
ViewBox;
212+
213+
public companion object {
214+
public fun fromString(value: String): GeometryBox {
215+
return when (value.lowercase()) {
216+
"margin-box" -> MarginBox
217+
"border-box" -> BorderBox
218+
"content-box" -> ContentBox
219+
"padding-box" -> PaddingBox
220+
"fill-box" -> FillBox
221+
"stroke-box" -> StrokeBox
222+
"view-box" -> ViewBox
223+
else -> BorderBox
224+
}
225+
}
226+
}
227+
}
228+
229+
public data class ClipPath(
230+
val shape: BasicShape? = null,
231+
val geometryBox: GeometryBox? = null,
232+
) {
233+
public companion object {
234+
public fun parse(map: ReadableMap?): ClipPath? {
235+
if (map == null) return null
236+
237+
val shape =
238+
if (map.hasKey("shape") && map.getType("shape") == ReadableType.Map) {
239+
val shapeMap = map.getMap("shape")
240+
if (shapeMap != null) BasicShape.parse(shapeMap) else null
241+
} else {
242+
null
243+
}
244+
245+
val geometryBox =
246+
if (map.hasKey("geometryBox")) {
247+
GeometryBox.fromString(map.getString("geometryBox") ?: "border-box")
248+
} else {
249+
null
250+
}
251+
252+
return ClipPath(shape, geometryBox)
253+
}
254+
}
255+
}

0 commit comments

Comments
 (0)