diff --git a/example/src/Examples/SwitchExample.tsx b/example/src/Examples/SwitchExample.tsx
index da959eda47..fc7d491355 100644
--- a/example/src/Examples/SwitchExample.tsx
+++ b/example/src/Examples/SwitchExample.tsx
@@ -1,73 +1,106 @@
import * as React from 'react';
-import { Platform, StyleSheet, View } from 'react-native';
+import { StyleSheet, View } from 'react-native';
-import { Palette, Switch, Text, TouchableRipple } from 'react-native-paper';
+import { Switch, Text, useTheme } from 'react-native-paper';
+import type { SwitchColors } from 'react-native-paper';
import ScreenWrapper from '../ScreenWrapper';
+const Row = ({
+ label,
+ children,
+}: {
+ label: string;
+ children: React.ReactNode;
+}) => (
+
+ {label}
+ {children}
+
+);
+
const SwitchExample = () => {
- const [valueNormal, setNormalValue] = React.useState(true);
- const [valueCustom, setCustomValue] = React.useState(true);
+ const theme = useTheme();
+ const [defaultOn, setDefaultOn] = React.useState(true);
+ const [defaultCheckedIconOn, setDefaultCheckedIconOn] = React.useState(true);
+ const [defaultIconOn, setDefaultIconOn] = React.useState(true);
+ const [customOn, setCustomOn] = React.useState(true);
+ const [customIconOn, setCustomIconOn] = React.useState(true);
+ const [disableAll, setDisableAll] = React.useState(false);
- const switchValueNormalLabel = `switch ${
- valueNormal === true ? 'on' : 'off'
- }`;
- const switchValueCustomlLabel = `switch ${
- valueCustom === true ? 'on' : 'off'
- }`;
+ const tertiaryColors: Partial = React.useMemo(
+ () => ({
+ checkedTrackColor: theme.colors.tertiary,
+ checkedHandleColor: theme.colors.onTertiary,
+ checkedHoverHandleColor: theme.colors.tertiaryContainer,
+ checkedFocusHandleColor: theme.colors.tertiaryContainer,
+ checkedPressedHandleColor: theme.colors.tertiaryContainer,
+ checkedIconColor: theme.colors.tertiary,
+ checkedStateLayerColor: theme.colors.tertiary,
+ focusIndicatorColor: theme.colors.tertiary,
+ }),
+ [theme]
+ );
- return Platform.OS === 'android' ? (
+ return (
- setNormalValue(!valueNormal)}>
-
- Normal {switchValueNormalLabel}
-
-
-
-
-
- setCustomValue(!valueCustom)}>
-
- Custom {switchValueCustomlLabel}
-
-
-
-
-
-
- Switch on (disabled)
-
-
-
- Switch off (disabled)
-
-
-
- ) : (
-
-
- Normal {switchValueNormalLabel}
+
+
+
+
+
+
+
+
+
setNormalValue(!valueNormal)}
+ value={defaultIconOn}
+ onValueChange={setDefaultIconOn}
+ checkedIcon="check"
+ uncheckedIcon="close"
+ disabled={disableAll}
/>
-
-
- Custom {switchValueCustomlLabel}
+
+
+
setCustomValue(!valueCustom)}
- color={Palette.tertiary50}
+ value={customOn}
+ onValueChange={setCustomOn}
+ colors={tertiaryColors}
+ disabled={disableAll}
/>
-
-
- Switch on (disabled)
-
-
-
- Switch off (disabled)
-
-
+
+
+
+
+
+
+
+
+
+
+
);
};
@@ -85,6 +118,16 @@ const styles = StyleSheet.create({
paddingVertical: 8,
paddingHorizontal: 16,
},
+ right: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ },
+ separator: {
+ height: 1,
+ marginHorizontal: 16,
+ marginVertical: 16,
+ },
});
export default SwitchExample;
diff --git a/jest/testSetup.js b/jest/testSetup.js
index 5088ab5585..5110c85551 100644
--- a/jest/testSetup.js
+++ b/jest/testSetup.js
@@ -8,6 +8,10 @@ jest.mock('react-native-worklets', () =>
require('react-native-worklets/lib/module/mock')
);
+jest.mock('react-native-reanimated', () =>
+ require('react-native-reanimated/mock')
+);
+
jest.mock('@react-native-vector-icons/material-design-icons', () => {
const React = require('react');
const { Text } = require('react-native');
diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx
index 04b0fef0c6..05113032ec 100644
--- a/src/components/Switch/Switch.tsx
+++ b/src/components/Switch/Switch.tsx
@@ -1,108 +1,492 @@
import * as React from 'react';
+import { StyleSheet, type StyleProp, View, type ViewStyle } from 'react-native';
+
+import Animated, {
+ Easing,
+ ReduceMotion,
+ useAnimatedStyle,
+ useDerivedValue,
+ useSharedValue,
+ withDelay,
+ withSequence,
+ withSpring,
+ withTiming,
+} from 'react-native-reanimated';
+
+import { SwitchTokens } from './tokens';
import {
- NativeModules,
- Platform,
- StyleProp,
- Switch as NativeSwitch,
- ViewStyle,
-} from 'react-native';
-
-import { getSwitchColor } from './utils';
+ resolveSwitchColors,
+ resolveSwitchPaint,
+ type SwitchColors,
+} from './utils';
+import { useLocale } from '../../core/locale';
import { useInternalTheme } from '../../core/theming';
-import type { ThemeProp } from '../../types';
+import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
+import { tokens } from '../../theme/tokens';
+import { toRawSpring } from '../../theme/tokens/sys/motion';
+import { cornerFull } from '../../theme/tokens/sys/shape';
+import type { StateOpacityKey, ThemeProp } from '../../types';
+import { useFocusVisible } from '../../utils/useFocusVisible';
+import Icon, { type IconSource } from '../Icon';
+import TouchableRipple from '../TouchableRipple/TouchableRipple';
-const version = NativeModules.PlatformConstants
- ? NativeModules.PlatformConstants.reactNativeVersion
- : undefined;
+export type { SwitchColors } from './utils';
-export type Props = React.ComponentPropsWithRef & {
- /**
- * Disable toggling the switch.
- */
- disabled?: boolean;
- /**
- * Value of the switch, true means 'on', false means 'off'.
- */
+export type Props = {
+ /** Whether the switch is on. */
value?: boolean;
- /**
- * Custom color for switch.
- */
- color?: string;
- /**
- * Callback called with the new value when it changes.
- */
- onValueChange?: Function;
+ /** Called with the new value when the user toggles the switch. */
+ onValueChange?: (value: boolean) => void;
+ /** Disables interaction and renders the disabled visual state. */
+ disabled?: boolean;
+ /** Icon shown inside the handle when checked */
+ checkedIcon?: IconSource;
+ /** Icon shown inside the handle when unchecked. */
+ uncheckedIcon?: IconSource;
+ /** Per-slot color overrides. Unspecified keys fall back to theme defaults. */
+ colors?: Partial;
style?: StyleProp;
- /**
- * @optional
- */
+ testID?: string;
theme?: ThemeProp;
+ accessibilityLabel?: string;
};
+const {
+ trackWidth: TRACK_WIDTH,
+ trackHeight: TRACK_HEIGHT,
+ trackOutlineWidth: TRACK_OUTLINE_WIDTH,
+ stateLayerSize: STATE_LAYER_SIZE,
+ selectedHandleSize: SELECTED_HANDLE,
+ unselectedHandleSize: UNSELECTED_HANDLE,
+ iconHandleSize: ICON_HANDLE,
+ pressedHandleSize: PRESSED_HANDLE,
+ selectedIconSize: SELECTED_ICON,
+ unselectedIconSize: UNSELECTED_ICON,
+ disabledSelectedHandleOpacity: DISABLED_SELECTED_HANDLE_OPACITY,
+ disabledUnselectedHandleOpacity: DISABLED_UNSELECTED_HANDLE_OPACITY,
+ disabledSelectedIconOpacity: DISABLED_SELECTED_ICON_OPACITY,
+ disabledUnselectedIconOpacity: DISABLED_UNSELECTED_ICON_OPACITY,
+ disabledTrackOpacity: DISABLED_TRACK_OPACITY,
+} = SwitchTokens;
+
+const { state: stateTokens } = tokens.md.sys;
+const stateOpacity = stateTokens.opacity;
+const { thickness: FOCUS_THICKNESS, outerOffset: FOCUS_OUTER_OFFSET } =
+ stateTokens.focusIndicator;
+const FOCUS_RING_INSET = -(FOCUS_OUTER_OFFSET + FOCUS_THICKNESS);
+const OVERLAY_TOP = (STATE_LAYER_SIZE - TRACK_HEIGHT) / 2;
+
+// Hold-then-grow: a brief delay before snapping to PRESSED_HANDLE so a quick
+// tap doesn't flash the press-grow visual.
+const PRESS_GROW_DELAY = 100;
+
+function restingHandleSize(checked: boolean, hasIcon: boolean): number {
+ if (hasIcon) return ICON_HANDLE;
+ return checked ? SELECTED_HANDLE : UNSELECTED_HANDLE;
+}
+
+function offsetForHandle(
+ checked: boolean,
+ handleSize: number,
+ pressed: boolean
+): number {
+ const minBound = (TRACK_HEIGHT - handleSize) / 2;
+ const handlePadding = (TRACK_HEIGHT - SELECTED_HANDLE) / 2;
+ const maxBound = TRACK_WIDTH - SELECTED_HANDLE - handlePadding;
+ if (pressed) {
+ return checked ? maxBound - TRACK_OUTLINE_WIDTH : TRACK_OUTLINE_WIDTH;
+ }
+ return checked ? maxBound : minBound;
+}
+
+function stateLayerOpacityFor(interaction: StateOpacityKey | null): number {
+ switch (interaction) {
+ case 'pressed':
+ return stateOpacity.pressed;
+ case 'focused':
+ return 0;
+ case 'hovered':
+ return stateOpacity.hovered;
+ default:
+ return 0;
+ }
+}
+
/**
- * Switch is a visual toggle between two mutually exclusive states — on and off.
+ * Material 3 toggle between two mutually exclusive states (on / off).
*
* ## Usage
* ```js
* import * as React from 'react';
* import { Switch } from 'react-native-paper';
*
- * const MyComponent = () => {
- * const [isSwitchOn, setIsSwitchOn] = React.useState(false);
- *
- * const onToggleSwitch = () => setIsSwitchOn(!isSwitchOn);
- *
- * return ;
+ * const Example = () => {
+ * const [on, setOn] = React.useState(false);
+ * return ;
* };
- *
- * export default MyComponent;
* ```
*/
const Switch = ({
value,
disabled,
onValueChange,
- color,
+ checkedIcon,
+ uncheckedIcon,
+ colors: colorOverrides,
+ style,
+ testID,
theme: themeOverrides,
- ...rest
+ accessibilityLabel,
}: Props) => {
const theme = useInternalTheme(themeOverrides);
- const { checkedColor, onTintColor, thumbTintColor } = getSwitchColor({
- theme,
- disabled,
- value,
- color,
- });
-
- const props =
- version && version.major === 0 && version.minor <= 56
- ? {
- onTintColor,
- thumbTintColor,
- }
- : Platform.OS === 'web'
- ? {
- activeTrackColor: onTintColor,
- thumbColor: thumbTintColor,
- activeThumbColor: checkedColor,
- }
- : {
- thumbColor: thumbTintColor,
- trackColor: {
- true: onTintColor,
- false: onTintColor,
- },
- };
+ const reduceMotion = useReduceMotion();
+ const { direction } = useLocale();
+ const xSign = direction === 'ltr' ? 1 : -1;
+
+ const checked = !!value;
+ const isDisabled = !!disabled;
+ const isEnabled = !isDisabled;
+ const iconSource = checked ? checkedIcon : uncheckedIcon;
+ const hasIcon = iconSource !== undefined;
+
+ const [hovered, setHovered] = React.useState(false);
+ const [pressed, setPressed] = React.useState(false);
+ const {
+ focusVisible,
+ onFocus: handleFocus,
+ onBlur: handleBlur,
+ } = useFocusVisible();
+
+ const colors = React.useMemo(
+ () => resolveSwitchColors(theme, colorOverrides),
+ [theme, colorOverrides]
+ );
+
+ const reanimatedReduceMotion = reduceMotion
+ ? ReduceMotion.Always
+ : ReduceMotion.Never;
+
+ const springConfig = React.useMemo(
+ () => ({
+ ...toRawSpring(theme.motion.spring.fast.spatial),
+ reduceMotion: reanimatedReduceMotion,
+ }),
+ [theme.motion.spring.fast.spatial, reanimatedReduceMotion]
+ );
+ const stateLayerRiseConfig = React.useMemo(
+ () => ({
+ duration: theme.motion.duration.medium3,
+ easing: Easing.bezier(...theme.motion.easing.standard),
+ reduceMotion: reanimatedReduceMotion,
+ }),
+ [
+ theme.motion.duration.medium3,
+ theme.motion.easing.standard,
+ reanimatedReduceMotion,
+ ]
+ );
+ const stateLayerPulseFallConfig = React.useMemo(
+ () => ({
+ duration: theme.motion.duration.medium3,
+ easing: Easing.bezier(...theme.motion.easing.standardAccelerate),
+ reduceMotion: reanimatedReduceMotion,
+ }),
+ [
+ theme.motion.duration.medium3,
+ theme.motion.easing.standardAccelerate,
+ reanimatedReduceMotion,
+ ]
+ );
+
+ const interaction: StateOpacityKey | null = pressed
+ ? 'pressed'
+ : focusVisible
+ ? 'focused'
+ : hovered
+ ? 'hovered'
+ : null;
+
+ const handleSize = useSharedValue(restingHandleSize(checked, hasIcon));
+ const handleOffset = useSharedValue(
+ offsetForHandle(checked, restingHandleSize(checked, hasIcon), false)
+ );
+ const stateLayerAlpha = useSharedValue(0);
+ const prevInteractionRef = React.useRef(null);
+
+ React.useEffect(() => {
+ const targetSize = pressed
+ ? PRESSED_HANDLE
+ : restingHandleSize(checked, hasIcon);
+ const targetOffset = offsetForHandle(checked, targetSize, pressed);
+ if (pressed) {
+ handleSize.value = withDelay(
+ PRESS_GROW_DELAY,
+ withTiming(targetSize, { duration: 0 })
+ );
+ handleOffset.value = withDelay(
+ PRESS_GROW_DELAY,
+ withTiming(targetOffset, { duration: 0 })
+ );
+ } else {
+ handleSize.value = withSpring(targetSize, springConfig);
+ handleOffset.value = withSpring(targetOffset, springConfig);
+ }
+ }, [checked, hasIcon, pressed, handleSize, handleOffset, springConfig]);
+
+ React.useEffect(() => {
+ const wasPressed = prevInteractionRef.current === 'pressed';
+ const target = stateLayerOpacityFor(interaction);
+
+ if (wasPressed && interaction !== 'pressed') {
+ // On release: rise to peak, then fall to the next state (0/hover/focus).
+ stateLayerAlpha.value = withSequence(
+ withTiming(stateOpacity.pressed, stateLayerRiseConfig),
+ withTiming(target, stateLayerPulseFallConfig)
+ );
+ } else if (interaction === null) {
+ // Hover/focus exit without prior press: snap off.
+ stateLayerAlpha.value = 0;
+ } else {
+ stateLayerAlpha.value = withTiming(target, stateLayerRiseConfig);
+ }
+
+ prevInteractionRef.current = interaction;
+ }, [
+ interaction,
+ stateLayerAlpha,
+ stateLayerRiseConfig,
+ stateLayerPulseFallConfig,
+ ]);
+
+ const handleCenter = useDerivedValue(
+ () => handleOffset.value + handleSize.value / 2
+ );
+
+ const handleAnimatedStyle = useAnimatedStyle(() => ({
+ width: handleSize.value,
+ height: handleSize.value,
+ top: (STATE_LAYER_SIZE - handleSize.value) / 2,
+ transform: [{ translateX: xSign * handleOffset.value }],
+ }));
+
+ const stateLayerAnimatedStyle = useAnimatedStyle(() => ({
+ opacity: stateLayerAlpha.value,
+ transform: [
+ { translateX: xSign * (handleCenter.value - STATE_LAYER_SIZE / 2) },
+ ],
+ }));
+
+ // Icon translateX follows the handle's center.
+ // Vertical position is fixed so the icon never moves vertically
+ // as the spring oscillates around its target.
+ const iconAnimatedStyle = useAnimatedStyle(() => ({
+ transform: [
+ { translateX: xSign * (handleCenter.value - SELECTED_ICON / 2) },
+ ],
+ }));
+
+ const paint = resolveSwitchPaint(colors, isEnabled, checked, interaction);
+ const stateLayerColor = checked
+ ? colors.checkedStateLayerColor
+ : colors.uncheckedStateLayerColor;
+ const showOutline = paint.border !== 'transparent';
+ const handleOpacity = isDisabled
+ ? checked
+ ? DISABLED_SELECTED_HANDLE_OPACITY
+ : DISABLED_UNSELECTED_HANDLE_OPACITY
+ : 1;
+ const iconOpacity = isDisabled
+ ? checked
+ ? DISABLED_SELECTED_ICON_OPACITY
+ : DISABLED_UNSELECTED_ICON_OPACITY
+ : 1;
+ const trackOpacityValue = isDisabled ? DISABLED_TRACK_OPACITY : 1;
+ const iconSize = checked ? SELECTED_ICON : UNSELECTED_ICON;
return (
-
+
+ onValueChange?.(!checked)}
+ onPressIn={() => setPressed(true)}
+ onPressOut={() => setPressed(false)}
+ onHoverIn={() => setHovered(true)}
+ onHoverOut={() => setHovered(false)}
+ onFocus={handleFocus}
+ onBlur={handleBlur}
+ rippleColor="transparent"
+ accessibilityRole="switch"
+ accessibilityState={{ disabled: isDisabled, checked }}
+ accessibilityLiveRegion="polite"
+ accessibilityLabel={accessibilityLabel}
+ testID={testID}
+ style={styles.touchable}
+ theme={theme}
+ >
+
+ {showOutline ? (
+
+ ) : null}
+
+
+
+
+
+
+ {/* Disabled-only: opaque `surface` backdrop. The tinted fill above
+ composites over it, reproducing the native math avoiding the PlatformColor alpha limitation. */}
+ {isDisabled ? (
+
+ ) : null}
+
+
+ {iconSource ? (
+
+ {/* The same icon twice; one tinted fill on top of the other,
+ reproducing the native math avoiding the PlatformColor alpha limitation. */}
+ {isDisabled ? (
+
+
+
+ ) : null}
+
+
+
+
+ ) : null}
+
+ {focusVisible ? (
+
+ ) : null}
+
);
};
+const styles = StyleSheet.create({
+ wrapper: {
+ width: TRACK_WIDTH,
+ height: STATE_LAYER_SIZE,
+ alignItems: 'center',
+ justifyContent: 'center',
+ overflow: 'visible',
+ },
+ touchable: {
+ width: TRACK_WIDTH,
+ height: STATE_LAYER_SIZE,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ track: {
+ width: TRACK_WIDTH,
+ height: TRACK_HEIGHT,
+ borderRadius: cornerFull,
+ overflow: 'visible',
+ },
+ outline: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ borderWidth: TRACK_OUTLINE_WIDTH,
+ borderRadius: cornerFull,
+ },
+ stateLayer: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ width: STATE_LAYER_SIZE,
+ height: STATE_LAYER_SIZE,
+ borderRadius: cornerFull,
+ },
+ handle: {
+ position: 'absolute',
+ left: 0,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ handleFill: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ borderRadius: cornerFull,
+ },
+ iconWrap: {
+ position: 'absolute',
+ left: 0,
+ top: (STATE_LAYER_SIZE - SELECTED_ICON) / 2,
+ width: SELECTED_ICON,
+ height: SELECTED_ICON,
+ },
+ focusRing: {
+ position: 'absolute',
+ },
+ absoluteFill: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ },
+});
+
export default Switch;
diff --git a/src/components/Switch/tokens.ts b/src/components/Switch/tokens.ts
new file mode 100644
index 0000000000..53422c7bad
--- /dev/null
+++ b/src/components/Switch/tokens.ts
@@ -0,0 +1,55 @@
+import type { ColorRole } from '../../theme/types';
+
+const sizes = {
+ trackWidth: 52,
+ trackHeight: 32,
+ trackOutlineWidth: 2,
+ stateLayerSize: 40,
+
+ selectedHandleSize: 24,
+ unselectedHandleSize: 16,
+ iconHandleSize: 24,
+ pressedHandleSize: 28,
+ selectedIconSize: 16,
+ unselectedIconSize: 16,
+
+ disabledSelectedHandleOpacity: 1.0,
+ disabledUnselectedHandleOpacity: 0.38,
+ disabledSelectedIconOpacity: 0.38,
+ disabledUnselectedIconOpacity: 0.38,
+ disabledTrackOpacity: 0.12,
+} as const;
+
+const colors = {
+ selectedHandleColor: 'onPrimary',
+ selectedHoverHandleColor: 'primaryContainer',
+ selectedFocusHandleColor: 'primaryContainer',
+ selectedPressedHandleColor: 'primaryContainer',
+
+ unselectedHandleColor: 'outline',
+ unselectedHoverHandleColor: 'onSurfaceVariant',
+ unselectedFocusHandleColor: 'onSurfaceVariant',
+ unselectedPressedHandleColor: 'onSurfaceVariant',
+
+ selectedIconColor: 'primary',
+ unselectedIconColor: 'surfaceContainerHighest',
+
+ selectedTrackColor: 'primary',
+ unselectedTrackColor: 'surfaceContainerHighest',
+ unselectedTrackOutlineColor: 'outline',
+
+ disabledSelectedHandleColor: 'surface',
+ disabledSelectedIconColor: 'onSurface',
+ disabledSelectedTrackColor: 'onSurface',
+ disabledUnselectedHandleColor: 'onSurface',
+ disabledUnselectedIconColor: 'surfaceContainerHighest',
+ disabledUnselectedTrackColor: 'surfaceContainerHighest',
+ disabledUnselectedTrackOutlineColor: 'onSurface',
+
+ selectedStateLayerColor: 'primary',
+ unselectedStateLayerColor: 'onSurface',
+
+ focusIndicatorColor: 'secondary',
+} as const satisfies Record;
+
+export const SwitchTokens = { ...sizes, ...colors };
diff --git a/src/components/Switch/utils.ts b/src/components/Switch/utils.ts
index cebf7264f2..e80e0f5690 100644
--- a/src/components/Switch/utils.ts
+++ b/src/components/Switch/utils.ts
@@ -1,106 +1,154 @@
-import { Platform, type ColorValue } from 'react-native';
-
-import setColor from 'color';
-
-import {
- grey400,
- grey800,
- grey50,
- grey700,
- white,
- black,
-} from '../../theme/colors';
-import type { InternalTheme } from '../../types';
-
-type BaseProps = {
- theme: InternalTheme;
- disabled?: boolean;
- value?: boolean;
+import type { ColorValue } from 'react-native';
+
+import { SwitchTokens } from './tokens';
+import type { InternalTheme, StateOpacityKey } from '../../types';
+
+export type SwitchColors = {
+ // Enabled, base
+ checkedHandleColor: ColorValue;
+ checkedTrackColor: ColorValue;
+ checkedBorderColor: ColorValue;
+ checkedIconColor: ColorValue;
+ uncheckedHandleColor: ColorValue;
+ uncheckedTrackColor: ColorValue;
+ uncheckedBorderColor: ColorValue;
+ uncheckedIconColor: ColorValue;
+
+ // Enabled, interacted handle (track stays constant per spec)
+ checkedHoverHandleColor: ColorValue;
+ checkedFocusHandleColor: ColorValue;
+ checkedPressedHandleColor: ColorValue;
+ uncheckedHoverHandleColor: ColorValue;
+ uncheckedFocusHandleColor: ColorValue;
+ uncheckedPressedHandleColor: ColorValue;
+
+ // State layer tints
+ checkedStateLayerColor: ColorValue;
+ uncheckedStateLayerColor: ColorValue;
+
+ // Focus indicator ring
+ focusIndicatorColor: ColorValue;
+
+ // Disabled
+ disabledCheckedHandleColor: ColorValue;
+ disabledCheckedTrackColor: ColorValue;
+ disabledCheckedBorderColor: ColorValue;
+ disabledCheckedIconColor: ColorValue;
+ disabledUncheckedHandleColor: ColorValue;
+ disabledUncheckedTrackColor: ColorValue;
+ disabledUncheckedBorderColor: ColorValue;
+ disabledUncheckedIconColor: ColorValue;
};
-const getCheckedColor = ({
- theme,
- color,
-}: {
- theme: InternalTheme;
- color?: ColorValue;
-}) => {
- if (color) {
- return color;
- }
-
- return theme.colors.primary;
-};
-
-const getThumbTintColor = ({
- theme,
- disabled,
- value,
- checkedColor,
-}: BaseProps & { checkedColor: ColorValue }) => {
- const isIOS = Platform.OS === 'ios';
-
- if (isIOS) {
- return undefined;
- }
-
- if (disabled) {
- if (theme.dark) {
- return grey800;
- }
- return grey400;
- }
-
- if (value) {
- return checkedColor;
- }
-
- if (theme.dark) {
- return grey400;
- }
- return grey50;
+export function getDefaultSwitchColors(theme: InternalTheme): SwitchColors {
+ const t = SwitchTokens;
+ const c = theme.colors;
+ return {
+ checkedHandleColor: c[t.selectedHandleColor],
+ checkedTrackColor: c[t.selectedTrackColor],
+ checkedBorderColor: 'transparent',
+ checkedIconColor: c[t.selectedIconColor],
+
+ uncheckedHandleColor: c[t.unselectedHandleColor],
+ uncheckedTrackColor: c[t.unselectedTrackColor],
+ uncheckedBorderColor: c[t.unselectedTrackOutlineColor],
+ uncheckedIconColor: c[t.unselectedIconColor],
+
+ checkedHoverHandleColor: c[t.selectedHoverHandleColor],
+ checkedFocusHandleColor: c[t.selectedFocusHandleColor],
+ checkedPressedHandleColor: c[t.selectedPressedHandleColor],
+ uncheckedHoverHandleColor: c[t.unselectedHoverHandleColor],
+ uncheckedFocusHandleColor: c[t.unselectedFocusHandleColor],
+ uncheckedPressedHandleColor: c[t.unselectedPressedHandleColor],
+
+ checkedStateLayerColor: c[t.selectedStateLayerColor],
+ uncheckedStateLayerColor: c[t.unselectedStateLayerColor],
+
+ focusIndicatorColor: c[t.focusIndicatorColor],
+
+ disabledCheckedHandleColor: c[t.disabledSelectedHandleColor],
+ disabledCheckedTrackColor: c[t.disabledSelectedTrackColor],
+ disabledCheckedBorderColor: 'transparent',
+ disabledCheckedIconColor: c[t.disabledSelectedIconColor],
+ disabledUncheckedHandleColor: c[t.disabledUnselectedHandleColor],
+ disabledUncheckedTrackColor: c[t.disabledUnselectedTrackColor],
+ disabledUncheckedBorderColor: c[t.disabledUnselectedTrackOutlineColor],
+ disabledUncheckedIconColor: c[t.disabledUnselectedIconColor],
+ };
+}
+
+export function resolveSwitchColors(
+ theme: InternalTheme,
+ overrides: Partial | undefined
+): SwitchColors {
+ return { ...getDefaultSwitchColors(theme), ...overrides };
+}
+
+export type SwitchPaint = {
+ handle: ColorValue;
+ track: ColorValue;
+ border: ColorValue;
+ icon: ColorValue;
};
-const getOnTintColor = ({
- theme,
- disabled,
- value,
- checkedColor,
-}: BaseProps & { checkedColor: ColorValue }) => {
- const isIOS = Platform.OS === 'ios';
-
- if (isIOS) {
- return checkedColor;
- }
-
- if (disabled) {
- if (theme.dark) {
- return setColor(white).alpha(0.06).rgb().string();
- }
- return setColor(black).alpha(0.12).rgb().string();
+/**
+ * Resolve every paint slot for the current state in one pass. The handle
+ * color further branches on `interaction` (hover/focus/pressed swap to the
+ * primary-container family); track / border / icon are interaction-agnostic
+ * per the MD3 spec.
+ */
+export function resolveSwitchPaint(
+ c: SwitchColors,
+ isEnabled: boolean,
+ checked: boolean,
+ interaction: StateOpacityKey | null
+): SwitchPaint {
+ if (!isEnabled) {
+ return checked
+ ? {
+ handle: c.disabledCheckedHandleColor,
+ track: c.disabledCheckedTrackColor,
+ border: c.disabledCheckedBorderColor,
+ icon: c.disabledCheckedIconColor,
+ }
+ : {
+ handle: c.disabledUncheckedHandleColor,
+ track: c.disabledUncheckedTrackColor,
+ border: c.disabledUncheckedBorderColor,
+ icon: c.disabledUncheckedIconColor,
+ };
}
- if (value) {
- return theme.colors.surfaceContainerHighest;
+ let handle: ColorValue;
+ if (checked) {
+ handle =
+ interaction === 'pressed'
+ ? c.checkedPressedHandleColor
+ : interaction === 'focused'
+ ? c.checkedFocusHandleColor
+ : interaction === 'hovered'
+ ? c.checkedHoverHandleColor
+ : c.checkedHandleColor;
+ return {
+ handle,
+ track: c.checkedTrackColor,
+ border: c.checkedBorderColor,
+ icon: c.checkedIconColor,
+ };
}
- if (theme.dark) {
- return grey700;
- }
- return 'rgb(178, 175, 177)';
-};
-
-export const getSwitchColor = ({
- theme,
- disabled,
- value,
- color,
-}: BaseProps & { color?: ColorValue }) => {
- const checkedColor = getCheckedColor({ theme, color });
-
+ handle =
+ interaction === 'pressed'
+ ? c.uncheckedPressedHandleColor
+ : interaction === 'focused'
+ ? c.uncheckedFocusHandleColor
+ : interaction === 'hovered'
+ ? c.uncheckedHoverHandleColor
+ : c.uncheckedHandleColor;
return {
- onTintColor: getOnTintColor({ theme, disabled, value, checkedColor }),
- thumbTintColor: getThumbTintColor({ theme, disabled, value, checkedColor }),
- checkedColor,
+ handle,
+ track: c.uncheckedTrackColor,
+ border: c.uncheckedBorderColor,
+ icon: c.uncheckedIconColor,
};
-};
+}
diff --git a/src/components/__tests__/Switch.test.tsx b/src/components/__tests__/Switch.test.tsx
index 27637c085b..3417d65831 100644
--- a/src/components/__tests__/Switch.test.tsx
+++ b/src/components/__tests__/Switch.test.tsx
@@ -1,224 +1,121 @@
import * as React from 'react';
-import { Platform } from 'react-native';
-import color from 'color';
-
-import { getTheme } from '../../core/theming';
-import { render } from '../../test-utils';
-import {
- white,
- black,
- grey400,
- grey50,
- grey800,
- pink500,
- grey700,
-} from '../../theme/colors';
+import { fireEvent, render } from '../../test-utils';
+import { LightTheme, DarkTheme } from '../../theme/schemes';
import Switch from '../Switch/Switch';
-import { getSwitchColor } from '../Switch/utils';
-
-it('renders on switch', () => {
- const tree = render().toJSON();
-
- expect(tree).toMatchSnapshot();
-});
-
-it('renders off switch', () => {
- const tree = render().toJSON();
-
- expect(tree).toMatchSnapshot();
-});
-
-it('renders disabled switch', () => {
- const tree = render().toJSON();
-
- expect(tree).toMatchSnapshot();
-});
-
-it('renders switch with color', () => {
- const tree = render().toJSON();
-
- expect(tree).toMatchSnapshot();
-});
+import { getDefaultSwitchColors } from '../Switch/utils';
-describe('getSwitchColor - checked color', () => {
- it('should return custom color', () => {
- expect(
- getSwitchColor({
- theme: getTheme(),
- color: 'purple',
- })
- ).toMatchObject({
- checkedColor: 'purple',
- });
+describe('Switch render', () => {
+ it('renders on', () => {
+ expect(render().toJSON()).toMatchSnapshot();
});
- it('should return theme color, for theme version 3', () => {
- expect(
- getSwitchColor({
- theme: getTheme(),
- })
- ).toMatchObject({
- checkedColor: getTheme().colors.primary,
- });
+ it('renders off', () => {
+ expect(render().toJSON()).toMatchSnapshot();
});
-});
-
-describe('getSwitchColor - thumb tint color', () => {
- it('should return undefined for iOS platform', () => {
- Platform.OS = 'ios';
- expect(
- getSwitchColor({
- theme: getTheme(true),
- })
- ).toMatchObject({
- thumbTintColor: undefined,
- });
+ it('renders disabled on', () => {
+ expect(render().toJSON()).toMatchSnapshot();
});
- it('should return correct disabled color, dark mode', () => {
- Platform.OS = 'android';
-
+ it('renders disabled off', () => {
expect(
- getSwitchColor({
- theme: getTheme(true),
- disabled: true,
- })
- ).toMatchObject({
- thumbTintColor: grey800,
- });
+ render().toJSON()
+ ).toMatchSnapshot();
});
- it('should return correct disabled color, light mode', () => {
+ it('renders with checked icon', () => {
expect(
- getSwitchColor({
- theme: getTheme(),
- disabled: true,
- })
- ).toMatchObject({
- thumbTintColor: grey400,
- });
+ render().toJSON()
+ ).toMatchSnapshot();
});
- it('should return correct checked color if there is value', () => {
- Platform.OS = 'android';
-
+ it('renders with per-state icons', () => {
expect(
- getSwitchColor({
- theme: getTheme(),
- value: true,
- color: 'purple',
- })
- ).toMatchObject({
- thumbTintColor: 'purple',
- });
+ render(
+
+ ).toJSON()
+ ).toMatchSnapshot();
});
- it('should return theme checked color, dark mode', () => {
- Platform.OS = 'android';
-
+ it('renders with colors override', () => {
expect(
- getSwitchColor({
- theme: getTheme(true),
- })
- ).toMatchObject({
- thumbTintColor: grey400,
- });
- });
-
- it('should return theme checked color, light mode', () => {
- Platform.OS = 'android';
-
- expect(
- getSwitchColor({
- theme: getTheme(),
- })
- ).toMatchObject({
- thumbTintColor: grey50,
- });
+ render(
+
+ ).toJSON()
+ ).toMatchSnapshot();
});
});
-describe('getSwitchColor - on tint color', () => {
- it('should return checked color for iOS platform, for theme version 3', () => {
- Platform.OS = 'ios';
-
- expect(
- getSwitchColor({
- theme: getTheme(),
- })
- ).toMatchObject({
- onTintColor: getTheme().colors.primary,
- });
+describe('Switch interaction', () => {
+ it('toggles to true when off and pressed', () => {
+ const onValueChange = jest.fn();
+ const { getByRole } = render(
+
+ );
+ fireEvent.press(getByRole('switch'));
+ expect(onValueChange).toHaveBeenCalledWith(true);
});
- it('should return custom color for iOS platform', () => {
- Platform.OS = 'ios';
-
- expect(
- getSwitchColor({
- theme: getTheme(),
- color: 'purple',
- })
- ).toMatchObject({
- onTintColor: 'purple',
- });
+ it('toggles to false when on and pressed', () => {
+ const onValueChange = jest.fn();
+ const { getByRole } = render(
+
+ );
+ fireEvent.press(getByRole('switch'));
+ expect(onValueChange).toHaveBeenCalledWith(false);
});
- it('should return correct disabled color, for theme version 3, dark mode', () => {
- Platform.OS = 'android';
-
- expect(
- getSwitchColor({
- theme: getTheme(true),
- disabled: true,
- })
- ).toMatchObject({
- onTintColor: color(white).alpha(0.06).rgb().string(),
- });
+ it('does not fire onValueChange when disabled', () => {
+ const onValueChange = jest.fn();
+ const { getByRole } = render(
+
+ );
+ fireEvent.press(getByRole('switch'));
+ expect(onValueChange).not.toHaveBeenCalled();
});
+});
- it('should return correct disabled color, light mode', () => {
- expect(
- getSwitchColor({
- theme: getTheme(),
- disabled: true,
- })
- ).toMatchObject({
- onTintColor: color(black).alpha(0.12).rgb().string(),
+describe('Switch accessibility', () => {
+ it('uses role="switch" and reports checked/disabled', () => {
+ const { getByRole } = render();
+ const node = getByRole('switch');
+ expect(node.props.accessibilityState).toEqual({
+ disabled: true,
+ checked: true,
});
});
- it('should return correct checked color if there is value', () => {
- expect(
- getSwitchColor({
- theme: getTheme(),
- value: true,
- color: 'purple',
- })
- ).toMatchObject({
- checkedColor: 'purple',
- });
+ it('forwards accessibilityLabel', () => {
+ const { getByLabelText } = render(
+
+ );
+ expect(getByLabelText('My switch')).toBeTruthy();
});
+});
- it('should return theme checked color, dark mode', () => {
- expect(
- getSwitchColor({
- theme: getTheme(true),
- })
- ).toMatchObject({
- onTintColor: grey700,
- });
+describe('getDefaultSwitchColors', () => {
+ it('resolves all color roles against the light theme palette', () => {
+ const colors = getDefaultSwitchColors(LightTheme);
+ expect(colors.checkedTrackColor).toBe(LightTheme.colors.primary);
+ expect(colors.checkedHandleColor).toBe(LightTheme.colors.onPrimary);
+ expect(colors.uncheckedTrackColor).toBe(
+ LightTheme.colors.surfaceContainerHighest
+ );
+ expect(colors.uncheckedHandleColor).toBe(LightTheme.colors.outline);
+ expect(colors.focusIndicatorColor).toBe(LightTheme.colors.secondary);
+ expect(colors.checkedBorderColor).toBe('transparent');
});
- it('should return theme checked color, light mode', () => {
- expect(
- getSwitchColor({
- theme: getTheme(),
- })
- ).toMatchObject({
- onTintColor: 'rgb(178, 175, 177)',
- });
+ it('resolves all color roles against the dark theme palette', () => {
+ const colors = getDefaultSwitchColors(DarkTheme);
+ expect(colors.checkedTrackColor).toBe(DarkTheme.colors.primary);
+ expect(colors.checkedHandleColor).toBe(DarkTheme.colors.onPrimary);
+ expect(colors.uncheckedTrackColor).toBe(
+ DarkTheme.colors.surfaceContainerHighest
+ );
});
});
diff --git a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap
index f7839d9be4..ac302fa4a5 100644
--- a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap
+++ b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap
@@ -1,70 +1,1261 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`renders disabled switch 1`] = `
-
+>
+
+
+
+
+
+
+
+
+
+
+
`;
-exports[`renders off switch 1`] = `
-
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Switch render renders off 1`] = `
+
+>
+
+
+
+
+
+
+
+
+
+
`;
-exports[`renders on switch 1`] = `
-
+
+
+
+
+
+
+
+
+`;
+
+exports[`Switch render renders with checked icon 1`] = `
+
+>
+
+
+
+
+
+
+
+
+
+
+ check
+
+
+
+
`;
-exports[`renders switch with color 1`] = `
-
+
+
+
+
+
+
+
+
+`;
+
+exports[`Switch render renders with per-state icons 1`] = `
+
+>
+
+
+
+
+
+
+
+
+
+
+ check
+
+
+
+
`;
diff --git a/src/index.tsx b/src/index.tsx
index 1430e797c4..2e56e3fa70 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -126,7 +126,10 @@ export type { Props as RadioButtonItemProps } from './components/RadioButton/Rad
export type { Props as SearchbarProps } from './components/Searchbar';
export type { Props as SnackbarProps } from './components/Snackbar';
export type { Props as SurfaceProps } from './components/Surface';
-export type { Props as SwitchProps } from './components/Switch/Switch';
+export type {
+ Props as SwitchProps,
+ SwitchColors,
+} from './components/Switch/Switch';
export type { Props as TextInputProps } from './components/TextInput/TextInput';
export type { Props as TextInputAffixProps } from './components/TextInput/Adornment/TextInputAffix';
export type { Props as TextInputIconProps } from './components/TextInput/Adornment/TextInputIcon';