zkit-ui 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@ import type {
4
4
  DimensionValue,
5
5
  GestureResponderEvent,
6
6
  Insets,
7
+ PressableStateCallbackType,
7
8
  StyleProp,
8
9
  TextStyle,
9
10
  ViewStyle,
@@ -837,41 +838,71 @@ function resolveWebCursorStyle(disabled: boolean): ViewStyle | undefined {
837
838
  return { cursor: disabled ? 'not-allowed' : 'pointer' } as ViewStyle;
838
839
  }
839
840
 
840
- function ButtonImpl(
841
- {
842
- variant = 'solid',
843
- tone = 'primary',
844
- size = 'md',
845
- shape = 'rounded',
846
- block = false,
847
- disabled = false,
848
- loading = false,
849
- loadingMode = 'inline',
850
- pressEffect = 'auto',
851
- icon,
852
- iconPlacement = 'start',
853
- iconOnly = false,
854
- color,
855
- colors,
856
- border,
857
- layout,
858
- gradient,
859
- shadow = 'none',
860
- children,
861
- style,
862
- contentStyle,
863
- textStyle,
864
- accessibilityLabel,
865
- accessibilityState,
866
- hitSlop,
867
- onPress,
868
- onPressIn,
869
- onPressOut,
870
- testID,
871
- ...pressableProps
872
- }: ButtonProps,
873
- ref: React.ForwardedRef<ButtonRef>
874
- ) {
841
+ function requiresAnimatedPressEffect(effect: ButtonPressEffect | undefined) {
842
+ return (
843
+ effect === 'scale' ||
844
+ effect === 'scale-opacity' ||
845
+ effect === 'highlight' ||
846
+ effect === 'scale-highlight'
847
+ );
848
+ }
849
+
850
+ function shouldUseStaticButtonPath({
851
+ disabled,
852
+ loading,
853
+ pressEffect,
854
+ }: {
855
+ disabled?: boolean;
856
+ loading?: boolean;
857
+ pressEffect?: ButtonPressEffect;
858
+ }) {
859
+ if (loading) return false;
860
+ if (disabled) return true;
861
+ return !requiresAnimatedPressEffect(pressEffect);
862
+ }
863
+
864
+ function useButtonFrame({
865
+ variant = 'solid',
866
+ tone = 'primary',
867
+ size = 'md',
868
+ shape = 'rounded',
869
+ block = false,
870
+ disabled = false,
871
+ loading = false,
872
+ icon,
873
+ iconPlacement = 'start',
874
+ iconOnly = false,
875
+ color,
876
+ colors,
877
+ border,
878
+ layout,
879
+ gradient,
880
+ shadow = 'none',
881
+ children,
882
+ textStyle,
883
+ accessibilityLabel,
884
+ }: Pick<
885
+ ButtonProps,
886
+ | 'variant'
887
+ | 'tone'
888
+ | 'size'
889
+ | 'shape'
890
+ | 'block'
891
+ | 'disabled'
892
+ | 'loading'
893
+ | 'icon'
894
+ | 'iconPlacement'
895
+ | 'iconOnly'
896
+ | 'color'
897
+ | 'colors'
898
+ | 'border'
899
+ | 'layout'
900
+ | 'gradient'
901
+ | 'shadow'
902
+ | 'children'
903
+ | 'textStyle'
904
+ | 'accessibilityLabel'
905
+ >) {
875
906
  const theme = useTheme();
876
907
  const scheme = useColorScheme();
877
908
  const { width: viewportWidth } = useWindowDimensions();
@@ -892,8 +923,6 @@ function ButtonImpl(
892
923
 
893
924
  const interactionDisabled = disabled || loading;
894
925
  const visualDisabled = disabled;
895
- const resolvedLoadingMode = iconOnly ? 'overlay' : loadingMode;
896
- const centeredLoading = resolvedLoadingMode !== 'inline';
897
926
 
898
927
  const visualColors = React.useMemo(
899
928
  () =>
@@ -1009,16 +1038,6 @@ function ButtonImpl(
1009
1038
  const hasText = resolvedText != null;
1010
1039
  const hasIcon = icon != null;
1011
1040
  const iconSize = layout?.iconSize ?? metrics.iconSize;
1012
- const loadingSize = React.useMemo(
1013
- () =>
1014
- resolveLoadingSize({
1015
- iconOnly,
1016
- layout,
1017
- metrics,
1018
- variant,
1019
- }),
1020
- [iconOnly, layout, metrics, variant]
1021
- );
1022
1041
  const contentGap = layout?.gap ?? metrics.gap;
1023
1042
  const iconBaseBoxStyle = React.useMemo(() => resolveIconBoxStyle(hasIcon, iconSize), [hasIcon, iconSize]);
1024
1043
  const iconPlacedBoxStyle = React.useMemo((): ViewStyle => {
@@ -1034,6 +1053,292 @@ function ButtonImpl(
1034
1053
  iconOnly && isPrimitiveTextChild(children) ? String(children) : undefined;
1035
1054
  const resolvedAccessibilityLabel = accessibilityLabel ?? inferredAccessibilityLabel;
1036
1055
 
1056
+ return {
1057
+ accentColor,
1058
+ contentGap,
1059
+ contentSizeStyle,
1060
+ defaultHitSlop,
1061
+ gradientCfg,
1062
+ hasGradientBackground,
1063
+ hasIcon,
1064
+ hasText,
1065
+ iconBaseBoxStyle,
1066
+ iconPlacedBoxStyle,
1067
+ iconSize,
1068
+ interactionDisabled,
1069
+ LinearGradientComponent,
1070
+ metrics,
1071
+ paddingStyle,
1072
+ radiusStyle,
1073
+ resolvedAccessibilityLabel,
1074
+ resolvedText,
1075
+ rootSizeStyle,
1076
+ shadowStyle,
1077
+ visualColors,
1078
+ visualDisabled,
1079
+ webCursorStyle,
1080
+ borderStyle,
1081
+ };
1082
+ }
1083
+
1084
+ function ButtonStaticImpl(
1085
+ {
1086
+ variant = 'solid',
1087
+ tone = 'primary',
1088
+ size = 'md',
1089
+ shape = 'rounded',
1090
+ block = false,
1091
+ disabled = false,
1092
+ loading = false,
1093
+ loadingMode: _loadingMode = 'inline',
1094
+ pressEffect = 'auto',
1095
+ icon,
1096
+ iconPlacement = 'start',
1097
+ iconOnly = false,
1098
+ color,
1099
+ colors,
1100
+ border,
1101
+ layout,
1102
+ gradient,
1103
+ shadow = 'none',
1104
+ children,
1105
+ style,
1106
+ contentStyle,
1107
+ textStyle,
1108
+ accessibilityLabel,
1109
+ accessibilityState,
1110
+ hitSlop,
1111
+ onPress,
1112
+ testID,
1113
+ ...pressableProps
1114
+ }: ButtonProps,
1115
+ ref: React.ForwardedRef<ButtonRef>
1116
+ ) {
1117
+ const frame = useButtonFrame({
1118
+ accessibilityLabel,
1119
+ block,
1120
+ border,
1121
+ children,
1122
+ color,
1123
+ colors,
1124
+ disabled,
1125
+ gradient,
1126
+ icon,
1127
+ iconOnly,
1128
+ iconPlacement,
1129
+ layout,
1130
+ loading,
1131
+ shadow,
1132
+ shape,
1133
+ size,
1134
+ textStyle,
1135
+ tone,
1136
+ variant,
1137
+ });
1138
+ const {
1139
+ borderStyle,
1140
+ contentSizeStyle,
1141
+ defaultHitSlop,
1142
+ gradientCfg,
1143
+ hasIcon,
1144
+ iconBaseBoxStyle,
1145
+ iconPlacedBoxStyle,
1146
+ interactionDisabled,
1147
+ LinearGradientComponent,
1148
+ paddingStyle,
1149
+ radiusStyle,
1150
+ resolvedAccessibilityLabel,
1151
+ resolvedText,
1152
+ rootSizeStyle,
1153
+ shadowStyle,
1154
+ visualColors,
1155
+ visualDisabled,
1156
+ webCursorStyle,
1157
+ } = frame;
1158
+
1159
+ const handlePress = React.useCallback(
1160
+ (event: GestureResponderEvent) => {
1161
+ if (interactionDisabled) return;
1162
+ onPress?.(event);
1163
+ },
1164
+ [interactionDisabled, onPress]
1165
+ );
1166
+ const pressOpacityEnabled = !interactionDisabled && (pressEffect === 'auto' || pressEffect === 'opacity');
1167
+ const rootStyle = React.useCallback(
1168
+ ({ pressed }: PressableStateCallbackType) => [
1169
+ styles.root,
1170
+ rootSizeStyle,
1171
+ radiusStyle,
1172
+ shadowStyle,
1173
+ visualDisabled ? styles.disabledRoot : null,
1174
+ pressOpacityEnabled && pressed ? styles.pressedRoot : null,
1175
+ webCursorStyle,
1176
+ style,
1177
+ ],
1178
+ [
1179
+ pressOpacityEnabled,
1180
+ radiusStyle,
1181
+ rootSizeStyle,
1182
+ shadowStyle,
1183
+ style,
1184
+ visualDisabled,
1185
+ webCursorStyle,
1186
+ ]
1187
+ );
1188
+
1189
+ return (
1190
+ <Pressable
1191
+ {...pressableProps}
1192
+ ref={ref}
1193
+ accessibilityLabel={resolvedAccessibilityLabel}
1194
+ accessibilityRole="button"
1195
+ accessibilityState={{
1196
+ ...accessibilityState,
1197
+ busy: Boolean(loading || accessibilityState?.busy),
1198
+ disabled: Boolean(interactionDisabled || accessibilityState?.disabled),
1199
+ }}
1200
+ disabled={interactionDisabled}
1201
+ hitSlop={hitSlop ?? defaultHitSlop}
1202
+ onPress={handlePress}
1203
+ style={rootStyle}
1204
+ testID={testID}
1205
+ >
1206
+ <View
1207
+ pointerEvents="none"
1208
+ style={[
1209
+ styles.content,
1210
+ contentSizeStyle,
1211
+ paddingStyle,
1212
+ radiusStyle,
1213
+ borderStyle,
1214
+ { backgroundColor: visualColors.backgroundColor },
1215
+ contentStyle,
1216
+ ]}
1217
+ >
1218
+ {LinearGradientComponent && gradientCfg ? (
1219
+ <LinearGradientComponent
1220
+ pointerEvents="none"
1221
+ colors={gradientCfg.colors}
1222
+ start={gradientCfg.start}
1223
+ end={gradientCfg.end}
1224
+ style={[StyleSheet.absoluteFillObject, radiusStyle]}
1225
+ />
1226
+ ) : null}
1227
+
1228
+ {iconOnly ? (
1229
+ (icon ?? resolvedText)
1230
+ ) : (
1231
+ <>
1232
+ {icon && iconPlacement === 'start' ? (
1233
+ <View style={[iconBaseBoxStyle, iconPlacedBoxStyle]}>{icon}</View>
1234
+ ) : null}
1235
+ {resolvedText}
1236
+ {icon && iconPlacement === 'end' ? (
1237
+ <View style={[iconBaseBoxStyle, iconPlacedBoxStyle]}>{icon}</View>
1238
+ ) : null}
1239
+ </>
1240
+ )}
1241
+ </View>
1242
+ </Pressable>
1243
+ );
1244
+ }
1245
+
1246
+ function ButtonAnimatedImpl(
1247
+ {
1248
+ variant = 'solid',
1249
+ tone = 'primary',
1250
+ size = 'md',
1251
+ shape = 'rounded',
1252
+ block = false,
1253
+ disabled = false,
1254
+ loading = false,
1255
+ loadingMode = 'inline',
1256
+ pressEffect = 'auto',
1257
+ icon,
1258
+ iconPlacement = 'start',
1259
+ iconOnly = false,
1260
+ color,
1261
+ colors,
1262
+ border,
1263
+ layout,
1264
+ gradient,
1265
+ shadow = 'none',
1266
+ children,
1267
+ style,
1268
+ contentStyle,
1269
+ textStyle,
1270
+ accessibilityLabel,
1271
+ accessibilityState,
1272
+ hitSlop,
1273
+ onPress,
1274
+ onPressIn,
1275
+ onPressOut,
1276
+ testID,
1277
+ ...pressableProps
1278
+ }: ButtonProps,
1279
+ ref: React.ForwardedRef<ButtonRef>
1280
+ ) {
1281
+ const resolvedLoadingMode = iconOnly ? 'overlay' : loadingMode;
1282
+ const centeredLoading = resolvedLoadingMode !== 'inline';
1283
+ const frame = useButtonFrame({
1284
+ accessibilityLabel,
1285
+ block,
1286
+ border,
1287
+ children,
1288
+ color,
1289
+ colors,
1290
+ disabled,
1291
+ gradient,
1292
+ icon,
1293
+ iconOnly,
1294
+ iconPlacement,
1295
+ layout,
1296
+ loading,
1297
+ shadow,
1298
+ shape,
1299
+ size,
1300
+ textStyle,
1301
+ tone,
1302
+ variant,
1303
+ });
1304
+ const {
1305
+ accentColor,
1306
+ borderStyle,
1307
+ contentGap,
1308
+ contentSizeStyle,
1309
+ defaultHitSlop,
1310
+ gradientCfg,
1311
+ hasGradientBackground,
1312
+ hasIcon,
1313
+ hasText,
1314
+ iconBaseBoxStyle,
1315
+ iconPlacedBoxStyle,
1316
+ iconSize,
1317
+ interactionDisabled,
1318
+ LinearGradientComponent,
1319
+ metrics,
1320
+ paddingStyle,
1321
+ radiusStyle,
1322
+ resolvedAccessibilityLabel,
1323
+ resolvedText,
1324
+ rootSizeStyle,
1325
+ shadowStyle,
1326
+ visualColors,
1327
+ visualDisabled,
1328
+ webCursorStyle,
1329
+ } = frame;
1330
+
1331
+ const loadingSize = React.useMemo(
1332
+ () =>
1333
+ resolveLoadingSize({
1334
+ iconOnly,
1335
+ layout,
1336
+ metrics,
1337
+ variant,
1338
+ }),
1339
+ [iconOnly, layout, metrics, variant]
1340
+ );
1341
+
1037
1342
  const pressSv = useSharedValue(0);
1038
1343
  const loadingSv = useSharedValue(loading ? 1 : 0);
1039
1344
  const [spinnerMounted, setSpinnerMounted] = React.useState(loading);
@@ -1075,23 +1380,32 @@ function ButtonImpl(
1075
1380
 
1076
1381
  if (loading) {
1077
1382
  setSpinnerMounted(true);
1383
+ loadingSv.value = withTiming(1, LOADING_TIMING);
1384
+ return () => {
1385
+ if (hideSpinnerTimerRef.current) {
1386
+ clearTimeout(hideSpinnerTimerRef.current);
1387
+ hideSpinnerTimerRef.current = null;
1388
+ }
1389
+ };
1078
1390
  }
1079
1391
 
1080
- loadingSv.value = withTiming(loading ? 1 : 0, LOADING_TIMING);
1081
-
1082
- if (!loading) {
1083
- hideSpinnerTimerRef.current = setTimeout(() => {
1084
- setSpinnerMounted(false);
1085
- }, LOADING_TIMING.duration);
1392
+ if (!spinnerMounted) {
1393
+ loadingSv.value = 0;
1394
+ return undefined;
1086
1395
  }
1087
1396
 
1397
+ loadingSv.value = withTiming(0, LOADING_TIMING);
1398
+ hideSpinnerTimerRef.current = setTimeout(() => {
1399
+ setSpinnerMounted(false);
1400
+ }, LOADING_TIMING.duration);
1401
+
1088
1402
  return () => {
1089
1403
  if (hideSpinnerTimerRef.current) {
1090
1404
  clearTimeout(hideSpinnerTimerRef.current);
1091
1405
  hideSpinnerTimerRef.current = null;
1092
1406
  }
1093
1407
  };
1094
- }, [loading, loadingSv]);
1408
+ }, [loading, loadingSv, spinnerMounted]);
1095
1409
 
1096
1410
  const rootAnimatedStyle = useAnimatedStyle(() => {
1097
1411
  const baseOpacity = visualDisabled ? DISABLED_OPACITY : 1;
@@ -1311,6 +1625,20 @@ function ButtonImpl(
1311
1625
  );
1312
1626
  }
1313
1627
 
1628
+ const ButtonStaticWithRef = React.forwardRef<ButtonRef, ButtonProps>(ButtonStaticImpl);
1629
+ ButtonStaticWithRef.displayName = 'ButtonStatic';
1630
+
1631
+ const ButtonAnimatedWithRef = React.forwardRef<ButtonRef, ButtonProps>(ButtonAnimatedImpl);
1632
+ ButtonAnimatedWithRef.displayName = 'ButtonAnimated';
1633
+
1634
+ function ButtonImpl(props: ButtonProps, ref: React.ForwardedRef<ButtonRef>) {
1635
+ if (shouldUseStaticButtonPath(props)) {
1636
+ return <ButtonStaticWithRef {...props} ref={ref} />;
1637
+ }
1638
+
1639
+ return <ButtonAnimatedWithRef {...props} ref={ref} />;
1640
+ }
1641
+
1314
1642
  const ButtonWithRef = React.forwardRef<ButtonRef, ButtonProps>(ButtonImpl);
1315
1643
  ButtonWithRef.displayName = 'Button';
1316
1644
 
@@ -1342,6 +1670,12 @@ const styles = StyleSheet.create({
1342
1670
  textDecorationLine: 'underline',
1343
1671
  },
1344
1672
  root: {},
1673
+ disabledRoot: {
1674
+ opacity: DISABLED_OPACITY,
1675
+ },
1676
+ pressedRoot: {
1677
+ opacity: PRESSED_OPACITY,
1678
+ },
1345
1679
  spinnerBox: {
1346
1680
  alignItems: 'center',
1347
1681
  justifyContent: 'center',
@@ -10,6 +10,7 @@ Checkbox 是三端一致的复选框组件,适合协议勾选、多选列表
10
10
  - 非受控模式先在 UI 线程推进动画,再同步内部状态
11
11
  - `CheckboxGroup` 使用 `value/defaultValue/onChange` 管理选中集合
12
12
  - 默认触控热区至少补到 `wp(44)`,小尺寸也保持可点性
13
+ - 自定义 `layout.indicatorSize` 但未传 `layout.indicatorIconSize` 时,默认勾号会按最终指示器尺寸等比缩放并取整
13
14
 
14
15
  ## 基础用法
15
16
 
@@ -102,6 +103,8 @@ export function Demo() {
102
103
  />
103
104
  ```
104
105
 
106
+ 只覆盖 `indicatorSize` 时,内置勾号尺寸会随最终指示器尺寸按比例取整;显式传入 `indicatorIconSize` 或 `indicatorRadius` 时仍以调用侧配置为准。`shape="circle"` 且未传 `indicatorRadius` 时,默认圆角会对 `indicatorSize / 2` 取整,减少 17、18 等小尺寸下的半像素圆角毛边。
107
+
105
108
  ## 自定义内容
106
109
 
107
110
  `children` 支持 render-prop。根节点本身就是可点击区域,子节点通常只读取状态做视觉变化,不需要再绑定一次点击。
@@ -149,7 +152,7 @@ Checkbox 透传 React Native `Pressable` 的常用属性和事件;组件自身
149
152
  - `shape?: 'rounded' | 'square' | 'circle'`:指示器形状,默认 `'rounded'`。
150
153
  - `color?: string`:主色覆盖,支持主题 token、语义色或颜色字符串。
151
154
  - `colors?: CheckboxColors`:结构化颜色覆盖。
152
- - `layout?: CheckboxLayout`:结构化尺寸覆盖。
155
+ - `layout?: CheckboxLayout`:结构化尺寸覆盖;自定义 `indicatorSize` 且未显式传 `indicatorIconSize` 时,勾号默认随最终尺寸缩放。
153
156
  - `duration?: number`:状态切换动画时长,默认 `170`。
154
157
 
155
158
  ### 内容
@@ -274,6 +274,7 @@ const SIZE_TOKENS = {
274
274
  const DISABLED_OPACITY = 0.48;
275
275
  const PRESSED_OPACITY = 0.86;
276
276
  const PRESSED_SCALE = 0.965;
277
+ const CUSTOM_INDICATOR_ICON_SIZE_RATIO = 0.625;
277
278
  const VALUE_TIMING_DURATION = 170;
278
279
  const PRESS_IN_DURATION = 90;
279
280
  const PRESS_OUT_DURATION = 140;
@@ -438,9 +439,19 @@ function resolveCheckboxMetrics(
438
439
  layout: CheckboxLayout | undefined
439
440
  ): CheckboxMetrics {
440
441
  const token = SIZE_TOKENS[size] ?? SIZE_TOKENS.md;
441
- const indicatorSize = resolvePositiveNumber(layout?.indicatorSize, wp(token.indicatorSize));
442
+ const tokenIndicatorSize = wp(token.indicatorSize);
443
+ const tokenIndicatorIconSize = wp(token.indicatorIconSize);
444
+ const hasCustomIndicatorSize = isPositiveNumber(layout?.indicatorSize);
445
+ const indicatorSize = resolvePositiveNumber(layout?.indicatorSize, tokenIndicatorSize);
446
+ const fallbackIconSize = hasCustomIndicatorSize
447
+ ? Math.max(wp(1), Math.round(indicatorSize * CUSTOM_INDICATOR_ICON_SIZE_RATIO))
448
+ : tokenIndicatorIconSize;
442
449
  const fallbackRadius =
443
- shape === 'circle' ? indicatorSize / 2 : shape === 'square' ? 0 : wp(token.indicatorRadius);
450
+ shape === 'circle'
451
+ ? Math.round(indicatorSize / 2)
452
+ : shape === 'square'
453
+ ? 0
454
+ : wp(token.indicatorRadius);
444
455
 
445
456
  return {
446
457
  indicatorSize,
@@ -449,7 +460,7 @@ function resolveCheckboxMetrics(
449
460
  layout?.indicatorBorderWidth,
450
461
  wp(token.indicatorBorderWidth)
451
462
  ),
452
- indicatorIconSize: resolvePositiveNumber(layout?.indicatorIconSize, wp(token.indicatorIconSize)),
463
+ indicatorIconSize: resolvePositiveNumber(layout?.indicatorIconSize, fallbackIconSize),
453
464
  gap: resolveNonNegativeNumber(layout?.gap, wp(token.gap)),
454
465
  minTouchTarget: resolvePositiveNumber(layout?.minTouchTarget, wp(44)),
455
466
  focusRingWidth: resolvePositiveNumber(layout?.focusRingWidth, wp(2)),
@@ -457,8 +468,12 @@ function resolveCheckboxMetrics(
457
468
  };
458
469
  }
459
470
 
471
+ function isPositiveNumber(value: number | undefined): value is number {
472
+ return typeof value === 'number' && Number.isFinite(value) && value > 0;
473
+ }
474
+
460
475
  function resolvePositiveNumber(value: number | undefined, fallback: number) {
461
- return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
476
+ return isPositiveNumber(value) ? value : fallback;
462
477
  }
463
478
 
464
479
  function resolveNonNegativeNumber(value: number | undefined, fallback: number) {
@@ -11,6 +11,7 @@ Radio 是三端一致的单选组件,适合互斥选项、设置项模式选
11
11
  - 已选项默认再次点击不取消;筛选类场景需要清空时显式开启 `allowDeselect`
12
12
  - `size/tone/variant` 提供语义化外观,`colors/layout` 作为集中 escape hatch
13
13
  - 默认触控热区至少补到 `wp(44)`,小尺寸也保持可点性
14
+ - 自定义 `layout.indicatorSize` 但未传 `layout.indicatorDotSize` 时,默认内点会按最终指示器尺寸等比缩放并取整
14
15
 
15
16
  ## RadioGroup
16
17
 
@@ -82,6 +83,8 @@ Radio 默认遵循单选控件直觉:点击已选项不会取消。需要筛
82
83
  />
83
84
  ```
84
85
 
86
+ 只覆盖 `indicatorSize` 时,内置圆点尺寸会随最终指示器尺寸按比例取整;显式传入 `indicatorDotSize` 时仍以调用侧配置为准。Radio 外圈默认圆角会对 `indicatorSize / 2` 取整,减少 17、18 等小尺寸下的半像素圆角毛边。
87
+
85
88
  ## 自定义内容
86
89
 
87
90
  `children` 支持 render-prop。根节点本身就是可点击区域,子节点通常只读取状态做视觉变化,不需要再绑定一次点击。
@@ -148,7 +151,7 @@ Radio 透传 React Native `Pressable` 的常用属性和事件;组件自身固
148
151
  - `variant?: 'outline' | 'soft' | 'solid'`:选中态视觉样式,默认 `'outline'`。
149
152
  - `color?: string`:主色覆盖,支持主题 token、语义色或颜色字符串。
150
153
  - `colors?: RadioColors`:结构化颜色覆盖。
151
- - `layout?: RadioLayout`:结构化尺寸覆盖。
154
+ - `layout?: RadioLayout`:结构化尺寸覆盖;自定义 `indicatorSize` 且未显式传 `indicatorDotSize` 时,圆点默认随最终尺寸缩放。
152
155
  - `duration?: number`:状态切换动画时长,默认 `170`。
153
156
 
154
157
  ### 内容
@@ -72,6 +72,7 @@ export type RadioSlotProps = {
72
72
 
73
73
  type RadioMetrics = {
74
74
  indicatorSize: number;
75
+ indicatorRadius: number;
75
76
  indicatorDotSize: number;
76
77
  indicatorBorderWidth: number;
77
78
  gap: number;
@@ -233,12 +234,16 @@ const SIZE_TOKENS = {
233
234
  },
234
235
  } as const satisfies Record<
235
236
  RadioSize,
236
- Omit<RadioMetrics, 'minTouchTarget' | 'focusRingWidth' | 'focusRingOffset'>
237
+ Omit<
238
+ RadioMetrics,
239
+ 'indicatorRadius' | 'minTouchTarget' | 'focusRingWidth' | 'focusRingOffset'
240
+ >
237
241
  >;
238
242
 
239
243
  const DISABLED_OPACITY = 0.48;
240
244
  const PRESSED_OPACITY = 0.86;
241
245
  const PRESSED_SCALE = 0.965;
246
+ const CUSTOM_INDICATOR_DOT_SIZE_RATIO = 0.45;
242
247
  const VALUE_TIMING_DURATION = 170;
243
248
  const PRESS_IN_DURATION = 90;
244
249
  const PRESS_OUT_DURATION = 140;
@@ -377,14 +382,18 @@ function resolveVariantColors(
377
382
 
378
383
  function resolveRadioMetrics(size: RadioSize, layout: RadioLayout | undefined): RadioMetrics {
379
384
  const token = SIZE_TOKENS[size] ?? SIZE_TOKENS.md;
380
- const indicatorSize = resolvePositiveNumber(layout?.indicatorSize, wp(token.indicatorSize));
385
+ const tokenIndicatorSize = wp(token.indicatorSize);
386
+ const tokenIndicatorDotSize = wp(token.indicatorDotSize);
387
+ const hasCustomIndicatorSize = isPositiveNumber(layout?.indicatorSize);
388
+ const indicatorSize = resolvePositiveNumber(layout?.indicatorSize, tokenIndicatorSize);
389
+ const fallbackDotSize = hasCustomIndicatorSize
390
+ ? Math.max(wp(1), Math.round(indicatorSize * CUSTOM_INDICATOR_DOT_SIZE_RATIO))
391
+ : tokenIndicatorDotSize;
381
392
 
382
393
  return {
383
394
  indicatorSize,
384
- indicatorDotSize: resolvePositiveNumber(
385
- layout?.indicatorDotSize,
386
- wp(token.indicatorDotSize)
387
- ),
395
+ indicatorRadius: Math.round(indicatorSize / 2),
396
+ indicatorDotSize: resolvePositiveNumber(layout?.indicatorDotSize, fallbackDotSize),
388
397
  indicatorBorderWidth: resolvePositiveNumber(
389
398
  layout?.indicatorBorderWidth,
390
399
  wp(token.indicatorBorderWidth)
@@ -396,8 +405,12 @@ function resolveRadioMetrics(size: RadioSize, layout: RadioLayout | undefined):
396
405
  };
397
406
  }
398
407
 
408
+ function isPositiveNumber(value: number | undefined): value is number {
409
+ return typeof value === 'number' && Number.isFinite(value) && value > 0;
410
+ }
411
+
399
412
  function resolvePositiveNumber(value: number | undefined, fallback: number) {
400
- return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
413
+ return isPositiveNumber(value) ? value : fallback;
401
414
  }
402
415
 
403
416
  function resolveNonNegativeNumber(value: number | undefined, fallback: number) {
@@ -798,10 +811,10 @@ function RadioImpl(
798
811
  () => ({
799
812
  width: metrics.indicatorSize,
800
813
  height: metrics.indicatorSize,
801
- borderRadius: metrics.indicatorSize / 2,
814
+ borderRadius: metrics.indicatorRadius,
802
815
  borderWidth: metrics.indicatorBorderWidth,
803
816
  }),
804
- [metrics.indicatorBorderWidth, metrics.indicatorSize]
817
+ [metrics.indicatorBorderWidth, metrics.indicatorRadius, metrics.indicatorSize]
805
818
  );
806
819
 
807
820
  const indicatorNode = showIndicator ? (
@@ -823,7 +836,7 @@ function RadioImpl(
823
836
  bottom: -metrics.focusRingOffset,
824
837
  left: -metrics.focusRingOffset,
825
838
  right: -metrics.focusRingOffset,
826
- borderRadius: metrics.indicatorSize / 2 + metrics.focusRingOffset,
839
+ borderRadius: metrics.indicatorRadius + metrics.focusRingOffset,
827
840
  borderWidth: metrics.focusRingWidth,
828
841
  borderColor: resolvedColors.focusRing,
829
842
  },