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/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..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,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.BackgroundStyleApplicator /** * Switch that has its value controlled by JS. Whenever the value of the switch changes, we do not @@ -26,6 +27,8 @@ 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 + private var switchBackgroundColor: Int? = null override fun setChecked(checked: Boolean) { if (allowChange && isChecked != checked) { @@ -41,8 +44,18 @@ internal class ReactSwitch(context: Context) : SwitchCompat(context) { } override fun setBackgroundColor(color: Int) { - background = - RippleDrawable(createRippleDrawableColorStateList(color), ColorDrawable(color), null) + switchBackgroundColor = color + 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. + if (!BackgroundStyleApplicator.setOriginalBackground(this, ripple)) { + background = ripple + } } fun setColor(drawable: Drawable, color: Int?) { @@ -60,10 +73,27 @@ 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 = + (BackgroundStyleApplicator.getOriginalBackground(this) as? RippleDrawable) + ?: currentBackground as? RippleDrawable + rippleColor = 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 new file mode 100644 index 000000000000..23cbdae4ac7a --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/switchview/ReactSwitchManagerTest.kt @@ -0,0 +1,180 @@ +/* + * 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) + } + + @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 = + 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 + } +}