From 5452a3dc881a800a1ae22fe9eae2fd8093263c2a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Mon, 19 Oct 2026 13:47:02 +0300 Subject: [PATCH] Rebuild background and border geometry only when it changes BackgroundDrawable, BackgroundImageDrawable and BorderDrawable marked their paths stale in invalidateSelf(), so every redraw request rebuilt their geometry on the next draw: a background or border color change, an alpha change, a border style change, a new background image layer. A color animation paid that rebuild for every view on every frame. The geometry depends only on the bounds, the border radius and insets and the layout direction. The drawables now mark it stale in their borderRadius and borderInsets setters, in onBoundsChange and in onLayoutDirectionChanged. A layout direction change now rebuilds the geometry instead of waiting for the next redraw request. BorderDrawable now insets its inner clip by the border widths whatever its edge colors. It used to inset it only while an edge was visible, and nothing is drawn while none is. It also resolves its border widths with the rest of its geometry instead of on every draw, reuses its single-edge path across draws, and stops building an outline path nothing reads, the only geometry setBorderWidth's widths fed. It drops setBorderRadius(property, radius), which nothing calls and which changed the shared BorderRadiusStyle in place without reassigning it. --- .../uimanager/drawable/BackgroundDrawable.kt | 32 +- .../drawable/BackgroundImageDrawable.kt | 32 +- .../uimanager/drawable/BorderDrawable.kt | 122 ++--- .../BackgroundAndBorderDrawableTest.kt | 497 ++++++++++++++++++ .../uimanager/drawable/RecordingCanvas.kt | 93 ++++ 5 files changed, 677 insertions(+), 99 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BackgroundAndBorderDrawableTest.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/RecordingCanvas.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundDrawable.kt index d5a2c2d342aa..fb6122649228 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundDrawable.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundDrawable.kt @@ -26,12 +26,8 @@ import kotlin.math.roundToInt internal class BackgroundDrawable( private val context: Context, - /* - * We assume borderRadius & borderInsets to be shared across multiple drawables - * therefore we should manually invalidate this drawable when changing either of them - */ - var borderRadius: BorderRadiusStyle? = null, - var borderInsets: BorderInsets? = null, + borderRadius: BorderRadiusStyle? = null, + borderInsets: BorderInsets? = null, ) : Drawable() { /** * There is a small gap between the edges of adjacent paths, such as between its Border and its @@ -43,6 +39,22 @@ internal class BackgroundDrawable( private var computedBorderRadius: ComputedBorderRadius? = null private var needUpdatePath = true + /* + * We assume borderRadius & borderInsets to be shared across multiple drawables + * therefore user should assign them to this drawable again when changing either of them + */ + var borderRadius: BorderRadiusStyle? = borderRadius + set(value) { + field = value + needUpdatePath = true + } + + var borderInsets: BorderInsets? = borderInsets + set(value) { + field = value + needUpdatePath = true + } + var backgroundColor: Int = Color.TRANSPARENT set(value) { if (field != value) { @@ -61,14 +73,14 @@ internal class BackgroundDrawable( color = backgroundColor } - override fun invalidateSelf() { + override fun onBoundsChange(bounds: Rect) { + super.onBoundsChange(bounds) needUpdatePath = true - super.invalidateSelf() } - override fun onBoundsChange(bounds: Rect) { - super.onBoundsChange(bounds) + override fun onLayoutDirectionChanged(layoutDirection: Int): Boolean { needUpdatePath = true + return true } override fun setAlpha(alpha: Int) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundImageDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundImageDrawable.kt index 66e04d3e7cbc..1e6b0295189a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundImageDrawable.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundImageDrawable.kt @@ -34,18 +34,30 @@ import kotlin.math.round internal class BackgroundImageDrawable( private val context: Context, - /* - * We assume borderRadius & borderInsets to be shared across multiple drawables - * therefore we should manually invalidate this drawable when changing either of them - */ - var borderRadius: BorderRadiusStyle? = null, - var borderInsets: BorderInsets? = null, + borderRadius: BorderRadiusStyle? = null, + borderInsets: BorderInsets? = null, ) : Drawable() { private var needUpdatePath = true private var backgroundImageClipPath: Path? = null private var backgroundPositioningArea: RectF? = null private var backgroundPaintingArea: RectF? = null + /* + * We assume borderRadius & borderInsets to be shared across multiple drawables + * therefore user should assign them to this drawable again when changing either of them + */ + var borderRadius: BorderRadiusStyle? = borderRadius + set(value) { + field = value + needUpdatePath = true + } + + var borderInsets: BorderInsets? = borderInsets + set(value) { + field = value + needUpdatePath = true + } + var backgroundImageLayers: List? = null set(value) { if (field != value) { @@ -81,14 +93,14 @@ internal class BackgroundImageDrawable( private val backgroundPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } - override fun invalidateSelf() { + override fun onBoundsChange(bounds: Rect) { + super.onBoundsChange(bounds) needUpdatePath = true - super.invalidateSelf() } - override fun onBoundsChange(bounds: Rect) { - super.onBoundsChange(bounds) + override fun onLayoutDirectionChanged(layoutDirection: Int): Boolean { needUpdatePath = true + return true } override fun setAlpha(alpha: Int) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BorderDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BorderDrawable.kt index ef22623c9cff..b92462413748 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BorderDrawable.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BorderDrawable.kt @@ -23,13 +23,11 @@ import android.graphics.Region import android.graphics.drawable.Drawable import android.os.Build import com.facebook.react.uimanager.FloatUtil.floatsEqual -import com.facebook.react.uimanager.LengthPercentage import com.facebook.react.uimanager.PixelUtil.dpToPx import com.facebook.react.uimanager.PixelUtil.pxToDp import com.facebook.react.uimanager.Spacing import com.facebook.react.uimanager.style.BorderColors import com.facebook.react.uimanager.style.BorderInsets -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.ColorEdges @@ -52,30 +50,42 @@ private const val ALL_BITS_UNSET = 0 internal class BorderDrawable( private val context: Context, val borderWidth: Spacing?, - /* - * We assume borderRadius & borderInsets to be shared across multiple drawables - * therefore user should invalidate this drawable when changing either of them - */ - var borderRadius: BorderRadiusStyle?, - var borderInsets: BorderInsets?, + borderRadius: BorderRadiusStyle?, + borderInsets: BorderInsets?, borderStyle: BorderStyle?, ) : Drawable() { - private fun invalidatingAndPathChange(initialValue: T): ReadWriteProperty = + private fun invalidatingChange(initialValue: T): ReadWriteProperty = object : ObservableProperty(initialValue) { override fun afterChange(property: KProperty<*>, oldValue: T, newValue: T) { if (oldValue != newValue) { - needUpdatePath = true invalidateSelf() } } } + /* + * We assume borderRadius & borderInsets to be shared across multiple drawables + * therefore user should assign them to this drawable again when changing either of them + */ + var borderRadius: BorderRadiusStyle? = borderRadius + set(value) { + field = value + needUpdatePath = true + } + + var borderInsets: BorderInsets? = borderInsets + set(value) { + field = value + needUpdatePath = true + } + /** Border Properties */ - var borderStyle: BorderStyle? by invalidatingAndPathChange(borderStyle) + var borderStyle: BorderStyle? by invalidatingChange(borderStyle) private var borderColors: BorderColors? = null private var computedBorderColors: ColorEdges = ColorEdges() private var computedBorderRadius: ComputedBorderRadius? = null + private var computedBorderInsets: RectF = RectF() private var borderAlpha: Int = 255 /** @@ -91,7 +101,6 @@ internal class BorderDrawable( private var needUpdatePath: Boolean = true private var pathForSingleBorder: Path? = null - private var pathForOutline: Path? = null private var centerDrawPath: Path? = null private var outerClipPathForBorderRadius: Path? = null @@ -111,14 +120,14 @@ internal class BorderDrawable( private var outerClipTempRectForBorderRadius: RectF? = null private var tempRectForCenterDrawPath: RectF? = null - override fun invalidateSelf() { + override fun onBoundsChange(bounds: Rect) { + super.onBoundsChange(bounds) needUpdatePath = true - super.invalidateSelf() } - override fun onBoundsChange(bounds: Rect) { - super.onBoundsChange(bounds) + override fun onLayoutDirectionChanged(layoutDirection: Int): Boolean { needUpdatePath = true + return true } override fun setAlpha(alpha: Int) { @@ -171,6 +180,7 @@ internal class BorderDrawable( override fun draw(canvas: Canvas) { updatePathEffect() computedBorderColors = borderColors?.resolve(layoutDirection, context) ?: computedBorderColors + updatePath() if (borderRadius?.hasRoundedBorders() == true) { drawRoundedBorders(canvas) } else { @@ -189,23 +199,6 @@ internal class BorderDrawable( fun setBorderWidth(position: Int, width: Float) { if (!floatsEqual(this.borderWidth?.getRaw(position), width)) { this.borderWidth?.set(position, width) - when (position) { - Spacing.ALL, - Spacing.LEFT, - Spacing.BOTTOM, - Spacing.RIGHT, - Spacing.TOP, - Spacing.START, - Spacing.END -> needUpdatePath = true - } - invalidateSelf() - } - } - - fun setBorderRadius(property: BorderRadiusProp, radius: LengthPercentage?) { - if (radius != borderRadius?.get(property)) { - borderRadius?.set(property, radius) - needUpdatePath = true invalidateSelf() } } @@ -213,15 +206,12 @@ internal class BorderDrawable( fun setBorderStyle(style: String?) { val borderStyle = if (style == null) null else BorderStyle.valueOf(style.uppercase()) this.borderStyle = borderStyle - needUpdatePath = true - invalidateSelf() } fun setBorderColor(position: LogicalEdge, color: Int?) { borderColors = borderColors ?: BorderColors() borderColors?.edgeColors?.set(position.ordinal, color) - needUpdatePath = true invalidateSelf() } @@ -230,7 +220,7 @@ internal class BorderDrawable( } private fun drawRectangularBorders(canvas: Canvas) { - val borderWidth = computeBorderInsets() + val borderWidth = computedBorderInsets val borderLeft = borderWidth.left.roundToInt() val borderTop = borderWidth.top.roundToInt() val borderRight = borderWidth.right.roundToInt() @@ -261,7 +251,7 @@ internal class BorderDrawable( val bottom = bounds.bottom borderPaint.color = multiplyColorAlpha(fastBorderColor, borderAlpha) borderPaint.style = Paint.Style.STROKE - pathForSingleBorder = Path() + pathForSingleBorder = pathForSingleBorder ?: Path() if (borderLeft > 0) { pathForSingleBorder?.reset() val width = borderWidth.left.roundToInt() @@ -360,13 +350,12 @@ internal class BorderDrawable( } private fun drawRoundedBorders(canvas: Canvas) { - updatePath() canvas.save() // Clip outer border canvas.clipPath(checkNotNull(outerClipPathForBorderRadius)) - val borderWidth = computeBorderInsets() + val borderWidth = computedBorderInsets if ( borderWidth.top > 0 || borderWidth.bottom > 0 || @@ -666,11 +655,15 @@ internal class BorderDrawable( } needUpdatePath = false + computedBorderInsets = computeBorderInsets() + + if (borderRadius?.hasRoundedBorders() != true) { + return + } // Path innerClipPathForBorderRadius = innerClipPathForBorderRadius ?: Path() outerClipPathForBorderRadius = outerClipPathForBorderRadius ?: Path() - pathForOutline = Path() // RectF innerClipTempRectForBorderRadius = innerClipTempRectForBorderRadius ?: RectF() @@ -684,24 +677,16 @@ internal class BorderDrawable( outerClipTempRectForBorderRadius?.set(bounds) tempRectForCenterDrawPath?.set(bounds) - val borderWidth = computeBorderInsets() + val borderWidth = computedBorderInsets - // Clip border ONLY if at least one edge is non-transparent - if ( - Color.alpha(computedBorderColors.left) != 0 || - Color.alpha(computedBorderColors.top) != 0 || - Color.alpha(computedBorderColors.right) != 0 || - Color.alpha(computedBorderColors.bottom) != 0 - ) { - innerClipTempRectForBorderRadius?.top = - innerClipTempRectForBorderRadius?.top?.plus(borderWidth.top) ?: 0f - innerClipTempRectForBorderRadius?.bottom = - innerClipTempRectForBorderRadius?.bottom?.minus(borderWidth.bottom) ?: 0f - innerClipTempRectForBorderRadius?.left = - innerClipTempRectForBorderRadius?.left?.plus(borderWidth.left) ?: 0f - innerClipTempRectForBorderRadius?.right = - innerClipTempRectForBorderRadius?.right?.minus(borderWidth.right) ?: 0f - } + innerClipTempRectForBorderRadius?.top = + innerClipTempRectForBorderRadius?.top?.plus(borderWidth.top) ?: 0f + innerClipTempRectForBorderRadius?.bottom = + innerClipTempRectForBorderRadius?.bottom?.minus(borderWidth.bottom) ?: 0f + innerClipTempRectForBorderRadius?.left = + innerClipTempRectForBorderRadius?.left?.plus(borderWidth.left) ?: 0f + innerClipTempRectForBorderRadius?.right = + innerClipTempRectForBorderRadius?.right?.minus(borderWidth.right) ?: 0f tempRectForCenterDrawPath?.top = tempRectForCenterDrawPath?.top?.plus(borderWidth.top * 0.5f) ?: 0f @@ -775,27 +760,6 @@ internal class BorderDrawable( ) } - var extraRadiusForOutline = 0f - - if (this.borderWidth != null) { - extraRadiusForOutline = this.borderWidth[Spacing.ALL] / 2f - } - - pathForOutline?.addRoundRect( - RectF(bounds), - floatArrayOf( - topLeftRadius.horizontal + extraRadiusForOutline, - topLeftRadius.vertical + extraRadiusForOutline, - topRightRadius.horizontal + extraRadiusForOutline, - topRightRadius.vertical + extraRadiusForOutline, - bottomRightRadius.horizontal + extraRadiusForOutline, - bottomRightRadius.vertical + extraRadiusForOutline, - bottomLeftRadius.horizontal + extraRadiusForOutline, - bottomLeftRadius.vertical + extraRadiusForOutline, - ), - Path.Direction.CW, - ) - if (computedBorderRadius?.isUniform() != true) { centerDrawPath = centerDrawPath ?: Path() centerDrawPath?.reset() diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BackgroundAndBorderDrawableTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BackgroundAndBorderDrawableTest.kt new file mode 100644 index 000000000000..9bac3d4a38a9 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BackgroundAndBorderDrawableTest.kt @@ -0,0 +1,497 @@ +/* + * 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.drawable + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.util.DisplayMetrics +import android.util.LayoutDirection +import com.facebook.react.bridge.JavaOnlyArray +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.LengthPercentage +import com.facebook.react.uimanager.LengthPercentageType +import com.facebook.react.uimanager.PixelUtil.dpToPx +import com.facebook.react.uimanager.Spacing +import com.facebook.react.uimanager.style.BackgroundImageLayer +import com.facebook.react.uimanager.style.BorderInsets +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.LogicalEdge +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.spy +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.GraphicsMode + +/** Tests for [BackgroundDrawable], [BackgroundImageDrawable] and [BorderDrawable] */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class BackgroundAndBorderDrawableTest { + + private val context: Context = RuntimeEnvironment.getApplication() + + @Before + fun setup() { + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics().apply { density = 2f }) + } + + @After + fun tearDown() { + DisplayMetricsHolder.setScreenDisplayMetrics(null) + } + + @Test + fun backgroundKeepsItsGeometryWhenItsColorOrAlphaChanges() { + val borderRadius = spy(roundedBy(8f)) + val drawable = background(borderRadius).withBounds(BOUNDS) + drawable.draw(RecordingCanvas()) + + drawable.backgroundColor = Color.RED + drawable.alpha = 128 + + val expected = + background(roundedBy(8f)).withBounds(BOUNDS).apply { + backgroundColor = Color.RED + alpha = 128 + } + assertDrawsLike(drawable, expected) + verify(borderRadius, times(1)).resolve(any(), any(), any(), any()) + } + + @Test + fun backgroundFollowsARadiusChangedInPlaceAndReassigned() { + val borderRadius = unevenlyRoundedBy(8f) + val drawable = background(borderRadius).withBounds(BOUNDS) + drawable.draw(RecordingCanvas()) + + borderRadius.set(BorderRadiusProp.BORDER_TOP_LEFT_RADIUS, points(12f)) + drawable.borderRadius = borderRadius + + val expected = + unevenlyRoundedBy(8f).apply { set(BorderRadiusProp.BORDER_TOP_LEFT_RADIUS, points(12f)) } + assertDrawsLike(drawable, background(expected).withBounds(BOUNDS)) + } + + @Test + fun backgroundFollowsBorderInsetsChangedInPlaceAndReassigned() { + val borderInsets = BorderInsets() + val drawable = background(roundedBy(8f), borderInsets).withBounds(BOUNDS) + drawable.draw(RecordingCanvas()) + + borderInsets.setBorderWidth(LogicalEdge.ALL, 3f) + drawable.borderInsets = borderInsets + + assertDrawsLike(drawable, background(roundedBy(8f), insetBy(3f)).withBounds(BOUNDS)) + } + + @Test + fun backgroundFollowsBoundsThatGrowFromEmpty() { + val drawable = background(unevenlyRoundedBy(8f)) + drawable.draw(RecordingCanvas()) + + drawable.bounds = BOUNDS + + assertDrawsLike(drawable, background(unevenlyRoundedBy(8f)).withBounds(BOUNDS)) + } + + @Test + fun backgroundFollowsALayoutDirectionChange() { + val drawable = background(startRoundedBy(8f)).withBounds(BOUNDS) + drawable.draw(RecordingCanvas()) + + drawable.layoutDirection = LayoutDirection.RTL + + val expected = background(startRoundedBy(8f)).withBounds(BOUNDS) + expected.layoutDirection = LayoutDirection.RTL + assertDrawsLike(drawable, expected) + } + + @Test + fun backgroundImageKeepsItsGeometryWhenItsLayersOrAlphaChange() { + val borderRadius = spy(roundedBy(8f)) + val drawable = backgroundImage(borderRadius).withBounds(BOUNDS) + drawable.draw(RecordingCanvas()) + + drawable.backgroundImageLayers = listOf(gradientLayer(Color.GREEN, Color.BLACK)) + drawable.alpha = 128 + + val expected = + backgroundImage(roundedBy(8f), layers = listOf(gradientLayer(Color.GREEN, Color.BLACK))) + .withBounds(BOUNDS) + .apply { alpha = 128 } + assertDrawsLike(drawable, expected) + verify(borderRadius, times(1)).resolve(any(), any(), any(), any()) + } + + @Test + fun backgroundImageFollowsARadiusChangedInPlaceAndReassigned() { + val borderRadius = roundedBy(8f) + val drawable = backgroundImage(borderRadius).withBounds(BOUNDS) + drawable.draw(RecordingCanvas()) + + borderRadius.set(BorderRadiusProp.BORDER_RADIUS, points(4f)) + drawable.borderRadius = borderRadius + + assertDrawsLike(drawable, backgroundImage(roundedBy(4f)).withBounds(BOUNDS)) + } + + @Test + fun backgroundImageFollowsBorderInsetsChangedInPlaceAndReassigned() { + val borderInsets = BorderInsets() + val drawable = backgroundImage(roundedBy(8f), borderInsets).withBounds(BOUNDS) + drawable.draw(RecordingCanvas()) + + borderInsets.setBorderWidth(LogicalEdge.ALL, 3f) + drawable.borderInsets = borderInsets + + assertDrawsLike(drawable, backgroundImage(roundedBy(8f), insetBy(3f)).withBounds(BOUNDS)) + } + + @Test + fun backgroundImageFollowsBoundsThatGrowFromEmpty() { + val drawable = backgroundImage(unevenlyRoundedBy(8f)) + drawable.draw(RecordingCanvas()) + + drawable.bounds = BOUNDS + + assertDrawsLike(drawable, backgroundImage(unevenlyRoundedBy(8f)).withBounds(BOUNDS)) + } + + @Test + fun backgroundImageFollowsALayoutDirectionChange() { + val drawable = backgroundImage(startRoundedBy(8f)).withBounds(BOUNDS) + drawable.draw(RecordingCanvas()) + + drawable.layoutDirection = LayoutDirection.RTL + + val expected = backgroundImage(startRoundedBy(8f)).withBounds(BOUNDS) + expected.layoutDirection = LayoutDirection.RTL + assertDrawsLike(drawable, expected) + } + + @Test + fun borderKeepsItsGeometryWhenItsColorAlphaOrStyleChanges() { + val borderRadius = spy(roundedBy(8f)) + val borderInsets = spy(insetBy(2f)) + val drawable = border(borderRadius, borderInsets, Color.BLUE).withBounds(BOUNDS) + drawable.draw(RecordingCanvas()) + + drawable.setBorderColor(LogicalEdge.ALL, Color.RED) + drawable.alpha = 128 + drawable.borderStyle = BorderStyle.DASHED + + val expected = + border(roundedBy(8f), insetBy(2f), Color.RED).withBounds(BOUNDS).apply { + alpha = 128 + borderStyle = BorderStyle.DASHED + } + assertDrawsLike(drawable, expected) + verify(borderRadius, times(1)).resolve(any(), any(), any(), any()) + verify(borderInsets, times(1)).resolve(any(), any()) + } + + @Test + fun borderFollowsARadiusChangedInPlaceAndReassigned() { + val borderRadius = roundedBy(8f) + val drawable = border(borderRadius, insetBy(2f), Color.BLUE).withBounds(BOUNDS) + drawable.draw(RecordingCanvas()) + + borderRadius.set(BorderRadiusProp.BORDER_RADIUS, points(4f)) + drawable.borderRadius = borderRadius + + assertDrawsLike(drawable, border(roundedBy(4f), insetBy(2f), Color.BLUE).withBounds(BOUNDS)) + } + + @Test + fun borderFollowsBorderInsetsChangedInPlaceAndReassigned() { + val borderInsets = insetBy(2f) + val drawable = border(roundedBy(8f), borderInsets, Color.BLUE).withBounds(BOUNDS) + drawable.draw(RecordingCanvas()) + + borderInsets.setBorderWidth(LogicalEdge.ALL, 5f) + drawable.borderInsets = borderInsets + + assertDrawsLike(drawable, border(roundedBy(8f), insetBy(5f), Color.BLUE).withBounds(BOUNDS)) + } + + @Test + fun borderFollowsBoundsThatGrowFromEmpty() { + val drawable = border(unevenlyRoundedBy(8f), insetBy(2f), Color.BLUE) + drawable.draw(RecordingCanvas()) + + drawable.bounds = BOUNDS + + assertDrawsLike( + drawable, + border(unevenlyRoundedBy(8f), insetBy(2f), Color.BLUE).withBounds(BOUNDS), + ) + } + + @Test + fun borderFollowsALayoutDirectionChange() { + val drawable = border(startRoundedBy(8f), insetBy(2f), Color.BLUE).withBounds(BOUNDS) + drawable.draw(RecordingCanvas()) + + drawable.layoutDirection = LayoutDirection.RTL + + val expected = border(startRoundedBy(8f), insetBy(2f), Color.BLUE).withBounds(BOUNDS) + expected.layoutDirection = LayoutDirection.RTL + assertDrawsLike(drawable, expected) + } + + @Test + fun borderFollowsAnEdgeColorThatBecomesVisible() { + val drawable = border(roundedBy(8f), insetBy(4f), Color.TRANSPARENT).withBounds(BOUNDS) + drawable.draw(RecordingCanvas()) + + drawable.setBorderColor(LogicalEdge.ALL, Color.BLUE) + drawable.setBorderColor(LogicalEdge.TOP, Color.RED) + + val expected = + border(roundedBy(8f), insetBy(4f), Color.BLUE).withBounds(BOUNDS).apply { + setBorderColor(LogicalEdge.TOP, Color.RED) + } + assertDrawsLike(drawable, expected) + } + + @Test + fun uniformBorderKeepsItsGeometryWhenItsColorOrStyleChanges() { + val borderRadius = spy(roundedBy(8f)) + val borderInsets = spy(insetBy(2f)) + val drawable = uniformBorder(borderRadius, 2f, Color.BLUE, borderInsets).withBounds(BOUNDS) + drawable.draw(RecordingCanvas()) + + drawable.setBorderColor(LogicalEdge.ALL, Color.RED) + drawable.borderStyle = BorderStyle.DASHED + + val expected = + uniformBorder(roundedBy(8f), 2f, Color.RED).withBounds(BOUNDS).apply { + borderStyle = BorderStyle.DASHED + } + assertDrawsLike(drawable, expected) + verify(borderRadius, times(1)).resolve(any(), any(), any(), any()) + verify(borderInsets, times(1)).resolve(any(), any()) + } + + @Test + fun uniformBorderFollowsARadiusChangedInPlaceAndReassigned() { + val borderRadius = unevenlyRoundedBy(8f) + val drawable = uniformBorder(borderRadius, 2f, Color.BLUE).withBounds(BOUNDS) + drawable.draw(RecordingCanvas()) + + borderRadius.set(BorderRadiusProp.BORDER_TOP_LEFT_RADIUS, points(12f)) + drawable.borderRadius = borderRadius + + val expected = + unevenlyRoundedBy(8f).apply { set(BorderRadiusProp.BORDER_TOP_LEFT_RADIUS, points(12f)) } + assertDrawsLike(drawable, uniformBorder(expected, 2f, Color.BLUE).withBounds(BOUNDS)) + } + + @Test + fun uniformBorderFollowsItsWidthChangedInPlaceAndReassigned() { + val borderInsets = insetBy(2f) + val drawable = + uniformBorder(unevenlyRoundedBy(8f), 2f, Color.BLUE, borderInsets).withBounds(BOUNDS) + drawable.draw(RecordingCanvas()) + + borderInsets.setBorderWidth(LogicalEdge.ALL, 5f) + drawable.setBorderWidth(Spacing.ALL, 5f.dpToPx()) + drawable.borderInsets = borderInsets + + assertDrawsLike( + drawable, + uniformBorder(unevenlyRoundedBy(8f), 5f, Color.BLUE).withBounds(BOUNDS), + ) + } + + @Test + fun uniformBorderFollowsBoundsThatGrowFromEmpty() { + val drawable = uniformBorder(unevenlyRoundedBy(8f), 2f, Color.BLUE) + drawable.draw(RecordingCanvas()) + + drawable.bounds = BOUNDS + + assertDrawsLike( + drawable, + uniformBorder(unevenlyRoundedBy(8f), 2f, Color.BLUE).withBounds(BOUNDS), + ) + } + + @Test + fun uniformBorderFollowsALayoutDirectionChange() { + val drawable = uniformBorder(startRoundedBy(8f), 2f, Color.BLUE).withBounds(BOUNDS) + drawable.draw(RecordingCanvas()) + + drawable.layoutDirection = LayoutDirection.RTL + + val expected = uniformBorder(startRoundedBy(8f), 2f, Color.BLUE).withBounds(BOUNDS) + expected.layoutDirection = LayoutDirection.RTL + assertDrawsLike(drawable, expected) + } + + @Test + fun rectangularBorderKeepsItsGeometryWhenItsColorOrAlphaChanges() { + val borderInsets = spy(insetBy(2f)) + val drawable = border(null, borderInsets, Color.BLUE).withBounds(BOUNDS) + drawable.draw(RecordingCanvas()) + + drawable.setBorderColor(LogicalEdge.ALL, Color.RED) + drawable.alpha = 128 + + val expected = border(null, insetBy(2f), Color.RED).withBounds(BOUNDS).apply { alpha = 128 } + assertDrawsLike(drawable, expected) + verify(borderInsets, times(1)).resolve(any(), any()) + } + + @Test + fun rectangularBorderFollowsBorderInsetsChangedInPlaceAndReassigned() { + val borderInsets = insetBy(2f) + val drawable = border(null, borderInsets, Color.BLUE).withBounds(BOUNDS) + drawable.draw(RecordingCanvas()) + + borderInsets.setBorderWidth(LogicalEdge.ALL, 5f) + drawable.borderInsets = borderInsets + + assertDrawsLike(drawable, border(null, insetBy(5f), Color.BLUE).withBounds(BOUNDS)) + } + + @Test + fun rectangularBorderFollowsALayoutDirectionChange() { + val drawable = border(null, insetBy(start = 1f, end = 4f), Color.BLUE).withBounds(BOUNDS) + drawable.draw(RecordingCanvas()) + + drawable.layoutDirection = LayoutDirection.RTL + + val expected = border(null, insetBy(start = 1f, end = 4f), Color.BLUE).withBounds(BOUNDS) + expected.layoutDirection = LayoutDirection.RTL + assertDrawsLike(drawable, expected) + } + + @Test + fun rectangularBorderDrawsTheSamePathOnEveryDraw() { + val drawable = border(null, insetBy(2f), Color.BLUE).withBounds(BOUNDS) + + val firstDraw = RecordingCanvas().also { drawable.draw(it) }.geometry + val secondDraw = RecordingCanvas().also { drawable.draw(it) }.geometry + + assertThat(firstDraw).isNotEmpty() + assertThat(secondDraw).hasSameSizeAs(firstDraw) + firstDraw.zip(secondDraw).forEach { (first, second) -> assertThat(second).isSameAs(first) } + } + + private fun background( + borderRadius: BorderRadiusStyle?, + borderInsets: BorderInsets? = null, + ): BackgroundDrawable = + BackgroundDrawable(context, borderRadius, borderInsets).apply { backgroundColor = Color.BLUE } + + private fun backgroundImage( + borderRadius: BorderRadiusStyle?, + borderInsets: BorderInsets? = null, + layers: List = listOf(gradientLayer(Color.RED, Color.BLUE)), + ): BackgroundImageDrawable = + BackgroundImageDrawable(context, borderRadius, borderInsets).apply { + backgroundImageLayers = layers + } + + private fun border( + borderRadius: BorderRadiusStyle?, + borderInsets: BorderInsets, + color: Int, + ): BorderDrawable = + BorderDrawable( + context = context, + borderWidth = Spacing(0f), + borderRadius = borderRadius, + borderInsets = borderInsets, + borderStyle = BorderStyle.SOLID, + ) + .apply { setBorderColor(LogicalEdge.ALL, color) } + + private fun uniformBorder( + borderRadius: BorderRadiusStyle?, + width: Float, + color: Int, + borderInsets: BorderInsets = insetBy(width), + ): BorderDrawable = + BorderDrawable( + context = context, + borderWidth = Spacing(0f).apply { set(Spacing.ALL, width.dpToPx()) }, + borderRadius = borderRadius, + borderInsets = borderInsets, + borderStyle = BorderStyle.SOLID, + ) + .apply { setBorderColor(LogicalEdge.ALL, color) } + + private fun gradientLayer(from: Int, to: Int): BackgroundImageLayer = + checkNotNull( + BackgroundImageLayer.parse( + JavaOnlyMap.of( + "type", + "linear-gradient", + "direction", + JavaOnlyMap.of("type", "angle", "value", 90.0), + "colorStops", + JavaOnlyArray.of(JavaOnlyMap.of("color", from), JavaOnlyMap.of("color", to)), + ), + context, + ) + ) + + private fun points(value: Float): LengthPercentage = + LengthPercentage(value, LengthPercentageType.POINT) + + private fun insetBy(width: Float): BorderInsets = + BorderInsets().apply { setBorderWidth(LogicalEdge.ALL, width) } + + private fun insetBy(start: Float, end: Float): BorderInsets = + BorderInsets().apply { + setBorderWidth(LogicalEdge.START, start) + setBorderWidth(LogicalEdge.END, end) + } + + private fun roundedBy(radius: Float): BorderRadiusStyle = + BorderRadiusStyle().apply { set(BorderRadiusProp.BORDER_RADIUS, points(radius)) } + + private fun unevenlyRoundedBy(radius: Float): BorderRadiusStyle = + roundedBy(radius).apply { set(BorderRadiusProp.BORDER_BOTTOM_RIGHT_RADIUS, points(2f)) } + + private fun startRoundedBy(radius: Float): BorderRadiusStyle = + BorderRadiusStyle().apply { set(BorderRadiusProp.BORDER_TOP_START_RADIUS, points(radius)) } + + private fun T.withBounds(bounds: Rect): T = apply { this.bounds = bounds } + + private fun assertDrawsLike(drawable: Drawable, expected: Drawable) { + val drawn = RecordingCanvas().also { drawable.draw(it) }.calls + assertThat(drawn).isNotEmpty() + assertThat(drawn).isEqualTo(RecordingCanvas().also { expected.draw(it) }.calls) + assertThat(pixelsOf(drawable).sameAs(pixelsOf(expected))).describedAs("pixels").isTrue() + } + + private fun pixelsOf(drawable: Drawable): Bitmap = + Bitmap.createBitmap(BOUNDS.width(), BOUNDS.height(), Bitmap.Config.ARGB_8888).also { + drawable.draw(Canvas(it)) + } + + private companion object { + val BOUNDS = Rect(0, 0, 100, 50) + } +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/RecordingCanvas.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/RecordingCanvas.kt new file mode 100644 index 000000000000..110da4cb85d9 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/RecordingCanvas.kt @@ -0,0 +1,93 @@ +/* + * 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.drawable + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Rect +import android.graphics.RectF + +/** + * Records the clip, translate and draw calls it overrides as text, with each draw's paint, and + * every geometry object those calls hand the canvas. + */ +internal class RecordingCanvas : Canvas() { + val calls = mutableListOf() + val geometry = mutableListOf() + val drawnPaths = mutableListOf() + + override fun clipPath(path: Path): Boolean { + record("clipPath ${describe(path)}", path) + return true + } + + override fun clipOutPath(path: Path): Boolean { + record("clipOutPath ${describe(path)}", path) + return true + } + + override fun clipRect(rect: RectF): Boolean { + record("clipRect $rect", rect) + return true + } + + override fun clipOutRect(rect: Rect): Boolean { + record("clipOutRect $rect", rect) + return true + } + + override fun translate(dx: Float, dy: Float) { + record("translate $dx $dy") + } + + override fun drawPath(path: Path, paint: Paint) { + drawnPaths.add(path) + record("drawPath ${path.fillType} ${describe(path)} ${describe(paint)}", path) + } + + override fun drawRect(rect: RectF, paint: Paint) { + record("drawRect $rect ${describe(paint)}", rect) + } + + override fun drawRect(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) { + record("drawRect $left $top $right $bottom ${describe(paint)}") + } + + override fun drawRoundRect(rect: RectF, rx: Float, ry: Float, paint: Paint) { + record("drawRoundRect $rect $rx $ry ${describe(paint)}", rect) + } + + override fun drawDoubleRoundRect( + outer: RectF, + outerRadii: FloatArray, + inner: RectF, + innerRadii: FloatArray, + paint: Paint, + ) { + record( + "drawDoubleRoundRect $outer ${outerRadii.toList()} $inner ${innerRadii.toList()} " + + describe(paint), + outer, + outerRadii, + inner, + innerRadii, + ) + } + + private fun record(call: String, vararg drawnGeometry: Any) { + calls.add(call) + geometry.addAll(drawnGeometry) + } + + private fun describe(path: Path): String = path.approximate(0.5f).toList().toString() + + private fun describe(paint: Paint): String = + "color=${Integer.toHexString(paint.color)} alpha=${paint.alpha} style=${paint.style} " + + "width=${paint.strokeWidth} blurred=${paint.maskFilter != null}" +}