2026-03-09 21:29:03 +08:00
|
|
|
/**
|
|
|
|
|
* Button 按钮组件
|
|
|
|
|
* 支持多种变体、尺寸、加载状态和图标
|
|
|
|
|
*/
|
|
|
|
|
|
2026-03-25 05:16:54 +08:00
|
|
|
import React, { useMemo } from 'react';
|
2026-03-09 21:29:03 +08:00
|
|
|
import {
|
|
|
|
|
TouchableOpacity,
|
|
|
|
|
ActivityIndicator,
|
|
|
|
|
StyleSheet,
|
|
|
|
|
View,
|
|
|
|
|
ViewStyle,
|
|
|
|
|
TextStyle,
|
|
|
|
|
} from 'react-native';
|
|
|
|
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
2026-03-25 05:16:54 +08:00
|
|
|
import { borderRadius, spacing, fontSizes, useAppColors, type AppColors } from '../../theme';
|
2026-03-09 21:29:03 +08:00
|
|
|
import Text from './Text';
|
|
|
|
|
|
|
|
|
|
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'text' | 'danger';
|
|
|
|
|
type ButtonSize = 'sm' | 'md' | 'lg';
|
|
|
|
|
type IconPosition = 'left' | 'right';
|
|
|
|
|
|
|
|
|
|
interface ButtonProps {
|
|
|
|
|
title: string;
|
|
|
|
|
onPress: () => void;
|
|
|
|
|
variant?: ButtonVariant;
|
|
|
|
|
size?: ButtonSize;
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
loading?: boolean;
|
2026-03-25 05:16:54 +08:00
|
|
|
icon?: string;
|
2026-03-09 21:29:03 +08:00
|
|
|
iconPosition?: IconPosition;
|
|
|
|
|
fullWidth?: boolean;
|
|
|
|
|
style?: ViewStyle;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 05:16:54 +08:00
|
|
|
function createButtonStyles(colors: AppColors) {
|
|
|
|
|
return StyleSheet.create({
|
|
|
|
|
base: {
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
borderRadius: borderRadius.md,
|
|
|
|
|
},
|
|
|
|
|
content: {
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
},
|
|
|
|
|
size_sm: {
|
|
|
|
|
paddingVertical: spacing.sm,
|
|
|
|
|
paddingHorizontal: spacing.md,
|
|
|
|
|
minHeight: 32,
|
|
|
|
|
},
|
|
|
|
|
size_md: {
|
|
|
|
|
paddingVertical: spacing.md,
|
|
|
|
|
paddingHorizontal: spacing.lg,
|
|
|
|
|
minHeight: 40,
|
|
|
|
|
},
|
|
|
|
|
size_lg: {
|
|
|
|
|
paddingVertical: spacing.lg,
|
|
|
|
|
paddingHorizontal: spacing.xl,
|
|
|
|
|
minHeight: 48,
|
|
|
|
|
},
|
|
|
|
|
primary: {
|
|
|
|
|
backgroundColor: colors.primary.main,
|
|
|
|
|
},
|
|
|
|
|
secondary: {
|
|
|
|
|
backgroundColor: colors.secondary.main,
|
|
|
|
|
},
|
|
|
|
|
outline: {
|
|
|
|
|
backgroundColor: 'transparent',
|
|
|
|
|
borderWidth: 1,
|
|
|
|
|
borderColor: colors.primary.main,
|
|
|
|
|
},
|
|
|
|
|
text: {
|
|
|
|
|
backgroundColor: 'transparent',
|
|
|
|
|
shadowColor: 'transparent',
|
|
|
|
|
elevation: 0,
|
|
|
|
|
},
|
|
|
|
|
danger: {
|
|
|
|
|
backgroundColor: colors.error.main,
|
|
|
|
|
},
|
|
|
|
|
disabled: {
|
|
|
|
|
backgroundColor: colors.background.disabled,
|
|
|
|
|
borderColor: colors.background.disabled,
|
|
|
|
|
},
|
|
|
|
|
fullWidth: {
|
|
|
|
|
width: '100%',
|
|
|
|
|
},
|
|
|
|
|
textBase: {
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
},
|
|
|
|
|
textSize_sm: {
|
|
|
|
|
fontSize: fontSizes.sm,
|
|
|
|
|
},
|
|
|
|
|
textSize_md: {
|
|
|
|
|
fontSize: fontSizes.md,
|
|
|
|
|
},
|
|
|
|
|
textSize_lg: {
|
|
|
|
|
fontSize: fontSizes.lg,
|
|
|
|
|
},
|
|
|
|
|
iconLeft: {
|
|
|
|
|
marginRight: spacing.sm,
|
|
|
|
|
},
|
|
|
|
|
iconRight: {
|
|
|
|
|
marginLeft: spacing.sm,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 21:29:03 +08:00
|
|
|
const Button: React.FC<ButtonProps> = ({
|
|
|
|
|
title,
|
|
|
|
|
onPress,
|
|
|
|
|
variant = 'primary',
|
|
|
|
|
size = 'md',
|
|
|
|
|
disabled = false,
|
|
|
|
|
loading = false,
|
|
|
|
|
icon,
|
|
|
|
|
iconPosition = 'left',
|
|
|
|
|
fullWidth = false,
|
|
|
|
|
style,
|
|
|
|
|
}) => {
|
2026-03-25 05:16:54 +08:00
|
|
|
const colors = useAppColors();
|
|
|
|
|
const styles = useMemo(() => createButtonStyles(colors), [colors]);
|
|
|
|
|
|
2026-03-09 21:29:03 +08:00
|
|
|
const getButtonStyle = (): ViewStyle[] => {
|
|
|
|
|
const baseStyle: ViewStyle[] = [styles.base, styles[`size_${size}`]];
|
|
|
|
|
|
|
|
|
|
switch (variant) {
|
|
|
|
|
case 'primary':
|
|
|
|
|
baseStyle.push(styles.primary);
|
|
|
|
|
break;
|
|
|
|
|
case 'secondary':
|
|
|
|
|
baseStyle.push(styles.secondary);
|
|
|
|
|
break;
|
|
|
|
|
case 'outline':
|
|
|
|
|
baseStyle.push(styles.outline);
|
|
|
|
|
break;
|
|
|
|
|
case 'text':
|
|
|
|
|
baseStyle.push(styles.text);
|
|
|
|
|
break;
|
|
|
|
|
case 'danger':
|
|
|
|
|
baseStyle.push(styles.danger);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (disabled || loading) {
|
|
|
|
|
baseStyle.push(styles.disabled);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (fullWidth) {
|
|
|
|
|
baseStyle.push(styles.fullWidth);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return baseStyle;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getTextStyle = (): TextStyle => {
|
|
|
|
|
const baseStyle: TextStyle = {
|
|
|
|
|
...styles.textBase,
|
|
|
|
|
...styles[`textSize_${size}`],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
switch (variant) {
|
|
|
|
|
case 'primary':
|
|
|
|
|
case 'danger':
|
|
|
|
|
baseStyle.color = colors.text.inverse;
|
|
|
|
|
break;
|
|
|
|
|
case 'secondary':
|
|
|
|
|
baseStyle.color = colors.text.inverse;
|
|
|
|
|
break;
|
|
|
|
|
case 'outline':
|
|
|
|
|
case 'text':
|
|
|
|
|
baseStyle.color = colors.primary.main;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (disabled || loading) {
|
|
|
|
|
baseStyle.color = colors.text.disabled;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return baseStyle;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getIconSize = (): number => {
|
|
|
|
|
switch (size) {
|
|
|
|
|
case 'sm':
|
|
|
|
|
return 16;
|
|
|
|
|
case 'md':
|
|
|
|
|
return 20;
|
|
|
|
|
case 'lg':
|
|
|
|
|
return 24;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getIconColor = (): string => {
|
|
|
|
|
if (disabled || loading) {
|
|
|
|
|
return colors.text.disabled;
|
|
|
|
|
}
|
|
|
|
|
switch (variant) {
|
|
|
|
|
case 'primary':
|
|
|
|
|
case 'danger':
|
|
|
|
|
return colors.text.inverse;
|
|
|
|
|
case 'secondary':
|
|
|
|
|
return colors.text.inverse;
|
|
|
|
|
case 'outline':
|
|
|
|
|
case 'text':
|
|
|
|
|
return colors.primary.main;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const isDisabled = disabled || loading;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
style={[getButtonStyle(), style]}
|
|
|
|
|
onPress={onPress}
|
|
|
|
|
disabled={isDisabled}
|
|
|
|
|
activeOpacity={0.9}
|
|
|
|
|
>
|
|
|
|
|
{loading ? (
|
|
|
|
|
<ActivityIndicator
|
|
|
|
|
size="small"
|
2026-03-25 05:16:54 +08:00
|
|
|
color={
|
|
|
|
|
variant === 'outline' || variant === 'text' ? colors.primary.main : colors.text.inverse
|
|
|
|
|
}
|
2026-03-09 21:29:03 +08:00
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<View style={styles.content}>
|
|
|
|
|
{icon && iconPosition === 'left' && (
|
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
name={icon as any}
|
|
|
|
|
size={getIconSize()}
|
|
|
|
|
color={getIconColor()}
|
|
|
|
|
style={styles.iconLeft}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<Text style={getTextStyle()}>{title}</Text>
|
|
|
|
|
{icon && iconPosition === 'right' && (
|
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
name={icon as any}
|
|
|
|
|
size={getIconSize()}
|
|
|
|
|
color={getIconColor()}
|
|
|
|
|
style={styles.iconRight}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</View>
|
|
|
|
|
)}
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default Button;
|