Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/react-native/ReactAndroid/api/ReactAndroid.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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?) {
Expand All @@ -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))
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading