diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 72aee6d00f..f53d8f565f 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -285,6 +285,24 @@ The `overlayAccessibilityLabel` prop was renamed to `dismissAccessibilityLabel`: ``` +### RadioButton + +`RadioButton` renders one Material 3 circular radio control on every platform. The platform-specific components and their prop types are gone. On iOS checked controls no longer render a checkmark; they use the same ring-and-dot indicator as other platforms. `RadioButton` now also forwards `TouchableRipple` props (`accessible`, `onFocus`, `onBlur`, `borderless`, `rippleColor`, ...). + +The control now occupies a 48×48dp layout box instead of 40×40dp, so rows containing it may become 8dp taller. The 20dp radio indicator is unchanged. The minimum touch target is part of the layout on every platform, rather than an invisible area added with `hitSlop`. + +| v5 | v6 | +| ------------------------------------------------ | ----------------------------------------------------------------------- | +| `RadioButton.Android`, `RadioButton.IOS` | `RadioButton` | +| `RadioButtonAndroidProps`, `RadioButtonIOSProps` | `RadioButtonProps` | +| `RadioButton.Item` `mode="android" \| "ios"` | `mode` removed; `RadioButton.Item` always renders `RadioButton` | +| n/a | new `error` (uses `theme.colors.error`) and `style` (touch-target style) | + +```diff +- ++ +``` + ### Searchbar The misspelled `traileringIcon` props have been renamed: diff --git a/docs/component-docs.config.ts b/docs/component-docs.config.ts index 2cf106849c..1eb407109e 100644 --- a/docs/component-docs.config.ts +++ b/docs/component-docs.config.ts @@ -125,9 +125,7 @@ const pages = { ProgressBar: 'ProgressBar', RadioButton: { RadioButton: 'RadioButton/RadioButton', - RadioButtonAndroid: 'RadioButton/RadioButtonAndroid', RadioButtonGroup: 'RadioButton/RadioButtonGroup', - RadioButtonIOS: 'RadioButton/RadioButtonIOS', RadioButtonItem: 'RadioButton/RadioButtonItem', }, Searchbar: 'Searchbar', diff --git a/docs/src/data/screenshots.ts b/docs/src/data/screenshots.ts index b7c59d424d..47cdf889c8 100644 --- a/docs/src/data/screenshots.ts +++ b/docs/src/data/screenshots.ts @@ -107,12 +107,6 @@ export const screenshots = { Modal: 'screenshots/modal.gif', ProgressBar: 'screenshots/progress-bar.png', RadioButton: { - 'Android (enabled)': 'screenshots/radio-enabled.android.png', - 'Android (disabled)': 'screenshots/radio-disabled.android.png', - 'iOS (enabled)': 'screenshots/radio-enabled.ios.png', - 'iOS (disabled)': 'screenshots/radio-disabled.ios.png', - }, - 'RadioButton.Android': { enabled: 'screenshots/radio-enabled.android.png', disabled: 'screenshots/radio-disabled.android.png', }, @@ -120,10 +114,6 @@ export const screenshots = { Android: 'screenshots/radio-button-group-android.gif', iOS: 'screenshots/radio-button-group-ios.gif', }, - 'RadioButton.IOS': { - enabled: 'screenshots/radio-enabled.ios.png', - disabled: 'screenshots/radio-disabled.ios.png', - }, 'RadioButton.Item': 'screenshots/radio-item.ios.png', Searchbar: { bar: 'screenshots/searchbar.png', diff --git a/example/src/Examples/RadioButtonExample.tsx b/example/src/Examples/RadioButtonExample.tsx index 6ab1869dd9..c30e49e00f 100644 --- a/example/src/Examples/RadioButtonExample.tsx +++ b/example/src/Examples/RadioButtonExample.tsx @@ -10,7 +10,7 @@ import { import ScreenWrapper from '../ScreenWrapper'; -type State = 'normal' | 'normal-ios' | 'normal-item' | 'custom'; +type State = 'normal' | 'normal-item' | 'custom'; const RadioButtonExample = () => { const [checked, setChecked] = React.useState('normal'); @@ -19,26 +19,15 @@ const RadioButtonExample = () => { setChecked('normal')}> - Normal - Material Design + Normal - - setChecked('normal-ios')}> - - Normal 2 - IOS - - - - - setChecked('custom')}> Custom @@ -58,11 +47,31 @@ const RadioButtonExample = () => { onPress={() => setChecked('normal-item')} /> - Checked (Disabled) + Error (Checked) + + + + Error (Unchecked) + + + + + + Checked (Disabled) - Unchecked (Disabled) + Unchecked (Disabled) { Second - + Third - + diff --git a/example/src/Examples/RadioButtonItemExample.tsx b/example/src/Examples/RadioButtonItemExample.tsx index e640a1dd0a..efd4eee32a 100644 --- a/example/src/Examples/RadioButtonItemExample.tsx +++ b/example/src/Examples/RadioButtonItemExample.tsx @@ -7,55 +7,47 @@ import ScreenWrapper from '../ScreenWrapper'; const RadioButtonItemExample = () => { const [checkedDefault, setCheckedDefault] = React.useState(true); - const [checkedAndroid, setCheckedAndroid] = React.useState(true); - const [checkedIOS, setCheckedIOS] = React.useState(true); const [checkedLeadingControl, setCheckedLeadingControl] = React.useState(true); const [checkedDisabled, setCheckedDisabled] = React.useState(true); + const [checkedError, setCheckedError] = React.useState(true); const [checkedLabelVariant, setCheckedLabelVariant] = React.useState(true); return ( setCheckedDefault(!checkedDefault)} value="default" /> setCheckedAndroid(!checkedAndroid)} - value="android" - /> - setCheckedIOS(!checkedIOS)} - value="iOS" - /> - setCheckedLeadingControl(!checkedLeadingControl)} - value="iOS" + value="leading" position="leading" /> setCheckedError(!checkedError)} + value="error" + error + /> + setCheckedDisabled(!checkedDisabled)} - value="iOS" + value="disabled" disabled /> setCheckedLabelVariant(!checkedLabelVariant)} - value="default" + value="variant" /> ); diff --git a/src/components/RadioButton/RadioButton.tsx b/src/components/RadioButton/RadioButton.tsx index af39325c33..4167e9b94c 100644 --- a/src/components/RadioButton/RadioButton.tsx +++ b/src/components/RadioButton/RadioButton.tsx @@ -1,12 +1,29 @@ -import { Platform } from 'react-native'; +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; import type { GestureResponderEvent } from 'react-native'; -import RadioButtonAndroid from './RadioButtonAndroid'; -import RadioButtonIOS from './RadioButtonIOS'; +import Animated, { + Easing, + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + +import { RadioButtonContext } from './RadioButtonGroup'; +import { RadioButtonTokens } from './tokens'; +import { getSelectionControlColor, handlePress, isChecked } from './utils'; import { useInternalTheme } from '../../core/theming'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import type { ThemeProp } from '../../theme/types'; +import type { PressableStateCallbackType } from '../TouchableRipple/Pressable'; +import TouchableRipple from '../TouchableRipple/TouchableRipple'; +import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; -export type Props = { +export type Props = Omit< + React.PropsWithoutRef, + 'children' +> & { /** * Value of the radio button */ @@ -31,6 +48,12 @@ export type Props = { * Custom color for radio. */ color?: string; + /** + * Whether the radio button is in an error state. When true, the ring + * (unchecked) and dot (selected) use `theme.colors.error`. `disabled` + * and explicit `color`/`uncheckedColor` overrides take precedence. + */ + error?: boolean; /** * @optional */ @@ -41,6 +64,14 @@ export type Props = { testID?: string; }; +const { + ringSize, + dotSize, + outlineWidth: OUTLINE_WIDTH, + stateLayerSize, + touchTargetSize, +} = RadioButtonTokens; + /** * Radio buttons allow the selection a single option from a set. * @@ -71,16 +102,177 @@ export type Props = { * * export default MyComponent; * ``` + * + * @extends TouchableRipple props https://callstack.github.io/react-native-paper/docs/components/TouchableRipple */ -const RadioButton = ({ theme: themeOverrides, ...props }: Props) => { +const RadioButton = ({ + disabled, + onPress, + theme: themeOverrides, + value, + status, + testID, + error, + ...rest +}: Props) => { const theme = useInternalTheme(themeOverrides); + const context = React.useContext(RadioButtonContext); + + const checked = + isChecked({ + contextValue: context?.value, + status, + value, + }) === 'checked'; + + const reduceMotion = useReduceMotion(); + const reanimatedReduceMotion = reduceMotion + ? ReduceMotion.Always + : ReduceMotion.Never; + + // Single selection animation path: the dot scales in (with a slight + // overshoot) when the radio becomes checked. The ring outline stays a + // constant width. Keyed on `checked` (not `status`) so radios driven by a + // `RadioButton.Group` animate too. + const dotScale = useSharedValue(1); + const isFirstRendering = React.useRef(true); - const Button = Platform.select({ - default: RadioButtonAndroid, - ios: RadioButtonIOS, - }); + const dotTimingConfig = React.useMemo( + () => ({ + duration: theme.motion.duration.short3, + easing: Easing.bezier(...theme.motion.easing.standard), + reduceMotion: reanimatedReduceMotion, + }), + [ + theme.motion.duration.short3, + theme.motion.easing.standard, + reanimatedReduceMotion, + ] + ); - return