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 + } +}