diff --git a/packages/react-native/Libraries/Components/Switch/Switch.d.ts b/packages/react-native/Libraries/Components/Switch/Switch.d.ts index 7ffe96e663f6..5ec82ac4865b 100644 --- a/packages/react-native/Libraries/Components/Switch/Switch.d.ts +++ b/packages/react-native/Libraries/Components/Switch/Switch.d.ts @@ -54,6 +54,20 @@ export interface SwitchProps extends SwitchPropsIOS { */ thumbColor?: ColorValue | undefined; + /** + * Android only. Thumb color when the switch is off. Overrides `thumbColor` + * for the unchecked state. + * @platform android + */ + thumbColorForFalse?: ColorValue | undefined; + + /** + * Android only. Thumb color when the switch is on. Overrides `thumbColor` + * for the checked state. + * @platform android + */ + thumbColorForTrue?: ColorValue | undefined; + /** * Custom colors for the switch track * @@ -102,6 +116,21 @@ export interface SwitchProps extends SwitchPropsIOS { */ ios_backgroundColor?: ColorValue | undefined; + /** + * Android only. Drawable resource name(s) to display as an icon inside the + * thumb. Accepts a `{false, true}` object (like `trackColor`) so each state + * can have a different icon. Omit a key to show no icon for that state. + * Requires Material Design 3 (MaterialSwitch). + * @platform android + */ + thumbIcon?: {false?: string | null; true?: string | null} | null | undefined; + + /** + * Android only. Color tint applied to the thumb icon. + * @platform android + */ + thumbIconTint?: ColorValue | undefined; + style?: StyleProp | undefined; } diff --git a/packages/react-native/Libraries/Components/Switch/Switch.js b/packages/react-native/Libraries/Components/Switch/Switch.js index d441435a6c6d..5cd9cb3763b9 100644 --- a/packages/react-native/Libraries/Components/Switch/Switch.js +++ b/packages/react-native/Libraries/Components/Switch/Switch.js @@ -75,6 +75,20 @@ type SwitchPropsBase = { */ thumbColor?: ?ColorValue, + /** + * Android only. Thumb color when the switch is off. Overrides `thumbColor` + * for the unchecked state. + * @platform android + */ + thumbColorForFalse?: ?ColorValue, + + /** + * Android only. Thumb color when the switch is on. Overrides `thumbColor` + * for the checked state. + * @platform android + */ + thumbColorForTrue?: ?ColorValue, + /** Custom colors for the switch track. @@ -94,6 +108,21 @@ type SwitchPropsBase = { */ ios_backgroundColor?: ?ColorValue, + /** + * Android only. Drawable resource name(s) to display as an icon inside the + * thumb. Accepts a `{false, true}` object (like `trackColor`) so each state + * can have a different icon. Omit a key to show no icon for that state. + * Requires Material Design 3 (MaterialSwitch). + * @platform android + */ + thumbIcon?: ?Readonly<{false?: ?string, true?: ?string}>, + + /** + * Android only. Color tint applied to the thumb icon. + * @platform android + */ + thumbIconTint?: ?ColorValue, + /** Invoked when the user tries to change the value of the switch. Receives the change event as an argument. If you want to only receive the new @@ -180,6 +209,10 @@ const Switch: component( onValueChange, style, thumbColor, + thumbColorForFalse, + thumbColorForTrue, + thumbIcon, + thumbIconTint, trackColor, value, ...restProps @@ -243,6 +276,11 @@ const Switch: component( on: value === true, style, thumbTintColor: thumbColor, + thumbColorForFalse, + thumbColorForTrue, + thumbIconForFalse: thumbIcon?.false, + thumbIconForTrue: thumbIcon?.true, + thumbIconTint, trackColorForFalse: trackColorForFalse, trackColorForTrue: trackColorForTrue, trackTintColor: value === true ? trackColorForTrue : trackColorForFalse, diff --git a/packages/react-native/ReactAndroid/build.gradle.kts b/packages/react-native/ReactAndroid/build.gradle.kts index 4dc36a267344..82891a7dad92 100644 --- a/packages/react-native/ReactAndroid/build.gradle.kts +++ b/packages/react-native/ReactAndroid/build.gradle.kts @@ -697,6 +697,7 @@ dependencies { api(libs.androidx.appcompat) api(libs.androidx.appcompat.resources) api(libs.androidx.autofill) + api(libs.material) api(libs.androidx.swiperefreshlayout) api(libs.androidx.tracing) 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 c2d7066237c5..7afc633a16e7 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 @@ -9,29 +9,36 @@ package com.facebook.react.views.switchview import android.content.Context import android.content.res.ColorStateList -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.RippleDrawable -import androidx.appcompat.widget.SwitchCompat +import android.graphics.drawable.StateListDrawable +import android.view.ContextThemeWrapper +import com.google.android.material.R +import com.google.android.material.color.MaterialColors +import com.google.android.material.materialswitch.MaterialSwitch /** * Switch that has its value controlled by JS. Whenever the value of the switch changes, we do not * allow any other changes to that switch until JS sets a value explicitly. This stops the Switch * from changing its value multiple times, when those changes have not been processed by JS first. */ -internal class ReactSwitch(context: Context) : SwitchCompat(context) { +internal class ReactSwitch(context: Context) : MaterialSwitch( + ContextThemeWrapper(context, R.style.Theme_Material3_DayNight) +) { private var allowChange = true - private var trackColorForFalse: Int? = null + private var thumbColorForTrue: Int? = null + private var thumbColorForFalse: Int? = null private var trackColorForTrue: Int? = null + private var trackColorForFalse: Int? = null + private var thumbIconDrawableForTrue: Drawable? = null + private var thumbIconDrawableForFalse: Drawable? = null override fun setChecked(checked: Boolean) { if (allowChange && isChecked != checked) { allowChange = false super.setChecked(checked) - setTrackColor(checked) } else { // Even if mAllowChange is set to false or the checked value hasn't changed, we still must // call the super method, since it will make sure the thumb is moved back to the correct edge. @@ -45,64 +52,89 @@ internal class ReactSwitch(context: Context) : SwitchCompat(context) { RippleDrawable(createRippleDrawableColorStateList(color), ColorDrawable(color), null) } - fun setColor(drawable: Drawable, color: Int?): Unit { - if (color == null) { - drawable.clearColorFilter() - } else { - drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY) - } - } + fun setThumbColor(color: Int?) = setThumbColorForTrue(color) - fun setTrackColor(color: Int?): Unit { - setColor(super.getTrackDrawable(), color) + fun setThumbColorForTrue(color: Int?) { + thumbColorForTrue = color + applyThumbTintList() } - fun setThumbColor(color: Int?): Unit { - 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) - } + fun setThumbColorForFalse(color: Int?) { + thumbColorForFalse = color + applyThumbTintList() } - fun setOn(on: Boolean): Unit { + fun setOn(on: Boolean) { // If the switch has a different value than the value sent by JS, we must change it. if (isChecked != on) { super.setChecked(on) - setTrackColor(on) } allowChange = true } - fun setTrackColorForTrue(color: Int?): Unit { - if (color == trackColorForTrue) { + fun setTrackColorForTrue(color: Int?) { + trackColorForTrue = color + applyTrackTintList() + } + + fun setTrackColorForFalse(color: Int?) { + trackColorForFalse = color + applyTrackTintList() + } + + fun setThumbIconForFalse(drawable: Drawable?) { + thumbIconDrawableForFalse = drawable + applyThumbIconDrawable() + } + + fun setThumbIconForTrue(drawable: Drawable?) { + thumbIconDrawableForTrue = drawable + applyThumbIconDrawable() + } + + private fun applyThumbIconDrawable() { + if (thumbIconDrawableForTrue == null && thumbIconDrawableForFalse == null) { + setThumbIconDrawable(null) return } - trackColorForTrue = color - if (isChecked) { - setTrackColor(trackColorForTrue) + val stateList = StateListDrawable() + thumbIconDrawableForTrue?.let { + stateList.addState(intArrayOf(android.R.attr.state_checked), it) + } + thumbIconDrawableForFalse?.let { + stateList.addState(intArrayOf(-android.R.attr.state_checked), it) } + setThumbIconDrawable(stateList) } - fun setTrackColorForFalse(color: Int?): Unit { - if (color == trackColorForFalse) { + private fun applyThumbTintList() { + if (thumbColorForTrue == null && thumbColorForFalse == null) { + setThumbTintList(null) return } - trackColorForFalse = color - if (!isChecked) { - setTrackColor(trackColorForFalse) - } + setThumbTintList( + ColorStateList( + arrayOf( + intArrayOf(android.R.attr.state_checked), + intArrayOf(-android.R.attr.state_checked)), + intArrayOf( + thumbColorForTrue ?: MaterialColors.getColor(this, R.attr.colorOnPrimary), + thumbColorForFalse ?: MaterialColors.getColor(this, R.attr.colorOutline)))) } - private fun setTrackColor(checked: Boolean) { - if (trackColorForTrue != null || trackColorForFalse != null) { - // Update the track color to reflect the new value. We only want to do this if these - // props were actually set from JS; otherwise we'll just reset the color to the default. - val currentTrackColor = if (checked) trackColorForTrue else trackColorForFalse - setTrackColor(currentTrackColor) + private fun applyTrackTintList() { + if (trackColorForTrue == null && trackColorForFalse == null) { + setTrackTintList(null) + return } + setTrackTintList( + ColorStateList( + arrayOf( + intArrayOf(android.R.attr.state_checked), + intArrayOf(-android.R.attr.state_checked)), + intArrayOf( + trackColorForTrue ?: MaterialColors.getColor(this, R.attr.colorPrimary), + trackColorForFalse ?: MaterialColors.getColor(this, R.attr.colorSurfaceContainerHighest)))) } private fun createRippleDrawableColorStateList(color: Int): ColorStateList = diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/switchview/ReactSwitchManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/switchview/ReactSwitchManager.kt index fca2a534f005..f0f528740fe7 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/switchview/ReactSwitchManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/switchview/ReactSwitchManager.kt @@ -10,6 +10,8 @@ package com.facebook.react.views.switchview import android.content.Context +import android.content.ContextWrapper +import android.content.res.ColorStateList import android.view.View import android.widget.CompoundButton import androidx.annotation.ColorInt @@ -25,6 +27,7 @@ import com.facebook.react.uimanager.ViewProps import com.facebook.react.uimanager.annotations.ReactProp import com.facebook.react.viewmanagers.AndroidSwitchManagerDelegate import com.facebook.react.viewmanagers.AndroidSwitchManagerInterface +import com.facebook.react.views.imagehelper.ResourceDrawableIdHelper import com.facebook.yoga.YogaMeasureMode import com.facebook.yoga.YogaMeasureOutput @@ -76,6 +79,16 @@ internal class ReactSwitchManager : view.setThumbColor(value) } + @ReactProp(name = "thumbColorForFalse", customType = "Color") + override fun setThumbColorForFalse(view: ReactSwitch, value: Int?) { + view.setThumbColorForFalse(value) + } + + @ReactProp(name = "thumbColorForTrue", customType = "Color") + override fun setThumbColorForTrue(view: ReactSwitch, value: Int?) { + view.setThumbColorForTrue(value) + } + @ReactProp(name = "trackColorForFalse", customType = "Color") override fun setTrackColorForFalse(view: ReactSwitch, value: Int?) { view.setTrackColorForFalse(value) @@ -87,8 +100,21 @@ internal class ReactSwitchManager : } @ReactProp(name = "trackTintColor", customType = "Color") - override fun setTrackTintColor(view: ReactSwitch, value: Int?) { - view.setTrackColor(value) + override fun setTrackTintColor(view: ReactSwitch, value: Int?) = setTrackColorForTrue(view, value) + + @ReactProp(name = "thumbIconForFalse") + override fun setThumbIconForFalse(view: ReactSwitch, value: String?) { + view.setThumbIconForFalse(ResourceDrawableIdHelper.getResourceDrawable(view.context, value)) + } + + @ReactProp(name = "thumbIconForTrue") + override fun setThumbIconForTrue(view: ReactSwitch, value: String?) { + view.setThumbIconForTrue(ResourceDrawableIdHelper.getResourceDrawable(view.context, value)) + } + + @ReactProp(name = "thumbIconTint", customType = "Color") + override fun setThumbIconTint(view: ReactSwitch, value: Int?) { + view.setThumbIconTintList(if (value != null) ColorStateList.valueOf(value) else null) } override fun setNativeValue(view: ReactSwitch, value: Boolean) { @@ -137,7 +163,13 @@ internal class ReactSwitchManager : private val ON_CHECKED_CHANGE_LISTENER = CompoundButton.OnCheckedChangeListener { buttonView, isChecked -> - val reactContext = buttonView.context as ReactContext + // The view's context is wrapped in a ContextThemeWrapper (for Material3 theming), + // so walk up the chain to find the underlying ReactContext. + var ctx: android.content.Context = buttonView.context + while (ctx !is ReactContext && ctx is ContextWrapper) { + ctx = ctx.baseContext + } + val reactContext = ctx as ReactContext val reactTag = buttonView.id UIManagerHelper.getEventDispatcher(reactContext) ?.dispatchEvent( diff --git a/packages/react-native/gradle/libs.versions.toml b/packages/react-native/gradle/libs.versions.toml index c04bdb8a79a9..e6018135f951 100644 --- a/packages/react-native/gradle/libs.versions.toml +++ b/packages/react-native/gradle/libs.versions.toml @@ -9,6 +9,7 @@ ndkVersion = "27.1.12297006" agp = "8.12.0" androidx-annotation = "1.6.0" androidx-appcompat = "1.7.0" +material = "1.13.0" androidx-autofill = "1.3.0" androidx-benchmark-macro-junit4 = "1.3.3" androidx-profileinstaller = "1.4.1" @@ -54,6 +55,7 @@ nlohmannjson="3.11.2" androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-appcompat-resources = { module = "androidx.appcompat:appcompat-resources", version.ref = "androidx-appcompat" } +material = { module = "com.google.android.material:material", version.ref = "material" } androidx-autofill = { module = "androidx.autofill:autofill", version.ref = "androidx-autofill" } androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "androidx-benchmark-macro-junit4" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } diff --git a/packages/react-native/src/private/specs_DEPRECATED/components/AndroidSwitchNativeComponent.js b/packages/react-native/src/private/specs_DEPRECATED/components/AndroidSwitchNativeComponent.js index 14bff1e74b4c..c08fee545d31 100644 --- a/packages/react-native/src/private/specs_DEPRECATED/components/AndroidSwitchNativeComponent.js +++ b/packages/react-native/src/private/specs_DEPRECATED/components/AndroidSwitchNativeComponent.js @@ -33,12 +33,17 @@ type AndroidSwitchNativeProps = Readonly<{ disabled?: WithDefault, enabled?: WithDefault, thumbColor?: ?ColorValue, + thumbColorForFalse?: ?ColorValue, + thumbColorForTrue?: ?ColorValue, trackColorForFalse?: ?ColorValue, trackColorForTrue?: ?ColorValue, value?: WithDefault, on?: WithDefault, thumbTintColor?: ?ColorValue, trackTintColor?: ?ColorValue, + thumbIconForFalse?: ?string, + thumbIconForTrue?: ?string, + thumbIconTint?: ?ColorValue, // Events onChange?: BubblingEventHandler, diff --git a/packages/rn-tester/android/app/src/main/res/drawable/ic_check.xml b/packages/rn-tester/android/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 000000000000..356e998a73ea --- /dev/null +++ b/packages/rn-tester/android/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/rn-tester/android/app/src/main/res/drawable/ic_close.xml b/packages/rn-tester/android/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 000000000000..11f5339c1506 --- /dev/null +++ b/packages/rn-tester/android/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/rn-tester/js/examples/Switch/SwitchExample.js b/packages/rn-tester/js/examples/Switch/SwitchExample.js index bfc76fea03ef..6cca9b98e2f6 100644 --- a/packages/rn-tester/js/examples/Switch/SwitchExample.js +++ b/packages/rn-tester/js/examples/Switch/SwitchExample.js @@ -267,6 +267,54 @@ class ContainerBackgroundColorStyleExample extends React.Component< } } +class MD3ThumbIconExample extends React.Component< + {}, + {isOn: boolean, dualIsOn: boolean}, +> { + state: {isOn: boolean, dualIsOn: boolean} = {isOn: true, dualIsOn: false}; + + render(): React.Node { + return ( + + + Single-state icon (checkmark only when ON): + + + this.setState({isOn: value})} + thumbIcon={{true: 'ic_check'}} + thumbIconTint={this.state.isOn ? '#ffffff' : '#666666'} + thumbColor={this.state.isOn ? '#6200ee' : '#f4f3f4'} + /> + + + + Dual-state icons (close when OFF, check when ON): + + + this.setState({dualIsOn: value})} + thumbIcon={{false: 'ic_close', true: 'ic_check'}} + thumbIconTint={this.state.dualIsOn ? '#ffffff' : '#666666'} + thumbColor={this.state.dualIsOn ? '#6200ee' : '#f4f3f4'} + /> + + + + ); + } +} + exports.title = 'Switch'; exports.documentationURL = 'https://reactnative.dev/docs/switch'; exports.category = 'UI'; @@ -322,6 +370,16 @@ exports.examples = [ }, ] as Array; +if (Platform.OS === 'android') { + exports.examples.push({ + title: '[Android Only] MD3 thumb icon (MaterialSwitch)', + name: 'md3-thumb-icon', + render(): React.MixedElement { + return ; + }, + }); +} + if (Platform.OS === 'ios') { exports.examples.push({ title: '[iOS Only] Custom background colors can be set',