From 64ed47eb263582ab699ecce65be3e6235bf51b72 Mon Sep 17 00:00:00 2001 From: abbo Date: Fri, 23 Oct 2026 15:43:42 -0700 Subject: [PATCH 1/2] Generated from a GitHub Pull Request. Run 'jf sync' on this diff to load the correct commit data. Differential Revision: D127266607 --- .../drawable/CompositeBackgroundDrawable.kt | 16 ++ .../react/views/switchview/ReactSwitch.kt | 26 +++- .../switchview/ReactSwitchManagerTest.kt | 143 ++++++++++++++++++ 3 files changed, 179 insertions(+), 6 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..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 @@ -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 @@ -26,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) { @@ -41,8 +43,16 @@ internal class ReactSwitch(context: Context) : SwitchCompat(context) { } override fun setBackgroundColor(color: Int) { - background = - RippleDrawable(createRippleDrawableColorStateList(color), ColorDrawable(color), null) + val ripple = + 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 + background = composite?.withNewOriginalBackground(ripple) ?: ripple } fun setColor(drawable: Drawable, color: Int?) { @@ -60,10 +70,14 @@ 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 + 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 new file mode 100644 index 000000000000..83f073be5b72 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/switchview/ReactSwitchManagerTest.kt @@ -0,0 +1,143 @@ +/* + * 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) + } + + @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) + } + + @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 = + 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 cfba379c9208b11c111de10b9980513fb6ea8637 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Sat, 24 Oct 2026 07:43:03 -0700 Subject: [PATCH 2/2] Reset Switch ripple tint when thumbColor is cleared, fix getOutlineWidth Summary: Two follow-ups to the Switch background-color fix (parent import). 1. Clearing thumbColor left the live ripple tinted with the old thumb color. setThumbColor stored null but skipped recoloring the existing ripple, so a Switch with backgroundColor + thumbColor kept the thumb tint on its pressed ripple after thumbColor was removed. Now a null thumbColor resets the ripple effect to the background's own content color, matching what the next setBackgroundColor would build. 2. BackgroundStyleApplicator.getOutlineWidth returned outlineOffset instead of outlineWidth, so readers got the offset value (or null when only a width was set). Changelog: [Android][Fixed] - Fix Switch keeping the thumbColor ripple tint after thumbColor is removed, and fix getOutlineWidth returning the outline offset Differential Revision: D127268268 --- .../ReactAndroid/api/ReactAndroid.api | 2 + .../uimanager/BackgroundStyleApplicator.kt | 28 +++++++++++++- .../react/views/switchview/ReactSwitch.kt | 28 +++++++++++--- .../switchview/ReactSwitchManagerTest.kt | 37 +++++++++++++++++++ 4 files changed, 88 insertions(+), 7 deletions(-) diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index ba540f18c54d..241a46fc466a 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -3171,6 +3171,7 @@ public final class com/facebook/react/uimanager/BackgroundStyleApplicator { public static final fun getBorderRadius (Landroid/view/View;Lcom/facebook/react/uimanager/style/BorderRadiusProp;)Lcom/facebook/react/uimanager/LengthPercentage; public static final fun getBorderStyle (Landroid/view/View;)Lcom/facebook/react/uimanager/style/BorderStyle; public static final fun getBorderWidth (Landroid/view/View;Lcom/facebook/react/uimanager/style/LogicalEdge;)Ljava/lang/Float; + public static final fun getOriginalBackground (Landroid/view/View;)Landroid/graphics/drawable/Drawable; public static final fun getOutlineColor (Landroid/view/View;)Ljava/lang/Integer; public final fun getOutlineOffset (Landroid/view/View;)Ljava/lang/Float; public final fun getOutlineStyle (Landroid/view/View;)Lcom/facebook/react/uimanager/style/OutlineStyle; @@ -3185,6 +3186,7 @@ public final class com/facebook/react/uimanager/BackgroundStyleApplicator { public static final fun setBoxShadow (Landroid/view/View;Lcom/facebook/react/bridge/ReadableArray;)V public static final fun setBoxShadow (Landroid/view/View;Ljava/util/List;)V public static final fun setFeedbackUnderlay (Landroid/view/View;Landroid/graphics/drawable/Drawable;)V + public static final fun setOriginalBackground (Landroid/view/View;Landroid/graphics/drawable/Drawable;)Z public static final fun setOutlineColor (Landroid/view/View;Ljava/lang/Integer;)V public static final fun setOutlineOffset (Landroid/view/View;F)V public static final fun setOutlineStyle (Landroid/view/View;Lcom/facebook/react/uimanager/style/OutlineStyle;)V diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt index c7d94a770c39..28dfa363ca66 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt @@ -358,7 +358,7 @@ public object BackgroundStyleApplicator { * @param view The view to get the outline width from * @return The outline width in pixels, or null if not set */ - public fun getOutlineWidth(view: View): Float? = getOutlineDrawable(view)?.outlineOffset + public fun getOutlineWidth(view: View): Float? = getOutlineDrawable(view)?.outlineWidth /** * Sets box shadows for the view. @@ -639,6 +639,32 @@ public object BackgroundStyleApplicator { } } + /** + * Replaces the original (non-React-managed) background layer of the view's composite background, + * keeping the outline, shadows, border and other layers installed by this applicator. + * + * @param view The view whose original background layer to replace + * @param originalBackground The drawable to use as the original background layer + * @return true if the view had a composite background and the layer was replaced + */ + @JvmStatic + public fun setOriginalBackground(view: View, originalBackground: Drawable?): Boolean { + val composite = getCompositeBackgroundDrawable(view) ?: return false + view.background = composite.withNewOriginalBackground(originalBackground) + return true + } + + /** + * Gets the original (non-React-managed) background layer of the view's composite background, if + * the view has one. + * + * @param view The view to get the original background layer from + * @return The original background layer, or null if the view has no composite background + */ + @JvmStatic + public fun getOriginalBackground(view: View): Drawable? = + getCompositeBackgroundDrawable(view)?.originalBackground + private fun ensureCompositeBackgroundDrawable(view: View): CompositeBackgroundDrawable { if (view.background is CompositeBackgroundDrawable) { return view.background as CompositeBackgroundDrawable 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 c1fec6bb1f09..1ac8c88aaf8e 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,7 +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 +import com.facebook.react.uimanager.BackgroundStyleApplicator /** * Switch that has its value controlled by JS. Whenever the value of the switch changes, we do not @@ -28,6 +28,7 @@ internal class ReactSwitch(context: Context) : SwitchCompat(context) { private var trackColorForFalse: Int? = null private var trackColorForTrue: Int? = null private var rippleColor: Int? = null + private var switchBackgroundColor: Int? = null override fun setChecked(checked: Boolean) { if (allowChange && isChecked != checked) { @@ -43,6 +44,7 @@ internal class ReactSwitch(context: Context) : SwitchCompat(context) { } override fun setBackgroundColor(color: Int) { + switchBackgroundColor = color val ripple = RippleDrawable( createRippleDrawableColorStateList(rippleColor ?: color), @@ -51,8 +53,9 @@ internal class ReactSwitch(context: Context) : SwitchCompat(context) { ) // 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 + if (!BackgroundStyleApplicator.setOriginalBackground(this, ripple)) { + background = ripple + } } fun setColor(drawable: Drawable, color: Int?) { @@ -73,11 +76,24 @@ internal class ReactSwitch(context: Context) : SwitchCompat(context) { // 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 + (BackgroundStyleApplicator.getOriginalBackground(this) as? RippleDrawable) ?: currentBackground as? RippleDrawable rippleColor = color - if (color != null && ripple != null) { - ripple.setColor(createRippleDrawableColorStateList(color)) + if (ripple != null) { + // When thumbColor is removed, fall back to the background's own color so the ripple + // doesn't keep the old thumb tint. The theme's default ripple can have no content + // layer, so never index into it blindly (getDrawable throws when empty). + val effectColor = + color + ?: switchBackgroundColor + ?: if (ripple.numberOfLayers > 0) { + (ripple.getDrawable(0) as? ColorDrawable)?.color + } else { + null + } + if (effectColor != null) { + ripple.setColor(createRippleDrawableColorStateList(effectColor)) + } } } 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 83f073be5b72..23cbdae4ac7a 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 @@ -120,6 +120,43 @@ class ReactSwitchManagerTest { assertThat(rippleEffectColor().getColorForState(pressed, 0)).isEqualTo(Color.WHITE) } + @Test + fun clearingThumbColorResetsLiveRipple() { + manager.setBackgroundColor(view, Color.WHITE) + manager.setThumbColor(view, Color.GREEN) + manager.setThumbColor(view, null) + + val pressed = intArrayOf(android.R.attr.state_pressed) + assertThat(rippleEffectColor().getColorForState(pressed, 0)).isEqualTo(Color.WHITE) + } + + @Test + fun clearingThumbColorResetsLiveRippleInsideComposite() { + manager.setBackgroundColor(view, Color.WHITE) + manager.setOutlineWidth(view, 3f) + manager.setThumbColor(view, Color.GREEN) + manager.setThumbColor(view, null) + + val pressed = intArrayOf(android.R.attr.state_pressed) + assertThat(rippleEffectColor().getColorForState(pressed, 0)).isEqualTo(Color.WHITE) + } + + @Test + fun clearingThumbColorWithoutBackgroundDoesNotCrash() { + // A Switch with no backgroundColor keeps the theme's ripple, which can have no layers. + manager.setThumbColor(view, Color.GREEN) + manager.setThumbColor(view, null) + } + + @Test + fun getOutlineWidthReturnsWidthNotOffset() { + manager.setOutlineWidth(view, 3f) + manager.setOutlineOffset(view, 8f) + + assertThat(BackgroundStyleApplicator.getOutlineWidth(view)).isEqualTo(6f) + assertThat(BackgroundStyleApplicator.getOutlineOffset(view)).isEqualTo(16f) + } + private fun ripple(): RippleDrawable { val background = view.background val drawable =