From ad615d579f35ea83037f67a98469ad003e115373 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sun, 18 Oct 2026 23:09:47 +0300 Subject: [PATCH] Stop rebuilding box-shadow geometry on every draw InsetBoxShadowDrawable and OutsetBoxShadowDrawable resolved the border radii and built new RectF, Path and radii arrays inside draw(), so every redraw of a view's background, every frame of an animation that touches it, allocated them again and handed the canvas a new Path. Both drawables now keep that geometry in fields and rebuild it only when its inputs change: on a bounds or layout direction change, and when borderRadius or borderInsets is assigned. An alpha or color filter change only redraws. --- .../drawable/InsetBoxShadowDrawable.kt | 157 ++++++---- .../drawable/OutsetBoxShadowDrawable.kt | 140 +++++---- .../drawable/BoxShadowDrawableTest.kt | 294 ++++++++++++++++++ 3 files changed, 467 insertions(+), 124 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BoxShadowDrawableTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt index c59484be0f7f..cbe7489d5fd5 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt @@ -15,6 +15,7 @@ import android.graphics.ColorFilter import android.graphics.Paint import android.graphics.Path import android.graphics.PixelFormat +import android.graphics.Rect import android.graphics.RectF import android.graphics.drawable.Drawable import androidx.annotation.RequiresApi @@ -45,12 +46,8 @@ internal class InsetBoxShadowDrawable( private val offsetY: Float, private val blurRadius: Float, private val spread: Float, - /* - * We assume borderRadius & borderInsets to be shared across multiple drawables - * therefore user should invalidate this drawable when changing either of them - */ - var borderInsets: BorderInsets? = null, - var borderRadius: BorderRadiusStyle? = null, + borderInsets: BorderInsets? = null, + borderRadius: BorderRadiusStyle? = null, ) : Drawable() { private val shadowPaint = Paint().apply { @@ -61,6 +58,41 @@ internal class InsetBoxShadowDrawable( } } + private var needUpdatePath = true + private val paddingBoxRect = RectF() + private val paddingBoxRadii = FloatArray(8) + private var hasPaddingBoxRadii = false + private val paddingBoxPath = Path() + private val innerRect = RectF() + private val innerRadii = FloatArray(8) + private val outerRect = RectF() + + /* + * 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 borderInsets: BorderInsets? = borderInsets + set(value) { + field = value + needUpdatePath = true + } + + var borderRadius: BorderRadiusStyle? = borderRadius + set(value) { + field = value + needUpdatePath = true + } + + override fun onBoundsChange(bounds: Rect) { + super.onBoundsChange(bounds) + needUpdatePath = true + } + + override fun onLayoutDirectionChanged(layoutDirection: Int): Boolean { + needUpdatePath = true + return true + } + override fun setAlpha(alpha: Int) { shadowPaint.alpha = (((alpha / 255f) * (Color.alpha(shadowColor) / 255f)) * 255f).roundToInt() invalidateSelf() @@ -82,69 +114,80 @@ internal class InsetBoxShadowDrawable( } override fun draw(canvas: Canvas) { + updatePath() + + canvas.save().let { saveCount -> + if (hasPaddingBoxRadii) { + canvas.clipPath(paddingBoxPath) + canvas.drawDoubleRoundRect(outerRect, ZERO_RADII, innerRect, innerRadii, shadowPaint) + } else { + canvas.clipRect(paddingBoxRect) + canvas.drawDoubleRoundRect(outerRect, ZERO_RADII, innerRect, ZERO_RADII, shadowPaint) + } + + canvas.restoreToCount(saveCount) + } + } + + private fun updatePath() { + if (!needUpdatePath) { + return + } + needUpdatePath = false + val computedBorderRadii = computeBorderRadii() val computedBorderInsets = computeBorderInsets() - val paddingBoxRect = - RectF( - bounds.left + (computedBorderInsets?.left ?: 0f), - bounds.top + (computedBorderInsets?.top ?: 0f), - bounds.right - (computedBorderInsets?.right ?: 0f), - bounds.bottom - (computedBorderInsets?.bottom ?: 0f), - ) - val paddingBoxRadii = - computedBorderRadii?.let { - floatArrayOf( - innerRadius(it.topLeft.horizontal, computedBorderInsets?.left), - innerRadius(it.topLeft.vertical, computedBorderInsets?.top), - innerRadius(it.topRight.horizontal, computedBorderInsets?.right), - innerRadius(it.topRight.vertical, computedBorderInsets?.top), - innerRadius(it.bottomRight.horizontal, computedBorderInsets?.right), - innerRadius(it.bottomRight.vertical, computedBorderInsets?.bottom), - innerRadius(it.bottomLeft.horizontal, computedBorderInsets?.left), - innerRadius(it.bottomLeft.vertical, computedBorderInsets?.bottom), - ) - } + paddingBoxRect.set( + bounds.left + (computedBorderInsets?.left ?: 0f), + bounds.top + (computedBorderInsets?.top ?: 0f), + bounds.right - (computedBorderInsets?.right ?: 0f), + bounds.bottom - (computedBorderInsets?.bottom ?: 0f), + ) + hasPaddingBoxRadii = computedBorderRadii != null + if (computedBorderRadii != null) { + paddingBoxRadii[0] = + innerRadius(computedBorderRadii.topLeft.horizontal, computedBorderInsets?.left) + paddingBoxRadii[1] = + innerRadius(computedBorderRadii.topLeft.vertical, computedBorderInsets?.top) + paddingBoxRadii[2] = + innerRadius(computedBorderRadii.topRight.horizontal, computedBorderInsets?.right) + paddingBoxRadii[3] = + innerRadius(computedBorderRadii.topRight.vertical, computedBorderInsets?.top) + paddingBoxRadii[4] = + innerRadius(computedBorderRadii.bottomRight.horizontal, computedBorderInsets?.right) + paddingBoxRadii[5] = + innerRadius(computedBorderRadii.bottomRight.vertical, computedBorderInsets?.bottom) + paddingBoxRadii[6] = + innerRadius(computedBorderRadii.bottomLeft.horizontal, computedBorderInsets?.left) + paddingBoxRadii[7] = + innerRadius(computedBorderRadii.bottomLeft.vertical, computedBorderInsets?.bottom) + } val x = offsetX.dpToPx() val y = offsetY.dpToPx() val spreadExtent = spread.dpToPx() - val innerRect = - RectF(paddingBoxRect).apply { - if (2 * spreadExtent > paddingBoxRect.width()) { - setEmpty() - } else { - inset(spreadExtent, spreadExtent) - } - offset(x, y) - } + innerRect.set(paddingBoxRect) + if (2 * spreadExtent > paddingBoxRect.width()) { + innerRect.setEmpty() + } else { + innerRect.inset(spreadExtent, spreadExtent) + } + innerRect.offset(x, y) // Graciously stolen from Blink // https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/paint/box_painter_base.cc;l=338;drc=0a301506035e13015ea5c8dd39164d0d5954fa60 val blurExtent = FilterHelper.sigmaToRadius(blurRadius) - val outerRect = - RectF(innerRect).apply { - set(paddingBoxRect) - inset(-blurExtent, -blurExtent) - union(RectF(innerRect)) - } - - canvas.save().let { saveCount -> - if (paddingBoxRadii != null) { - canvas.clipPath( - Path().apply { addRoundRect(paddingBoxRect, paddingBoxRadii, Path.Direction.CW) }, - ) - - val innerRadii = - paddingBoxRadii.map { adjustRadiusForSpread(it, -spreadExtent) }.toFloatArray() - - canvas.drawDoubleRoundRect(outerRect, ZERO_RADII, innerRect, innerRadii, shadowPaint) - } else { - canvas.clipRect(paddingBoxRect) - canvas.drawDoubleRoundRect(outerRect, ZERO_RADII, innerRect, ZERO_RADII, shadowPaint) + outerRect.set(paddingBoxRect) + outerRect.inset(-blurExtent, -blurExtent) + outerRect.union(innerRect) + + if (hasPaddingBoxRadii) { + paddingBoxPath.rewind() + paddingBoxPath.addRoundRect(paddingBoxRect, paddingBoxRadii, Path.Direction.CW) + for (index in innerRadii.indices) { + innerRadii[index] = adjustRadiusForSpread(paddingBoxRadii[index], -spreadExtent) } - - canvas.restoreToCount(saveCount) } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/OutsetBoxShadowDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/OutsetBoxShadowDrawable.kt index c67596d44bea..e643535668d2 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/OutsetBoxShadowDrawable.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/OutsetBoxShadowDrawable.kt @@ -15,6 +15,7 @@ import android.graphics.ColorFilter import android.graphics.Paint import android.graphics.Path import android.graphics.PixelFormat +import android.graphics.Rect import android.graphics.RectF import android.graphics.drawable.Drawable import androidx.annotation.RequiresApi @@ -42,11 +43,7 @@ internal class OutsetBoxShadowDrawable( private val offsetY: Float, private val blurRadius: Float, private val spread: Float, - /* - * We assume borderRadius to be shared across multiple drawables - * therefore we should manually invalidate this drawable when changing it - */ - var borderRadius: BorderRadiusStyle? = null, + borderRadius: BorderRadiusStyle? = null, ) : Drawable() { private val shadowPaint = Paint().apply { @@ -57,6 +54,34 @@ internal class OutsetBoxShadowDrawable( } } + private var needUpdatePath = true + private val shadowRect = RectF() + private var hasRoundedBorders = false + private val subpixelInsetBounds = RectF() + private val radii = FloatArray(8) + private val clipOutPath = Path() + private val shadowPath = Path() + + /* + * We assume borderRadius to be shared across multiple drawables + * therefore we should assign it to this drawable again when changing it + */ + var borderRadius: BorderRadiusStyle? = borderRadius + set(value) { + field = value + needUpdatePath = true + } + + override fun onBoundsChange(bounds: Rect) { + super.onBoundsChange(bounds) + needUpdatePath = true + } + + override fun onLayoutDirectionChanged(layoutDirection: Int): Boolean { + needUpdatePath = true + return true + } + override fun setAlpha(alpha: Int) { shadowPaint.alpha = (((alpha / 255f) * (Color.alpha(shadowColor) / 255f)) * 255f).roundToInt() invalidateSelf() @@ -78,6 +103,26 @@ internal class OutsetBoxShadowDrawable( } override fun draw(canvas: Canvas) { + updatePath() + + canvas.save().let { saveCount -> + if (hasRoundedBorders) { + canvas.clipOutPath(clipOutPath) + canvas.drawPath(shadowPath, shadowPaint) + } else { + canvas.clipOutRect(bounds) + canvas.drawRect(shadowRect, shadowPaint) + } + canvas.restoreToCount(saveCount) + } + } + + private fun updatePath() { + if (!needUpdatePath) { + return + } + needUpdatePath = false + val resolutionWidth = bounds.width().toFloat().pxToDp() val resolutionHeight = bounds.height().toFloat().pxToDp() val computedBorderRadii = @@ -94,74 +139,35 @@ internal class OutsetBoxShadowDrawable( } val spreadExtent = spread.dpToPx() - val shadowRect = - RectF(bounds).apply { - inset(-spreadExtent, -spreadExtent) - offset(offsetX.dpToPx(), offsetY.dpToPx()) - } + shadowRect.set(bounds) + shadowRect.inset(-spreadExtent, -spreadExtent) + shadowRect.offset(offsetX.dpToPx(), offsetY.dpToPx()) - canvas.save().let { saveCount -> - if (computedBorderRadii?.hasRoundedBorders() == true) { - drawShadowRoundRect(canvas, shadowRect, spreadExtent, computedBorderRadii) - } else { - drawShadowRect(canvas, shadowRect) - } - canvas.restoreToCount(saveCount) + hasRoundedBorders = computedBorderRadii?.hasRoundedBorders() == true + if (computedBorderRadii == null || !hasRoundedBorders) { + return } - } - private fun drawShadowRoundRect( - canvas: Canvas, - shadowRect: RectF, - spreadExtent: Float, - computedBorderRadii: ComputedBorderRadius, - ) { // We inset the clip slightly, to avoid Skia artifacts with antialiased // clipping. This inset is only visible when no background is present. // https://neugierig.org/software/chromium/notes/2010/07/clipping.html - val subpixelInsetBounds = RectF(bounds).apply { inset(0.4f, 0.4f) } - canvas.clipOutPath( - Path().apply { - addRoundRect( - subpixelInsetBounds, - floatArrayOf( - computedBorderRadii.topLeft.horizontal, - computedBorderRadii.topLeft.vertical, - computedBorderRadii.topRight.horizontal, - computedBorderRadii.topRight.vertical, - computedBorderRadii.bottomRight.horizontal, - computedBorderRadii.bottomRight.vertical, - computedBorderRadii.bottomLeft.horizontal, - computedBorderRadii.bottomLeft.vertical, - ), - Path.Direction.CW, - ) - }, - ) - - canvas.drawPath( - Path().apply { - addRoundRect( - shadowRect, - floatArrayOf( - adjustRadiusForSpread(computedBorderRadii.topLeft.horizontal, spreadExtent), - adjustRadiusForSpread(computedBorderRadii.topLeft.vertical, spreadExtent), - adjustRadiusForSpread(computedBorderRadii.topRight.horizontal, spreadExtent), - adjustRadiusForSpread(computedBorderRadii.topRight.vertical, spreadExtent), - adjustRadiusForSpread(computedBorderRadii.bottomRight.horizontal, spreadExtent), - adjustRadiusForSpread(computedBorderRadii.bottomRight.vertical, spreadExtent), - adjustRadiusForSpread(computedBorderRadii.bottomLeft.horizontal, spreadExtent), - adjustRadiusForSpread(computedBorderRadii.bottomLeft.vertical, spreadExtent), - ), - Path.Direction.CW, - ) - }, - shadowPaint, - ) - } + subpixelInsetBounds.set(bounds) + subpixelInsetBounds.inset(0.4f, 0.4f) + radii[0] = computedBorderRadii.topLeft.horizontal + radii[1] = computedBorderRadii.topLeft.vertical + radii[2] = computedBorderRadii.topRight.horizontal + radii[3] = computedBorderRadii.topRight.vertical + radii[4] = computedBorderRadii.bottomRight.horizontal + radii[5] = computedBorderRadii.bottomRight.vertical + radii[6] = computedBorderRadii.bottomLeft.horizontal + radii[7] = computedBorderRadii.bottomLeft.vertical + clipOutPath.rewind() + clipOutPath.addRoundRect(subpixelInsetBounds, radii, Path.Direction.CW) - private fun drawShadowRect(canvas: Canvas, shadowRect: RectF) { - canvas.clipOutRect(bounds) - canvas.drawRect(shadowRect, shadowPaint) + for (index in radii.indices) { + radii[index] = adjustRadiusForSpread(radii[index], spreadExtent) + } + shadowPath.rewind() + shadowPath.addRoundRect(shadowRect, radii, Path.Direction.CW) } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BoxShadowDrawableTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BoxShadowDrawableTest.kt new file mode 100644 index 000000000000..a1e66d64dace --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BoxShadowDrawableTest.kt @@ -0,0 +1,294 @@ +/* + * 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.PorterDuff +import android.graphics.PorterDuffColorFilter +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.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.LengthPercentage +import com.facebook.react.uimanager.LengthPercentageType +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.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 + +/** Tests for [InsetBoxShadowDrawable] and [OutsetBoxShadowDrawable] */ +@RunWith(RobolectricTestRunner::class) +class BoxShadowDrawableTest { + + private val context: Context = RuntimeEnvironment.getApplication() + + @Before + fun setup() { + ReactNativeFeatureFlagsForTests.setUp() + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics().apply { density = 2f }) + } + + @After + fun tearDown() { + DisplayMetricsHolder.setScreenDisplayMetrics(null) + } + + @Test + fun roundedInsetShadowDrawsTheSameGeometryObjectsOnEveryDraw() { + assertDrawsTheSameGeometryObjectsOnEveryDraw(insetShadow(roundedBy(8f))) + } + + @Test + fun rectangularInsetShadowDrawsTheSameGeometryObjectsOnEveryDraw() { + assertDrawsTheSameGeometryObjectsOnEveryDraw(insetShadow(null)) + } + + @Test + fun roundedOutsetShadowDrawsTheSameGeometryObjectsOnEveryDraw() { + assertDrawsTheSameGeometryObjectsOnEveryDraw(outsetShadow(roundedBy(8f))) + } + + @Test + fun rectangularOutsetShadowDrawsTheSameGeometryObjectsOnEveryDraw() { + assertDrawsTheSameGeometryObjectsOnEveryDraw(outsetShadow(null)) + } + + @Test + fun insetShadowFollowsBoundsThatGrowFromEmpty() { + val drawable = insetShadow(roundedBy(8f)) + drawable.draw(RecordingCanvas()) + + drawable.bounds = Rect(0, 0, 100, 50) + + assertDrawsLike(drawable, insetShadow(roundedBy(8f)).withBounds(Rect(0, 0, 100, 50))) + } + + @Test + fun outsetShadowFollowsBoundsThatGrowFromEmpty() { + val drawable = outsetShadow(roundedBy(8f)) + drawable.draw(RecordingCanvas()) + + drawable.bounds = Rect(0, 0, 100, 50) + + assertDrawsLike(drawable, outsetShadow(roundedBy(8f)).withBounds(Rect(0, 0, 100, 50))) + } + + @Test + fun insetShadowFollowsARadiusChangedInPlaceAndReassigned() { + val borderRadius = roundedBy(8f) + val drawable = insetShadow(borderRadius).withBounds(Rect(0, 0, 100, 50)) + drawable.draw(RecordingCanvas()) + + borderRadius.set(BorderRadiusProp.BORDER_RADIUS, points(4f)) + drawable.borderRadius = borderRadius + + assertDrawsLike(drawable, insetShadow(roundedBy(4f)).withBounds(Rect(0, 0, 100, 50))) + } + + @Test + fun outsetShadowFollowsARadiusChangedInPlaceAndReassigned() { + val borderRadius = roundedBy(8f) + val drawable = outsetShadow(borderRadius).withBounds(Rect(0, 0, 100, 50)) + drawable.draw(RecordingCanvas()) + + borderRadius.set(BorderRadiusProp.BORDER_RADIUS, points(4f)) + drawable.borderRadius = borderRadius + + assertDrawsLike(drawable, outsetShadow(roundedBy(4f)).withBounds(Rect(0, 0, 100, 50))) + } + + @Test + fun insetShadowFollowsBorderInsetsChangedInPlaceAndReassigned() { + val borderInsets = BorderInsets() + val drawable = insetShadow(null, borderInsets).withBounds(Rect(0, 0, 100, 50)) + drawable.draw(RecordingCanvas()) + + borderInsets.setBorderWidth(LogicalEdge.ALL, 3f) + drawable.borderInsets = borderInsets + + val expectedInsets = BorderInsets().apply { setBorderWidth(LogicalEdge.ALL, 3f) } + assertDrawsLike(drawable, insetShadow(null, expectedInsets).withBounds(Rect(0, 0, 100, 50))) + } + + @Test + fun insetShadowKeepsItsGeometryWhenItsAlphaOrColorFilterChanges() { + val borderRadius = spy(roundedBy(8f)) + val drawable = insetShadow(borderRadius).withBounds(Rect(0, 0, 100, 50)) + drawable.draw(RecordingCanvas()) + + drawable.alpha = 128 + drawable.colorFilter = PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN) + drawable.draw(RecordingCanvas()) + + verify(borderRadius, times(1)).resolve(any(), any(), any(), any()) + } + + @Test + fun outsetShadowKeepsItsGeometryWhenItsAlphaOrColorFilterChanges() { + val borderRadius = spy(roundedBy(8f)) + val drawable = outsetShadow(borderRadius).withBounds(Rect(0, 0, 100, 50)) + drawable.draw(RecordingCanvas()) + + drawable.alpha = 128 + drawable.colorFilter = PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN) + drawable.draw(RecordingCanvas()) + + verify(borderRadius, times(1)).resolve(any(), any(), any(), any()) + } + + @Test + fun insetShadowFollowsALayoutDirectionChange() { + val drawable = insetShadow(startRoundedBy(8f)).withBounds(Rect(0, 0, 100, 50)) + drawable.draw(RecordingCanvas()) + + drawable.layoutDirection = LayoutDirection.RTL + + val expected = insetShadow(startRoundedBy(8f)).withBounds(Rect(0, 0, 100, 50)) + expected.layoutDirection = LayoutDirection.RTL + assertDrawsLike(drawable, expected) + } + + @Test + fun outsetShadowFollowsALayoutDirectionChange() { + val drawable = outsetShadow(startRoundedBy(8f)).withBounds(Rect(0, 0, 100, 50)) + drawable.draw(RecordingCanvas()) + + drawable.layoutDirection = LayoutDirection.RTL + + val expected = outsetShadow(startRoundedBy(8f)).withBounds(Rect(0, 0, 100, 50)) + expected.layoutDirection = LayoutDirection.RTL + assertDrawsLike(drawable, expected) + } + + private fun insetShadow( + borderRadius: BorderRadiusStyle?, + borderInsets: BorderInsets? = null, + ): InsetBoxShadowDrawable = + InsetBoxShadowDrawable( + context = context, + shadowColor = Color.BLACK, + offsetX = 2f, + offsetY = 3f, + blurRadius = 0f, + spread = 1f, + borderInsets = borderInsets, + borderRadius = borderRadius, + ) + + private fun outsetShadow(borderRadius: BorderRadiusStyle?): OutsetBoxShadowDrawable = + OutsetBoxShadowDrawable( + context = context, + shadowColor = Color.BLACK, + offsetX = 2f, + offsetY = 3f, + blurRadius = 0f, + spread = 1f, + borderRadius = borderRadius, + ) + + private fun points(value: Float): LengthPercentage = + LengthPercentage(value, LengthPercentageType.POINT) + + private fun roundedBy(radius: Float): BorderRadiusStyle = + BorderRadiusStyle().apply { set(BorderRadiusProp.BORDER_RADIUS, points(radius)) } + + 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 assertDrawsTheSameGeometryObjectsOnEveryDraw(drawable: Drawable) { + drawable.bounds = Rect(0, 0, 100, 50) + + val firstDraw = RecordingCanvas().also { drawable.draw(it) }.geometry + val secondDraw = RecordingCanvas().also { drawable.draw(it) }.geometry + + assertThat(secondDraw).hasSameSizeAs(firstDraw) + firstDraw.zip(secondDraw).forEach { (first, second) -> assertThat(second).isSameAs(first) } + } + + private fun assertDrawsLike(drawable: Drawable, expected: Drawable) { + assertThat(RecordingCanvas().also { drawable.draw(it) }.calls) + .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 clipRect(rect: RectF): Boolean { + record("clipRect $rect", rect) + return true + } + + override fun clipOutRect(rect: Rect): Boolean { + record("clipOutRect $rect", rect) + return true + } + + override fun drawPath(path: Path, paint: Paint) { + record("drawPath ${describe(path)}", path) + } + + override fun drawRect(rect: RectF, paint: Paint) { + record("drawRect $rect", rect) + } + + override fun drawDoubleRoundRect( + outer: RectF, + outerRadii: FloatArray, + inner: RectF, + innerRadii: FloatArray, + paint: Paint, + ) { + record( + "drawDoubleRoundRect $outer ${outerRadii.toList()} $inner ${innerRadii.toList()}", + 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() + } +}