From c2207dbfbe79739220cd06819c35d336d81ac8e7 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, and for a border on whether any edge's color is visible, which decides whether its inner clip is inset. The drawables now mark it stale in their borderRadius and borderInsets setters, in onBoundsChange and in onLayoutDirectionChanged, and BorderDrawable also when its edges' visibility changes. A layout direction change now rebuilds the geometry instead of waiting for the next redraw request. BorderDrawable also resolves its border widths with the rest of its geometry instead of on every draw, stops building an outline path nothing reads, reuses its single-edge path across draws, and 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 | 109 ++--- .../BackgroundAndBorderDrawableTest.kt | 448 ++++++++++++++++++ 4 files changed, 540 insertions(+), 81 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BackgroundAndBorderDrawableTest.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 d1293ee48d9c..b1d83a588d0d 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 /** @@ -90,8 +100,10 @@ internal class BorderDrawable( private var needUpdatePath: Boolean = true + /** The inner clip is inset by the border widths only while an edge's color is visible. */ + private var pathInsetForVisibleBorder: Boolean = false + private var pathForSingleBorder: Path? = null - private var pathForOutline: Path? = null private var centerDrawPath: Path? = null private var outerClipPathForBorderRadius: Path? = null @@ -111,14 +123,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 +183,7 @@ internal class BorderDrawable( override fun draw(canvas: Canvas) { updatePathEffect() computedBorderColors = borderColors?.resolve(layoutDirection, context) ?: computedBorderColors + updatePath() if (borderRadius?.hasRoundedBorders() == true) { drawRoundedBorders(canvas) } else { @@ -202,26 +215,15 @@ internal class BorderDrawable( } } - fun setBorderRadius(property: BorderRadiusProp, radius: LengthPercentage?) { - if (radius != borderRadius?.get(property)) { - borderRadius?.set(property, radius) - needUpdatePath = true - invalidateSelf() - } - } - 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 +232,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 +263,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 +362,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 || @@ -664,17 +665,29 @@ internal class BorderDrawable( } } + private fun hasVisibleBorderColor(): Boolean = + Color.alpha(computedBorderColors.left) != 0 || + Color.alpha(computedBorderColors.top) != 0 || + Color.alpha(computedBorderColors.right) != 0 || + Color.alpha(computedBorderColors.bottom) != 0 + private fun updatePath() { - if (!needUpdatePath) { + val insetForVisibleBorder = hasVisibleBorderColor() + if (!needUpdatePath && insetForVisibleBorder == pathInsetForVisibleBorder) { return } needUpdatePath = false + pathInsetForVisibleBorder = insetForVisibleBorder + computedBorderInsets = computeBorderInsets() + + if (borderRadius?.hasRoundedBorders() != true) { + return + } // Path innerClipPathForBorderRadius = innerClipPathForBorderRadius ?: Path() outerClipPathForBorderRadius = outerClipPathForBorderRadius ?: Path() - pathForOutline = Path() // RectF innerClipTempRectForBorderRadius = innerClipTempRectForBorderRadius ?: RectF() @@ -688,15 +701,10 @@ 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 - ) { + if (insetForVisibleBorder) { innerClipTempRectForBorderRadius?.top = innerClipTempRectForBorderRadius?.top?.plus(borderWidth.top) ?: 0f innerClipTempRectForBorderRadius?.bottom = @@ -779,27 +787,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..e258df2a3488 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BackgroundAndBorderDrawableTest.kt @@ -0,0 +1,448 @@ +/* + * 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.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Rect +import android.graphics.RectF +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.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)).withBounds(BOUNDS).apply { + backgroundImageLayers = listOf(gradientLayer(Color.GREEN, Color.BLACK)) + 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 borderInsetsItsInnerClipOnceAnEdgeColorBecomesVisible() { + 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 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, + ): BackgroundImageDrawable = + BackgroundImageDrawable(context, borderRadius, borderInsets).apply { + backgroundImageLayers = listOf(gradientLayer(Color.RED, Color.BLUE)) + } + + 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 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) + } + + /** Records each call a drawable makes as text, and every geometry object it hands the canvas. */ + private class RecordingCanvas : Canvas() { + val calls = mutableListOf() + val geometry = 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 translate(dx: Float, dy: Float) { + calls.add("translate $dx $dy") + } + + override fun drawPath(path: Path, paint: Paint) { + record("drawPath ${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) { + calls.add("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) + } + + private fun record(call: String, drawnGeometry: Any) { + calls.add(call) + geometry.add(drawnGeometry) + } + + private fun describe(path: Path): String = path.approximate(0.5f).toList().toString() + + private fun describe(paint: Paint): String = + "color=${Integer.toHexString(paint.color)} style=${paint.style} width=${paint.strokeWidth}" + } + + private companion object { + val BOUNDS = Rect(0, 0, 100, 50) + } +}