Skip to content

Commit 42ba24a

Browse files
hryhoriiK97meta-codesync[bot]
authored andcommitted
Use a round rect outline for Views with four equal corners on Android (#59376)
Summary: On Android, a View's background is a `CompositeBackgroundDrawable`, and the View's outline (used for `elevation` shadows) comes from its `getOutline`. **Current behavior.** For a View with rounded corners, `getOutline` allocates a new `Path`, adds a rounded rect with eight radii, and copies the path into the outline with `Outline.setPath`, every time Android asks for the outline. Android rebuilds the outline whenever the View's size changes and whenever its background drawable invalidates itself, for example when the background color changes or the drawable's bounds are updated during draw, which can happen two or three times for one change. Most rounded Views use the same radius for every corner, and for that shape Android has `Outline.setRoundRect`, which needs no path. On a Samsung Galaxy A14, a path outline costs about 15–19 µs per rebuild, and rebuilding it is 72% of the time to change a rounded View's background color. **With this change.** When all four corners resolve to the same circular radius (the eight values, including border insets, are equal), `getOutline` uses `Outline.setRoundRect(bounds, radius)`. A radius larger than half the shorter side is reduced to half of it, which is what `Path.addRoundRect` does to such radii (Skia scales all radii by the same factor so that they fit). Corners that differ, elliptical corners, and insets that differ by side still build the path as before. Two differences that don't change rendering in React Native: - On Android 12 and lower, a round rect outline can clip and a path outline can't. React Native doesn't set `clipToOutline`, so this only matters for a View where an app sets it, which then clips as requested. - A reduced radius is computed directly instead of through Skia's scale factor, so it can differ in the last bits of a float. ## Changelog: [ANDROID] [CHANGED] - Views with four equal rounded corners get their outline without building a path Pull Request resolved: #59376 Test Plan: - **Unit tests.** New `CompositeBackgroundDrawableTest` (Robolectric): four equal corners give a round rect outline with the radius in px; a radius larger than half the shorter side is reduced to it; differing corners still give a path outline; no rounded corners give a rect. `./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --tests "com.facebook.react.uimanager.drawable.CompositeBackgroundDrawableTest"`: 4 passed. On the current code, the first two fail and the other two pass. - The repo's pinned `ktfmt` check passes on both files. - **Device:** Samsung Galaxy A14 (SM-A145R, Android 15), RNTester built with Gradle in Release (`assembleRelease`, arm64-v8a) and compiled with `cmd package compile -m speed`. The scenarios are local Playground screens that are not part of this PR. - **A/B setup:** a single binary with a local switch (an intent extra read in `getOutline`, not part of this PR) that either uses this change or builds the path for every outline as before. Launches alternate between the arms (A B B A A B); each reads the UI thread's and RenderThread's CPU time from `/proc/<pid>/task/*/stat` over the same 20 s window and counts the time spent in `getOutline`. | 300 rounded cards (`borderRadius: 12`, border, background, two texts), one kind of change every 100 ms, 3 launches per arm | path (before) | this PR | change | |---|--:|--:|--:| | time per `getOutline`, cards move (a spacer above them changes height) | 14.6–17.6 µs | 3.6–3.9 µs | −77% | | time per `getOutline`, cards change background color | 18.5–19.4 µs | ~4.1 µs | −78% | | UI thread CPU, cards move | 6,870 ms | 5,413 ms | −21% | | UI thread CPU, cards change background color | 5,703 ms | 4,653 ms | −18% | | UI thread CPU, cards with `elevation: 3` move | 5,723 ms | 4,870 ms | −15% | The RenderThread's CPU time didn't change measurably, including for the cards with shadows. Moving cards changes their size too: positions are rounded to whole pixels, so a card's height changes by 1 px when its edges round differently, and each size change rebuilds the outline. Changing a background color doesn't change the shape, but Android rebuilds the outline when the drawable invalidates itself. - **Scrolling a feed** (a 1,000-row list with rounded cards, avatars and photos, 4 launches per arm): time in `getOutline` went from about 87 to 26 ms per 4,000 calls (−70%). Outlines are about 0.4% of the UI thread there, so its CPU time didn't change measurably. - **Rendering:** screens captured in both arms and compared pixel by pixel: a local screen of 8 Views with `elevation: 8` (equal corners, a circle, a pill whose radius is larger than half its height, a percentage radius, differing corners, a border, `overflow: hidden` with a child that overflows, square corners), RNTester's View examples and RNTester's Border examples. Every capture is identical between the arms, and between two captures of the same arm. - **Not run:** other devices, and Android versions other than 15. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed By: christophpurrer Differential Revision: D127120615 Pulled By: javache fbshipit-source-id: fb75f075af5628e576e6ce495056de06eeae03e2
1 parent 08f96fe commit 42ba24a

2 files changed

Lines changed: 114 additions & 8 deletions

File tree

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/CompositeBackgroundDrawable.kt‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,6 @@ internal class CompositeBackgroundDrawable(
187187
elevation shadow. */
188188
override fun getOutline(outline: Outline) {
189189
if (borderRadius?.hasRoundedBorders() == true) {
190-
val pathForOutline = Path()
191-
192190
val computedBorderRadius =
193191
borderRadius?.resolve(
194192
layoutDirection,
@@ -199,9 +197,8 @@ internal class CompositeBackgroundDrawable(
199197

200198
val computedBorderInsets = borderInsets?.resolve(layoutDirection, context)
201199

202-
computedBorderRadius?.let {
203-
pathForOutline.addRoundRect(
204-
RectF(bounds),
200+
val radii =
201+
computedBorderRadius?.let {
205202
floatArrayOf(
206203
(it.topLeft.horizontal + (computedBorderInsets?.left ?: 0f)).dpToPx(),
207204
(it.topLeft.vertical + (computedBorderInsets?.top ?: 0f)).dpToPx(),
@@ -211,11 +208,20 @@ internal class CompositeBackgroundDrawable(
211208
(it.bottomRight.vertical + (computedBorderInsets?.bottom ?: 0f)).dpToPx(),
212209
(it.bottomLeft.horizontal + (computedBorderInsets?.left ?: 0f)).dpToPx(),
213210
(it.bottomLeft.vertical + (computedBorderInsets?.bottom ?: 0f)).dpToPx(),
214-
),
215-
Path.Direction.CW,
216-
)
211+
)
212+
}
213+
214+
// Four equal circular corners describe the same shape as the rounded path below, without
215+
// building a path. Like the path, a radius larger than half the shorter side is reduced to
216+
// it.
217+
if (radii != null && radii.all { it == radii[0] }) {
218+
outline.setRoundRect(bounds, minOf(radii[0], minOf(bounds.width(), bounds.height()) / 2f))
219+
return
217220
}
218221

222+
val pathForOutline = Path()
223+
radii?.let { pathForOutline.addRoundRect(RectF(bounds), it, Path.Direction.CW) }
224+
219225
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
220226
outline.setPath(pathForOutline)
221227
} else {
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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.drawable
9+
10+
import android.content.Context
11+
import android.graphics.Outline
12+
import android.graphics.Rect
13+
import android.util.DisplayMetrics
14+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
15+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests
16+
import com.facebook.react.uimanager.DisplayMetricsHolder
17+
import com.facebook.react.uimanager.LengthPercentage
18+
import com.facebook.react.uimanager.LengthPercentageType
19+
import com.facebook.react.uimanager.style.BorderRadiusProp
20+
import com.facebook.react.uimanager.style.BorderRadiusStyle
21+
import org.assertj.core.api.Assertions.assertThat
22+
import org.junit.After
23+
import org.junit.Before
24+
import org.junit.Test
25+
import org.junit.runner.RunWith
26+
import org.robolectric.RobolectricTestRunner
27+
import org.robolectric.RuntimeEnvironment
28+
29+
/** Tests for the outline of [CompositeBackgroundDrawable] */
30+
@RunWith(RobolectricTestRunner::class)
31+
class CompositeBackgroundDrawableTest {
32+
33+
private val context: Context = RuntimeEnvironment.getApplication()
34+
35+
@Before
36+
fun setUp() {
37+
ReactNativeFeatureFlagsForTests.setUp()
38+
DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics().apply { density = 2f })
39+
}
40+
41+
@After
42+
fun tearDown() {
43+
DisplayMetricsHolder.setScreenDisplayMetrics(null)
44+
ReactNativeFeatureFlags.dangerouslyReset()
45+
}
46+
47+
@Test
48+
fun equalCornersGiveARoundRect() {
49+
val outline =
50+
outlineOf(BorderRadiusStyle(listOf(BorderRadiusProp.BORDER_RADIUS to points(10f))))
51+
52+
val rect = Rect()
53+
assertThat(outline.getRect(rect)).isTrue()
54+
assertThat(rect).isEqualTo(Rect(0, 0, 200, 100))
55+
assertThat(outline.radius).isEqualTo(20f)
56+
}
57+
58+
@Test
59+
fun radiusLargerThanHalfTheShorterSideIsReducedToIt() {
60+
val outline =
61+
outlineOf(BorderRadiusStyle(listOf(BorderRadiusProp.BORDER_RADIUS to points(80f))))
62+
63+
assertThat(outline.getRect(Rect())).isTrue()
64+
assertThat(outline.radius).isEqualTo(50f)
65+
}
66+
67+
@Test
68+
fun differentCornersGiveAPath() {
69+
val outline =
70+
outlineOf(
71+
BorderRadiusStyle(
72+
listOf(
73+
BorderRadiusProp.BORDER_RADIUS to points(10f),
74+
BorderRadiusProp.BORDER_TOP_LEFT_RADIUS to points(4f),
75+
)
76+
)
77+
)
78+
79+
assertThat(outline.isEmpty).isFalse()
80+
assertThat(outline.getRect(Rect())).isFalse()
81+
}
82+
83+
@Test
84+
fun noRoundedCornersGiveARect() {
85+
val outline = outlineOf(null)
86+
87+
val rect = Rect()
88+
assertThat(outline.getRect(rect)).isTrue()
89+
assertThat(rect).isEqualTo(Rect(0, 0, 200, 100))
90+
assertThat(outline.radius).isEqualTo(0f)
91+
}
92+
93+
private fun points(value: Float) = LengthPercentage(value, LengthPercentageType.POINT)
94+
95+
private fun outlineOf(borderRadius: BorderRadiusStyle?): Outline {
96+
val drawable = CompositeBackgroundDrawable(context = context, borderRadius = borderRadius)
97+
drawable.setBounds(0, 0, 200, 100)
98+
return Outline().also { drawable.getOutline(it) }
99+
}
100+
}

0 commit comments

Comments
 (0)