Skip to content

Commit 19d6d17

Browse files
shubh73meta-codesync[bot]
authored andcommitted
Fix Switch losing outline and box shadow when backgroundColor is set on Android (#59425)
Summary: On Android, `outline*`, `boxShadow` and the `borderRadius` they're drawn with are kept in the `CompositeBackgroundDrawable` that `BackgroundStyleApplicator` installs as a view's background. **Current behavior.** `ReactSwitch.setBackgroundColor` replaces the background with a new `RippleDrawable`, which throws away that composite and everything in it. Fabric applies a view's props in the order of its prop map, which depends on the set of prop keys, so whether a `Switch` keeps its outline depends on which props it has: - With `{width: 60, height: 40, backgroundColor: 'white', outlineWidth: 3, outlineColor: 'blue', outlineOffset: 8}`, the props are applied as `outlineColor`, `outlineOffset`, `backgroundColor`, `outlineWidth`. `backgroundColor` drops the color and offset, and `outlineWidth` then creates a new outline with the defaults, so the `Switch` mounts with a black outline and no offset. Updating `outlineOffset` later restores the offset, but the color stays black because `outlineColor` isn't sent again. - Any update to `backgroundColor` removes the outline and box shadow entirely, whatever order they were applied in. - `thumbColor` also sets the ripple's color. That color is lost when `backgroundColor` is applied after it, since the new ripple uses the background color, and it isn't applied at all when the ripple is inside a composite, since `setThumbColor` only checks whether the background itself is a `RippleDrawable`. **With this change.** - When the background is already a `CompositeBackgroundDrawable`, `setBackgroundColor` replaces only its original background layer with the ripple, and keeps the other layers. `CompositeBackgroundDrawable` gets a `withNewOriginalBackground`, like its other `withNew*` methods. - `ReactSwitch` keeps the last thumb color and builds the ripple with it (or with the background color once `thumbColor` is removed), and `setThumbColor` finds the ripple inside the composite. `ReactSwitch` is the only view in ReactAndroid that replaces its background in `setBackgroundColor`; the others go through `BackgroundStyleApplicator`. Moving the `Switch`'s background color to `BackgroundStyleApplicator.setBackgroundColor` and its ripple to the feedback underlay would remove the special case, but it would also move the ripple above the border and change how the background is drawn, so this keeps the existing ripple and only stops it from replacing the other layers. ## Changelog: [ANDROID] [FIXED] - Fix `Switch` losing its outline, `boxShadow` and `thumbColor` ripple color when `backgroundColor` is set Pull Request resolved: #59425 Test Plan: - **Unit tests.** New `ReactSwitchManagerTest` (Robolectric): outline props set before the background color are kept; updating the background color keeps the outline; the background color keeps a box shadow; `thumbColor` sets the color of a ripple inside the composite; a thumb color set before the background color is kept; a removed thumb color isn't reused for the next background color. `./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --tests "com.facebook.react.views.switchview.ReactSwitchManagerTest"`: 6 passed. On the current code the first 5 fail (black instead of blue, no outline, the composite replaced by a `RippleDrawable`, the ripple left white instead of green twice); the last passes there, since the current code doesn't store the thumb color, and guards the new stored color. - `./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest`: 832 tests, 0 failures. - `yarn format-check-kotlin` passes. - **Device:** OPPO K12x 5G (CPH2667, Android 16), RNTester debug build, New Architecture, with the `Switch` above next to a `View` with the same style. Logging the prop setters and `setBackgroundColor` (not part of this PR) showed, for the `Switch`: ``` setOutlineColor value=-16776961 bg=RippleDrawable setOutlineOffset value=8.0 bg=CompositeBackgroundDrawable ReactSwitch.setBackgroundColor color=ffffffff replacing CompositeBackgroundDrawable setOutlineWidth value=3.0 bg=RippleDrawable ``` - Before: the `Switch` mounts with a black outline and no offset; after setting `backgroundColor` to yellow, its outline is gone, and a `Switch` with `boxShadow: '0 0 0 6px red'` loses its shadow. - After: the `Switch` matches the `View` on mount, after updating `outlineOffset`, and after setting `backgroundColor`. - With `thumbColor: 'red'` and the outline, long-pressing the `Switch` shows no ripple before (it's white, like the background) and a red ripple after. - **Screenshots.** Each image shows, left to right: on mount, after updating `outlineOffset`, and after setting `backgroundColor` to yellow. In each row the `Switch` is on the left and a `View` with the same style on the right; the middle row (no `backgroundColor`) is the case not fixed here. Before: <img width="720" alt="Before" src="https://github.com/user-attachments/assets/bf96545a-d6a7-4b3d-8dc4-5788a48371a1" /> After: <img width="720" alt="After" src="https://github.com/user-attachments/assets/090a1036-9b3e-43d2-ac1e-8b5eba97fa3d" /> Long-pressing a `Switch` with `thumbColor: 'red'`, `backgroundColor: 'white'` and a blue outline, on `main` (left; the outline is also black there, from the prop order above) and with this change (right): <img width="720" alt="Ripple before and after" src="https://github.com/user-attachments/assets/cc8cef77-a213-43ba-ab3b-1740aeb85f6b" /> - **Not run:** other devices and Android versions, and the old architecture, where outline and box shadow aren't supported. Clearing the stored ripple color when `thumbColor` is removed is covered by the unit test only, not checked on a device. Not fixed here: a `Switch` with an outline and no `backgroundColor` draws no outline. Its default background is a projected ripple, so the whole composite is projected onto an ancestor and hidden behind flattened content. That's the same problem #58902 fixed for the feedback underlay behind `fixBorderlessRippleAndroid`, and it would need the same treatment for the original background. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed By: javache Differential Revision: D127266607 Pulled By: Abbondanzo fbshipit-source-id: bfb0c98060102fc1e79c9a89f4a5d9acb78306a0
1 parent 1d9ed87 commit 19d6d17

3 files changed

Lines changed: 179 additions & 6 deletions

File tree

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,22 @@ internal class CompositeBackgroundDrawable(
167167
)
168168
}
169169

170+
fun withNewOriginalBackground(newOriginalBackground: Drawable?): CompositeBackgroundDrawable {
171+
return CompositeBackgroundDrawable(
172+
context,
173+
newOriginalBackground,
174+
outerShadows,
175+
background,
176+
backgroundImage,
177+
border,
178+
feedbackUnderlay,
179+
innerShadows,
180+
outline,
181+
borderInsets,
182+
borderRadius,
183+
)
184+
}
185+
170186
fun withNewFeedbackUnderlay(newUnderlay: Drawable?): CompositeBackgroundDrawable {
171187
return CompositeBackgroundDrawable(
172188
context,

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/switchview/ReactSwitch.kt‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import android.graphics.drawable.ColorDrawable
1515
import android.graphics.drawable.Drawable
1616
import android.graphics.drawable.RippleDrawable
1717
import androidx.appcompat.widget.SwitchCompat
18+
import com.facebook.react.uimanager.drawable.CompositeBackgroundDrawable
1819

1920
/**
2021
* Switch that has its value controlled by JS. Whenever the value of the switch changes, we do not
@@ -26,6 +27,7 @@ internal class ReactSwitch(context: Context) : SwitchCompat(context) {
2627
private var allowChange = true
2728
private var trackColorForFalse: Int? = null
2829
private var trackColorForTrue: Int? = null
30+
private var rippleColor: Int? = null
2931

3032
override fun setChecked(checked: Boolean) {
3133
if (allowChange && isChecked != checked) {
@@ -41,8 +43,16 @@ internal class ReactSwitch(context: Context) : SwitchCompat(context) {
4143
}
4244

4345
override fun setBackgroundColor(color: Int) {
44-
background =
45-
RippleDrawable(createRippleDrawableColorStateList(color), ColorDrawable(color), null)
46+
val ripple =
47+
RippleDrawable(
48+
createRippleDrawableColorStateList(rippleColor ?: color),
49+
ColorDrawable(color),
50+
null,
51+
)
52+
// Keep the outline, box shadows and other styles layered over the background by
53+
// BackgroundStyleApplicator, which may have been applied before the background color.
54+
val composite = background as? CompositeBackgroundDrawable
55+
background = composite?.withNewOriginalBackground(ripple) ?: ripple
4656
}
4757

4858
fun setColor(drawable: Drawable, color: Int?) {
@@ -60,10 +70,14 @@ internal class ReactSwitch(context: Context) : SwitchCompat(context) {
6070
fun setThumbColor(color: Int?) {
6171
setColor(super.getThumbDrawable(), color)
6272

63-
// Set the ripple color if background is instance of RippleDrawable
64-
if (color != null && super.getBackground() is RippleDrawable) {
65-
val customColorState = createRippleDrawableColorStateList(color)
66-
(super.getBackground() as RippleDrawable).setColor(customColorState)
73+
// Set the ripple color if the background is, or is wrapped around, a RippleDrawable
74+
val currentBackground = super.getBackground()
75+
val ripple =
76+
(currentBackground as? CompositeBackgroundDrawable)?.originalBackground as? RippleDrawable
77+
?: currentBackground as? RippleDrawable
78+
rippleColor = color
79+
if (color != null && ripple != null) {
80+
ripple.setColor(createRippleDrawableColorStateList(color))
6781
}
6882
}
6983

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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.views.switchview
9+
10+
import android.content.res.ColorStateList
11+
import android.graphics.Color
12+
import android.graphics.drawable.ColorDrawable
13+
import android.graphics.drawable.RippleDrawable
14+
import android.util.DisplayMetrics
15+
import android.view.ContextThemeWrapper
16+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
17+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests
18+
import com.facebook.react.uimanager.BackgroundStyleApplicator
19+
import com.facebook.react.uimanager.DisplayMetricsHolder
20+
import com.facebook.react.uimanager.drawable.CompositeBackgroundDrawable
21+
import com.facebook.react.uimanager.style.BoxShadow
22+
import org.assertj.core.api.Assertions.assertThat
23+
import org.junit.After
24+
import org.junit.Before
25+
import org.junit.Test
26+
import org.junit.runner.RunWith
27+
import org.robolectric.RobolectricTestRunner
28+
import org.robolectric.RuntimeEnvironment
29+
30+
/** Tests for how [ReactSwitchManager] applies a background color alongside other view styles. */
31+
@RunWith(RobolectricTestRunner::class)
32+
class ReactSwitchManagerTest {
33+
34+
private lateinit var manager: ReactSwitchManager
35+
private lateinit var view: ReactSwitch
36+
37+
@Before
38+
fun setUp() {
39+
ReactNativeFeatureFlagsForTests.setUp()
40+
DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics().apply { density = 2f })
41+
manager = ReactSwitchManager()
42+
view =
43+
ReactSwitch(
44+
ContextThemeWrapper(
45+
RuntimeEnvironment.getApplication(),
46+
androidx.appcompat.R.style.Theme_AppCompat,
47+
)
48+
)
49+
}
50+
51+
@After
52+
fun tearDown() {
53+
DisplayMetricsHolder.setScreenDisplayMetrics(null)
54+
ReactNativeFeatureFlags.dangerouslyReset()
55+
}
56+
57+
@Test
58+
fun backgroundColorKeepsOutlinePropsSetBeforeIt() {
59+
manager.setOutlineColor(view, Color.BLUE)
60+
manager.setOutlineOffset(view, 8f)
61+
manager.setBackgroundColor(view, Color.WHITE)
62+
manager.setOutlineWidth(view, 3f)
63+
64+
assertThat(BackgroundStyleApplicator.getOutlineColor(view)).isEqualTo(Color.BLUE)
65+
assertThat(BackgroundStyleApplicator.getOutlineOffset(view)).isEqualTo(16f)
66+
assertThat(rippleColor()).isEqualTo(Color.WHITE)
67+
}
68+
69+
@Test
70+
fun backgroundColorUpdateKeepsOutline() {
71+
manager.setBackgroundColor(view, Color.WHITE)
72+
manager.setOutlineColor(view, Color.BLUE)
73+
manager.setOutlineWidth(view, 3f)
74+
manager.setBackgroundColor(view, Color.YELLOW)
75+
76+
assertThat(BackgroundStyleApplicator.getOutlineColor(view)).isEqualTo(Color.BLUE)
77+
assertThat(rippleColor()).isEqualTo(Color.YELLOW)
78+
}
79+
80+
@Test
81+
fun backgroundColorKeepsBoxShadow() {
82+
BackgroundStyleApplicator.setBoxShadow(
83+
view,
84+
listOf(BoxShadow(offsetX = 0f, offsetY = 0f, color = Color.RED, spreadDistance = 6f)),
85+
)
86+
manager.setBackgroundColor(view, Color.WHITE)
87+
88+
val composite = view.background as CompositeBackgroundDrawable
89+
assertThat(composite.outerShadows).hasSize(1)
90+
assertThat(rippleColor()).isEqualTo(Color.WHITE)
91+
}
92+
93+
@Test
94+
fun thumbColorTintsRippleWrappedByOtherStyles() {
95+
manager.setBackgroundColor(view, Color.WHITE)
96+
manager.setOutlineWidth(view, 3f)
97+
manager.setThumbColor(view, Color.GREEN)
98+
99+
val pressed = intArrayOf(android.R.attr.state_pressed)
100+
assertThat(rippleEffectColor().getColorForState(pressed, 0)).isEqualTo(Color.GREEN)
101+
}
102+
103+
@Test
104+
fun backgroundColorKeepsThumbColorSetBeforeIt() {
105+
manager.setThumbColor(view, Color.GREEN)
106+
manager.setBackgroundColor(view, Color.WHITE)
107+
108+
val pressed = intArrayOf(android.R.attr.state_pressed)
109+
assertThat(rippleEffectColor().getColorForState(pressed, 0)).isEqualTo(Color.GREEN)
110+
assertThat(rippleColor()).isEqualTo(Color.WHITE)
111+
}
112+
113+
@Test
114+
fun backgroundColorDoesNotUseClearedThumbColor() {
115+
manager.setThumbColor(view, Color.GREEN)
116+
manager.setThumbColor(view, null)
117+
manager.setBackgroundColor(view, Color.WHITE)
118+
119+
val pressed = intArrayOf(android.R.attr.state_pressed)
120+
assertThat(rippleEffectColor().getColorForState(pressed, 0)).isEqualTo(Color.WHITE)
121+
}
122+
123+
private fun ripple(): RippleDrawable {
124+
val background = view.background
125+
val drawable =
126+
if (background is CompositeBackgroundDrawable) background.originalBackground else background
127+
return drawable as RippleDrawable
128+
}
129+
130+
private fun rippleColor(): Int = (ripple().getDrawable(0) as ColorDrawable).color
131+
132+
// RippleDrawable has no public getter for the color passed to setColor().
133+
private fun rippleEffectColor(): ColorStateList {
134+
val state =
135+
RippleDrawable::class
136+
.java
137+
.getDeclaredField("mState")
138+
.apply { isAccessible = true }
139+
.get(ripple())
140+
return state.javaClass.getDeclaredField("mColor").apply { isAccessible = true }.get(state)
141+
as ColorStateList
142+
}
143+
}

0 commit comments

Comments
 (0)