From 1abea002919ac139630eec5ac0777e08524e9b6d Mon Sep 17 00:00:00 2001 From: Shubh Porwal Date: Sat, 24 Oct 2026 01:39:26 +0530 Subject: [PATCH 1/3] Keep outline and box shadow when setting a Switch's background color on Android ReactSwitch.setBackgroundColor replaced the view's background with a new RippleDrawable. Outline and boxShadow are drawn by the CompositeBackgroundDrawable that BackgroundStyleApplicator installs as the background, so it was discarded whenever backgroundColor was applied after them. Fabric applies props in map iteration order, so depending on the keys a Switch's outlineColor/outlineOffset could be dropped on mount (with a later outlineWidth recreating the outline with its black, zero-offset defaults), and any backgroundColor update removed the outline and box shadow entirely. Swap the ripple into the composite's original background instead, and let setThumbColor find the ripple inside the composite. Co-Authored-By: Claude Opus 5.5 --- .../drawable/CompositeBackgroundDrawable.kt | 16 +++ .../react/views/switchview/ReactSwitch.kt | 18 ++- .../switchview/ReactSwitchManagerTest.kt | 123 ++++++++++++++++++ 3 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/switchview/ReactSwitchManagerTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/CompositeBackgroundDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/CompositeBackgroundDrawable.kt index 626d7286418e..b42948166d46 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/CompositeBackgroundDrawable.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/CompositeBackgroundDrawable.kt @@ -167,6 +167,22 @@ internal class CompositeBackgroundDrawable( ) } + fun withNewOriginalBackground(newOriginalBackground: Drawable?): CompositeBackgroundDrawable { + return CompositeBackgroundDrawable( + context, + newOriginalBackground, + outerShadows, + background, + backgroundImage, + border, + feedbackUnderlay, + innerShadows, + outline, + borderInsets, + borderRadius, + ) + } + fun withNewFeedbackUnderlay(newUnderlay: Drawable?): CompositeBackgroundDrawable { return CompositeBackgroundDrawable( context, diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/switchview/ReactSwitch.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/switchview/ReactSwitch.kt index 1b3c42963521..7e6f7b5665e1 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/switchview/ReactSwitch.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/switchview/ReactSwitch.kt @@ -15,6 +15,7 @@ import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.RippleDrawable import androidx.appcompat.widget.SwitchCompat +import com.facebook.react.uimanager.drawable.CompositeBackgroundDrawable /** * Switch that has its value controlled by JS. Whenever the value of the switch changes, we do not @@ -41,8 +42,12 @@ internal class ReactSwitch(context: Context) : SwitchCompat(context) { } override fun setBackgroundColor(color: Int) { - background = + val ripple = RippleDrawable(createRippleDrawableColorStateList(color), ColorDrawable(color), null) + // Keep the outline, box shadows and other styles layered over the background by + // BackgroundStyleApplicator, which may have been applied before the background color. + val composite = background as? CompositeBackgroundDrawable + background = composite?.withNewOriginalBackground(ripple) ?: ripple } fun setColor(drawable: Drawable, color: Int?) { @@ -60,10 +65,13 @@ internal class ReactSwitch(context: Context) : SwitchCompat(context) { fun setThumbColor(color: Int?) { setColor(super.getThumbDrawable(), color) - // Set the ripple color if background is instance of RippleDrawable - if (color != null && super.getBackground() is RippleDrawable) { - val customColorState = createRippleDrawableColorStateList(color) - (super.getBackground() as RippleDrawable).setColor(customColorState) + // Set the ripple color if the background is, or is wrapped around, a RippleDrawable + val currentBackground = super.getBackground() + val ripple = + (currentBackground as? CompositeBackgroundDrawable)?.originalBackground as? RippleDrawable + ?: currentBackground as? RippleDrawable + if (color != null && ripple != null) { + ripple.setColor(createRippleDrawableColorStateList(color)) } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/switchview/ReactSwitchManagerTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/switchview/ReactSwitchManagerTest.kt new file mode 100644 index 000000000000..9d1f9eab66d3 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/switchview/ReactSwitchManagerTest.kt @@ -0,0 +1,123 @@ +/* + * 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.views.switchview + +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.RippleDrawable +import android.util.DisplayMetrics +import android.view.ContextThemeWrapper +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.BackgroundStyleApplicator +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.drawable.CompositeBackgroundDrawable +import com.facebook.react.uimanager.style.BoxShadow +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.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +/** Tests for how [ReactSwitchManager] applies a background color alongside other view styles. */ +@RunWith(RobolectricTestRunner::class) +class ReactSwitchManagerTest { + + private lateinit var manager: ReactSwitchManager + private lateinit var view: ReactSwitch + + @Before + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics().apply { density = 2f }) + manager = ReactSwitchManager() + view = + ReactSwitch( + ContextThemeWrapper( + RuntimeEnvironment.getApplication(), + androidx.appcompat.R.style.Theme_AppCompat, + ) + ) + } + + @After + fun tearDown() { + DisplayMetricsHolder.setScreenDisplayMetrics(null) + ReactNativeFeatureFlags.dangerouslyReset() + } + + @Test + fun backgroundColorKeepsOutlinePropsSetBeforeIt() { + manager.setOutlineColor(view, Color.BLUE) + manager.setOutlineOffset(view, 8f) + manager.setBackgroundColor(view, Color.WHITE) + manager.setOutlineWidth(view, 3f) + + assertThat(BackgroundStyleApplicator.getOutlineColor(view)).isEqualTo(Color.BLUE) + assertThat(BackgroundStyleApplicator.getOutlineOffset(view)).isEqualTo(16f) + assertThat(rippleColor()).isEqualTo(Color.WHITE) + } + + @Test + fun backgroundColorUpdateKeepsOutline() { + manager.setBackgroundColor(view, Color.WHITE) + manager.setOutlineColor(view, Color.BLUE) + manager.setOutlineWidth(view, 3f) + manager.setBackgroundColor(view, Color.YELLOW) + + assertThat(BackgroundStyleApplicator.getOutlineColor(view)).isEqualTo(Color.BLUE) + assertThat(rippleColor()).isEqualTo(Color.YELLOW) + } + + @Test + fun backgroundColorKeepsBoxShadow() { + BackgroundStyleApplicator.setBoxShadow( + view, + listOf(BoxShadow(offsetX = 0f, offsetY = 0f, color = Color.RED, spreadDistance = 6f)), + ) + manager.setBackgroundColor(view, Color.WHITE) + + val composite = view.background as CompositeBackgroundDrawable + assertThat(composite.outerShadows).hasSize(1) + assertThat(rippleColor()).isEqualTo(Color.WHITE) + } + + @Test + fun thumbColorTintsRippleWrappedByOtherStyles() { + manager.setBackgroundColor(view, Color.WHITE) + manager.setOutlineWidth(view, 3f) + manager.setThumbColor(view, Color.GREEN) + + val pressed = intArrayOf(android.R.attr.state_pressed) + assertThat(rippleEffectColor().getColorForState(pressed, 0)).isEqualTo(Color.GREEN) + } + + private fun ripple(): RippleDrawable { + val background = view.background + val drawable = + if (background is CompositeBackgroundDrawable) background.originalBackground else background + return drawable as RippleDrawable + } + + private fun rippleColor(): Int = (ripple().getDrawable(0) as ColorDrawable).color + + // RippleDrawable has no public getter for the color passed to setColor(). + private fun rippleEffectColor(): ColorStateList { + val state = + RippleDrawable::class + .java + .getDeclaredField("mState") + .apply { isAccessible = true } + .get(ripple()) + return state.javaClass.getDeclaredField("mColor").apply { isAccessible = true }.get(state) + as ColorStateList + } +} From 0049973fa75ecee3bebba6fd4dd14db791b75eb3 Mon Sep 17 00:00:00 2001 From: Shubh Porwal Date: Sat, 24 Oct 2026 02:06:16 +0530 Subject: [PATCH 2/3] Keep the thumb color's ripple color when a Switch's background color is set setBackgroundColor built the new ripple with the background color, dropping a ripple color set by an earlier thumbColor. Also mutate the ripple before recoloring it, since the theme's ripple may share its state with other drawables loaded from the same resource. Co-Authored-By: Claude Opus 5.5 --- .../facebook/react/views/switchview/ReactSwitch.kt | 14 +++++++++++--- .../views/switchview/ReactSwitchManagerTest.kt | 10 ++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/switchview/ReactSwitch.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/switchview/ReactSwitch.kt index 7e6f7b5665e1..dbe0c2a6c4c2 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/switchview/ReactSwitch.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/switchview/ReactSwitch.kt @@ -27,6 +27,7 @@ internal class ReactSwitch(context: Context) : SwitchCompat(context) { private var allowChange = true private var trackColorForFalse: Int? = null private var trackColorForTrue: Int? = null + private var rippleColor: Int? = null override fun setChecked(checked: Boolean) { if (allowChange && isChecked != checked) { @@ -43,7 +44,11 @@ internal class ReactSwitch(context: Context) : SwitchCompat(context) { override fun setBackgroundColor(color: Int) { val ripple = - RippleDrawable(createRippleDrawableColorStateList(color), ColorDrawable(color), null) + RippleDrawable( + createRippleDrawableColorStateList(rippleColor ?: color), + ColorDrawable(color), + null, + ) // Keep the outline, box shadows and other styles layered over the background by // BackgroundStyleApplicator, which may have been applied before the background color. val composite = background as? CompositeBackgroundDrawable @@ -70,8 +75,11 @@ internal class ReactSwitch(context: Context) : SwitchCompat(context) { val ripple = (currentBackground as? CompositeBackgroundDrawable)?.originalBackground as? RippleDrawable ?: currentBackground as? RippleDrawable - if (color != null && ripple != null) { - ripple.setColor(createRippleDrawableColorStateList(color)) + if (color != null) { + rippleColor = color + // The theme's ripple may share its state with other drawables loaded from the same resource + ripple?.mutate() + ripple?.setColor(createRippleDrawableColorStateList(color)) } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/switchview/ReactSwitchManagerTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/switchview/ReactSwitchManagerTest.kt index 9d1f9eab66d3..fc1ff98b8d0d 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/switchview/ReactSwitchManagerTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/switchview/ReactSwitchManagerTest.kt @@ -100,6 +100,16 @@ class ReactSwitchManagerTest { assertThat(rippleEffectColor().getColorForState(pressed, 0)).isEqualTo(Color.GREEN) } + @Test + fun backgroundColorKeepsThumbColorSetBeforeIt() { + manager.setThumbColor(view, Color.GREEN) + manager.setBackgroundColor(view, Color.WHITE) + + val pressed = intArrayOf(android.R.attr.state_pressed) + assertThat(rippleEffectColor().getColorForState(pressed, 0)).isEqualTo(Color.GREEN) + assertThat(rippleColor()).isEqualTo(Color.WHITE) + } + private fun ripple(): RippleDrawable { val background = view.background val drawable = From a352df56fd4e079d0d8e893f327d4325f8d202c6 Mon Sep 17 00:00:00 2001 From: Shubh Porwal Date: Sat, 24 Oct 2026 02:10:26 +0530 Subject: [PATCH 3/3] Clear a Switch's stored ripple color when thumbColor is removed setThumbColor only stored non-null colors, so after thumbColor was removed the next background color still built its ripple with the old thumb color. Also drop the mutate() call: each RippleDrawable created from a resource copies its RippleState, and setColor only writes to that copy, so recoloring it can't affect other drawables. Co-Authored-By: Claude Opus 5.5 --- .../com/facebook/react/views/switchview/ReactSwitch.kt | 8 +++----- .../react/views/switchview/ReactSwitchManagerTest.kt | 10 ++++++++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/switchview/ReactSwitch.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/switchview/ReactSwitch.kt index dbe0c2a6c4c2..c1fec6bb1f09 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/switchview/ReactSwitch.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/switchview/ReactSwitch.kt @@ -75,11 +75,9 @@ internal class ReactSwitch(context: Context) : SwitchCompat(context) { val ripple = (currentBackground as? CompositeBackgroundDrawable)?.originalBackground as? RippleDrawable ?: currentBackground as? RippleDrawable - if (color != null) { - rippleColor = color - // The theme's ripple may share its state with other drawables loaded from the same resource - ripple?.mutate() - ripple?.setColor(createRippleDrawableColorStateList(color)) + rippleColor = color + if (color != null && ripple != null) { + ripple.setColor(createRippleDrawableColorStateList(color)) } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/switchview/ReactSwitchManagerTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/switchview/ReactSwitchManagerTest.kt index fc1ff98b8d0d..83f073be5b72 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/switchview/ReactSwitchManagerTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/switchview/ReactSwitchManagerTest.kt @@ -110,6 +110,16 @@ class ReactSwitchManagerTest { assertThat(rippleColor()).isEqualTo(Color.WHITE) } + @Test + fun backgroundColorDoesNotUseClearedThumbColor() { + manager.setThumbColor(view, Color.GREEN) + manager.setThumbColor(view, null) + manager.setBackgroundColor(view, Color.WHITE) + + val pressed = intArrayOf(android.R.attr.state_pressed) + assertThat(rippleEffectColor().getColorForState(pressed, 0)).isEqualTo(Color.WHITE) + } + private fun ripple(): RippleDrawable { val background = view.background val drawable =