This commit is contained in:
oneao committed 2026-01-13 22:18:01 +08:00
1 parent 6277fb2f9e
commit 8ecac0d7dd
270 files changed
+40438 -529

No files matched your search

@@ -0,0 +1,54 @@
import { ScrollView, Text } from 'react-native'
import Empty from '@/static/images/Empty.svg'
import Error from '@/static/images/Error.svg'
import NotFound from '@/static/images/NotFound.svg'
interface EmptyStateProps {
type?: 'empty' | 'error' | '404'
title?: string
description?: string
image?: React.ReactNode
footer?: React.ReactNode
}
const defaultImages = {
empty: <Empty width={224} height={224} />,
error: <Error width={224} height={224} />,
404: <NotFound width={224} height={224} />,
}
export default function EmptyState({
type = 'empty',
title,
description,
image,
footer,
}: EmptyStateProps) {
return (
<ScrollView
contentContainerStyle={{
flexGrow: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'white',
padding: 16,
}}
showsVerticalScrollIndicator={true}
>
{/* image 插槽优先 */}
{image ?? defaultImages[type]}
{title && (
<Text className="text-2xl font-semibold text-gray-900 mb-2">{title}</Text>
)}
{description && (
<Text className="text-base text-gray-600 text-center mb-6 leading-6">
{description}
</Text>
)}
{footer}
</ScrollView>
)
}
@@ -0,0 +1,20 @@
import React from 'react'
import IconFont from '@/components/iconfont'
interface CategoryIconProps {
name?: string
size?: number
color?: string
type?: 0 | 1
}
/**
* 分类图标组件
*/
export default function MoneyCategoryIcon({
name,
size = 24,
color,
}: CategoryIconProps) {
return <IconFont name={name as any} size={size} color={color} />
}
@@ -0,0 +1,92 @@
import type { icons } from 'lucide-react-native'
import type { ViewStyle } from 'react-native'
import type { LucideIconProps } from './LucideIcon'
import React from 'react'
import {
ButtonSpinner,
ButtonText,
Button as NativeButton,
} from '@/components/ui/button'
import LucideIcon from './LucideIcon'
type IconProp = (LucideIconProps & { icon: keyof typeof icons }) | React.ReactNode
interface CustomButtonProps {
children?: React.ReactNode
onPress?: () => void
disabled?: boolean
loading?: boolean
className?: string
style?: ViewStyle
action?: 'primary' | 'secondary' | 'positive' | 'negative' | 'default'
variant?: 'solid' | 'outline' | 'link'
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
leftIcon?: IconProp
rightIcon?: IconProp
}
const Button: React.FC<CustomButtonProps> = ({
children,
onPress,
disabled = false,
loading = false,
className,
style,
action = 'primary',
variant = 'solid',
size = 'md',
leftIcon,
rightIcon,
}) => {
const isDisabled = disabled || loading
const hasChildren = !!children
const renderLeftIcon = () => {
if (loading)
return <ButtonSpinner color="#fff" className={hasChildren ? 'mr-2' : ''} />
if (!leftIcon)
return null
if (React.isValidElement(leftIcon)) {
return <span className={hasChildren ? 'mr-2' : ''}>{leftIcon}</span>
}
const { icon, ...props } = leftIcon as LucideIconProps & { icon: keyof typeof icons }
return <LucideIcon icon={icon} className={hasChildren ? 'mr-2' : ''} {...props} />
}
const renderRightIcon = () => {
if (!rightIcon)
return null
if (React.isValidElement(rightIcon)) {
return <span className={hasChildren ? 'ml-2' : ''}>{rightIcon}</span>
}
const { icon, ...props } = rightIcon as LucideIconProps & { icon: keyof typeof icons }
return <LucideIcon icon={icon} className={hasChildren ? 'ml-2' : ''} {...props} />
}
return (
<NativeButton
onPress={onPress}
isDisabled={isDisabled}
className={className}
style={[style, { opacity: isDisabled ? 0.8 : 1 }]}
action={action}
variant={variant}
size={size}
>
{renderLeftIcon()}
{
children && (
<ButtonText>{children}</ButtonText>
)
}
{renderRightIcon()}
</NativeButton>
)
}
export default Button
@@ -0,0 +1,105 @@
import type { icons } from 'lucide-react-native'
import type { ReactElement, ReactNode } from 'react'
import type { ViewStyle } from 'react-native'
import { Children, cloneElement, isValidElement } from 'react'
import { Text, TouchableOpacity, View } from 'react-native'
import LucideIcon from './LucideIcon'
/* ================= Cell ================= */
export interface CellProps {
left?: ReactNode
right?: ReactNode
title?: string
leftIcon?: keyof typeof icons
rightIcon?: keyof typeof icons
rightText?: string
isLink?: boolean
className?: string
style?: ViewStyle
showDivider?: boolean // 由 CellGroup 注入
onPress?: () => void
}
export function Cell({
left,
right,
title,
leftIcon,
rightIcon = 'ChevronRight',
rightText,
isLink = false,
className,
style,
showDivider = false,
onPress,
}: CellProps) {
return (
<View>
<TouchableOpacity
activeOpacity={isLink ? 0.5 : 1}
className={`flex flex-row items-center justify-between px-4 py-3 ${className}`}
style={style}
onPress={onPress}
>
{left || (
<View className="flex flex-row items-center space-x-2">
{leftIcon && <LucideIcon icon={leftIcon} size={18} />}
{title && <Text className="text-base text-gray-900 ml-2">{title}</Text>}
</View>
)}
{right || (
<View className="flex flex-row items-center ">
{rightText && <Text className="text-gray-500 text-base mr-2">{rightText}</Text>}
{isLink && rightIcon && (
<LucideIcon icon={rightIcon} size={16} color="#9ca3af" />
)}
</View>
)}
</TouchableOpacity>
{showDivider && (
<View
className="bg-gray-100"
style={{ marginLeft: 16, marginRight: 16, height: 1 }}
/>
)}
</View>
)
}
/* ================= CellGroup ================= */
export interface CellGroupProps {
className?: string
style?: ViewStyle
children?: ReactNode
showDivider?: boolean // 整组是否显示下划线
}
export function CellGroup({
className,
style,
children,
showDivider = true,
}: CellGroupProps) {
const elements = Children.toArray(children)
return (
<View className={`bg-white ${className}`} style={style}>
{elements.map((child, index) => {
if (!isValidElement<CellProps>(child))
return child
const isLast = index === elements.length - 1
return cloneElement<CellProps>(child as ReactElement<CellProps>, {
showDivider: showDivider && !isLast,
})
})}
</View>
)
}
@@ -0,0 +1,50 @@
// components/ui/CustomCheckbox.tsx
import type { ViewStyle } from 'react-native'
import React from 'react'
import {
CheckboxIcon,
CheckboxIndicator,
CheckboxLabel,
Checkbox as NativeCheckbox,
} from '@/components/ui/checkbox'
import { CheckIcon } from '@/components/ui/icon'
interface CustomCheckboxProps {
value?: string
isChecked: boolean
onChange: (checked: boolean) => void
label: React.ReactNode
className?: string
style?: ViewStyle
disabled?: boolean
icon?: React.ElementType
}
const Checkbox: React.FC<CustomCheckboxProps> = ({
value,
isChecked,
onChange,
label,
className,
style,
disabled,
icon,
}) => {
return (
<NativeCheckbox
value={value ?? ''} // 避免 undefined
isChecked={isChecked}
onChange={onChange}
className={className}
style={style}
isDisabled={disabled}
>
<CheckboxIndicator>
<CheckboxIcon as={icon ?? CheckIcon} />
</CheckboxIndicator>
<CheckboxLabel>{label}</CheckboxLabel>
</NativeCheckbox>
)
}
export default Checkbox
@@ -0,0 +1,108 @@
import type { IDatePickerProps } from '@yz1311/react-native-wheel-picker'
import { DatePicker } from '@yz1311/react-native-wheel-picker'
import dayjs from 'dayjs'
import React, { useEffect, useRef, useState } from 'react'
import { Animated, Modal, StyleSheet, TouchableOpacity } from 'react-native'
import { toDate } from '@/utils/time'
// 定义 mode 类型
export type DatePickerMode = 'year' | 'month' | 'date' | 'time' | 'datetime'
// 定义 format 类型
export type DateFormatType = 'YYYY' | 'YYYY-MM' | 'YYYY-MM-DD' | 'HH:mm:ss' | 'YYYY-MM-DD HH:mm:ss'
interface ADatePickerProps extends Omit<IDatePickerProps, 'date'> {
date?: Date | string
show?: boolean
onClose?: () => void
onConfirm?: (value: string) => void
mode?: DatePickerMode
format?: DateFormatType
}
const defaultFormatMap: Record<DatePickerMode, DateFormatType> = {
year: 'YYYY',
month: 'YYYY-MM',
date: 'YYYY-MM-DD',
time: 'HH:mm:ss',
datetime: 'YYYY-MM-DD HH:mm:ss',
}
export default function ADatePicker({
date,
show = false,
onClose,
onConfirm,
mode = 'date',
format,
...restProps
}: ADatePickerProps) {
const fadeAnim = useRef(new Animated.Value(0)).current
const slideAnim = useRef(new Animated.Value(300)).current
const [visible, setVisible] = useState(show)
// 使用传入 format 或默认 format
const finalFormat = format || defaultFormatMap[mode]
useEffect(() => {
if (show) {
setVisible(true)
fadeAnim.setValue(0)
slideAnim.setValue(300)
Animated.parallel([
Animated.timing(fadeAnim, { toValue: 1, duration: 250, useNativeDriver: true }),
Animated.timing(slideAnim, { toValue: 0, duration: 250, useNativeDriver: true }),
]).start()
}
else {
Animated.parallel([
Animated.timing(fadeAnim, { toValue: 0, duration: 250, useNativeDriver: true }),
Animated.timing(slideAnim, { toValue: 280, duration: 250, useNativeDriver: true }),
]).start(() => setVisible(false))
}
}, [show])
const handleCancel = () => onClose?.()
const handleConfirm = (date: Date) => {
const value = dayjs(date).format(finalFormat)
onConfirm?.(value)
}
if (!visible)
return null
return (
<Modal transparent visible={visible} animationType="none">
<Animated.View style={[styles.overlay, { opacity: fadeAnim }]}>
<TouchableOpacity style={{ flex: 1 }} activeOpacity={1} onPress={handleCancel} />
</Animated.View>
<Animated.View
style={[styles.pickerContainer, { transform: [{ translateY: slideAnim }] }]}
>
<DatePicker
{...restProps}
date={toDate(date)}
mode={mode}
isModal={false}
onPickerCancel={handleCancel}
onPickerConfirm={handleConfirm}
/>
</Animated.View>
</Modal>
)
}
const styles = StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
},
pickerContainer: {
backgroundColor: '#fff',
overflow: 'hidden',
position: 'absolute',
bottom: 0,
width: '100%',
},
})
@@ -0,0 +1,74 @@
import type { ViewStyle } from 'react-native'
import React from 'react'
import { Text, View } from 'react-native'
interface DividerProps {
direction?: 'horizontal' | 'vertical'
text?: string | React.ReactNode
textPosition?: 'left' | 'center' | 'right'
style?: ViewStyle
className?: string
}
export default function Divider({
direction = 'horizontal',
text,
textPosition = 'center',
style,
className,
}: DividerProps) {
const isString = typeof text === 'string'
// 垂直分割线
if (direction === 'vertical') {
return (
<View
style={style}
className={`w-px bg-gray-300 mx-2 h-4 ${className ?? ''}`}
/>
)
}
// 水平,无文字
if (!text) {
return (
<View
style={style}
className={`h-px bg-gray-300 my-2 w-full ${className ?? ''}`}
/>
)
}
// 水平 + 文本
return (
<View
style={style}
className={`flex-row items-center my-2 ${className ?? ''}`}
>
{/* 左线 */}
<View
className={`h-px bg-gray-300 ${
textPosition === 'left' ? 'w-2' : 'flex-1'
}`}
/>
{/* 文本(字符串 → 自动包裹 Text;node → 直接渲染) */}
<View className="mx-3">
{isString
? (
<Text className="text-gray-500 text-sm">{text}</Text>
)
: (
text
)}
</View>
{/* 右线 */}
<View
className={`h-px bg-gray-300 ${
textPosition === 'right' ? 'w-2' : 'flex-1'
}`}
/>
</View>
)
}
@@ -0,0 +1,81 @@
import type { ScrollView } from 'react-native'
import React, { createContext, forwardRef, useCallback, useImperativeHandle, useRef } from 'react'
export interface FormRef {
validate: () => boolean
scrollToField: (name: string) => void
}
interface FormProps {
children: React.ReactNode
form: Record<string, any>
scrollRef?: React.RefObject<ScrollView | null>
}
interface FormContextType {
form: Record<string, any>
registerValidator: (name: string, validator: () => boolean) => void
unregisterValidator: (name: string) => void
registerFieldPosition: (name: string, y: number) => void
}
export const FormContext = createContext<FormContextType | undefined>(undefined)
const Form = forwardRef<FormRef, FormProps>(({ children, form, scrollRef }, ref) => {
const validatorsRef = useRef<Array<{ name: string, validator: () => boolean }>>([])
const fieldPositions = useRef<Map<string, number>>(new Map())
const registerValidator = useCallback((name: string, validator: () => boolean) => {
validatorsRef.current.push({ name, validator })
}, [])
const unregisterValidator = useCallback((name: string) => {
validatorsRef.current = validatorsRef.current.filter(v => v.name !== name)
}, [])
const registerFieldPosition = useCallback((name: string, y: number) => {
fieldPositions.current.set(name, y)
}, [])
const validate = (): boolean => {
let firstErrorField: string | null = null
let isValid = true
for (const { name, validator } of validatorsRef.current) {
if (!validator() && !firstErrorField) {
firstErrorField = name
isValid = false
}
}
if (!isValid && firstErrorField) {
scrollToField(firstErrorField)
}
return isValid
}
const scrollToField = (name: string) => {
const y = fieldPositions.current.get(name)
if (y !== undefined && scrollRef?.current) {
scrollRef.current.scrollTo({ y, animated: true })
}
}
useImperativeHandle(ref, () => ({ validate, scrollToField }))
return (
<FormContext.Provider
value={{
form,
registerValidator,
unregisterValidator,
registerFieldPosition,
}}
>
{children}
</FormContext.Provider>
)
})
export default Form
@@ -0,0 +1,164 @@
import React, { useContext, useEffect, useState } from 'react'
import {
FormControl,
FormControlError,
FormControlErrorIcon,
FormControlErrorText,
FormControlLabel,
FormControlLabelText,
} from '@/components/ui/form-control'
import { AlertCircleIcon } from '@/components/ui/icon'
import { FormContext } from './Form'
interface RuleType {
validator?: (val: any) => boolean
pattern?: RegExp
message?: string
len?: number
min?: number
max?: number
required?: boolean
whitespace?: boolean
}
interface FormItemProps<T> {
name?: keyof T
label?: string
rules?: RuleType[]
children: React.ReactNode
isRequired?: boolean
}
function FormItem<T extends Record<string, any>>({
name,
label,
rules = [],
children,
isRequired = false,
}: FormItemProps<T>) {
const context = useContext(FormContext)
const [error, setError] = useState('')
useEffect(() => {
if (!context)
return
const currentName = String(name)
// 如果 isRequired 为 true,且 rules 中没有 required: true,则自动加
const hasRequiredInRules = rules.some(rule => rule.required)
const allRules = [...rules]
if (isRequired && !hasRequiredInRules) {
allRules.unshift({
required: true,
message: label ? `${label} 是必填项` : `${currentName} 是必填项`,
})
}
const validateFn = () => {
const value = context.form[currentName]
for (const rule of allRules) {
// 1. required 校验
if (rule.required) {
const empty
= value === undefined
|| value === null
|| value === ''
|| (Array.isArray(value) && value.length === 0)
|| (rule.whitespace && typeof value === 'string' && value.trim() === '')
if (empty) {
setError(rule.message || `${label || currentName} 校验不通过`)
return false
}
}
// 2. 自定义 validator 返回 true/false
if (rule.validator !== undefined) {
const ok = rule.validator(value)
if (!ok) {
setError(rule.message || `${label || currentName} 校验不通过`)
return false
}
}
// 3. pattern 正则
if (rule.pattern && typeof value === 'string') {
if (!rule.pattern.test(value)) {
setError(rule.message || `${label || currentName} 格式不正确`)
return false
}
}
// 4. len
if (rule.len !== undefined) {
if (
(typeof value === 'string' && value.length !== rule.len)
|| (typeof value === 'number' && value !== rule.len)
|| (Array.isArray(value) && value.length !== rule.len)
) {
setError(rule.message || `${label || currentName} 长度/值必须为 ${rule.len}`)
return false
}
}
// 5. min
if (rule.min !== undefined) {
if (
(typeof value === 'string' && value.length < rule.min)
|| (typeof value === 'number' && value < rule.min)
|| (Array.isArray(value) && value.length < rule.min)
) {
setError(rule.message || `${label || currentName} 最小值/长度为 ${rule.min}`)
return false
}
}
// 6. max
if (rule.max !== undefined) {
if (
(typeof value === 'string' && value.length > rule.max)
|| (typeof value === 'number' && value > rule.max)
|| (Array.isArray(value) && value.length > rule.max)
) {
setError(rule.message || `${label || currentName} 最大值/长度为 ${rule.max}`)
return false
}
}
}
setError('')
return true
}
context.registerValidator(currentName, validateFn)
return () => {
context.unregisterValidator(currentName)
}
}, [context, name, label, rules, isRequired])
const handleLayout = (event: any) => {
const { y } = event.nativeEvent.layout
context?.registerFieldPosition(String(name), y)
}
return (
<FormControl isInvalid={!!error} isRequired={isRequired} className="w-full mb-4" onLayout={handleLayout}>
{label && (
<FormControlLabel>
<FormControlLabelText>{label}</FormControlLabelText>
</FormControlLabel>
)}
{children}
{error && (
<FormControlError>
<FormControlErrorIcon as={AlertCircleIcon} className="text-red-500" />
<FormControlErrorText className="text-red-500">{error}</FormControlErrorText>
</FormControlError>
)}
</FormControl>
)
}
export default FormItem
@@ -0,0 +1,75 @@
import type { ComponentType } from 'react'
import type { StyleProp, TextInputProps, ViewStyle } from 'react-native'
import React, { forwardRef, useState } from 'react'
import { Pressable } from 'react-native'
import { EyeIcon, EyeOffIcon } from '@/components/ui/icon'
import { InputField, InputIcon, InputSlot, Input as NativeInput } from '@/components/ui/input'
export interface AInputProps extends Omit<TextInputProps, 'onChange'> {
isPassword?: boolean
isDisabled?: boolean
isReadOnly?: boolean
isInvalid?: boolean
isHovered?: boolean
isFocused?: boolean
variant?: 'outline' | 'rounded' | 'underlined'
size?: 'sm' | 'md' | 'lg' | 'xl'
leftIcon?: ComponentType
className?: string
style?: StyleProp<ViewStyle>
}
const AInput = forwardRef<React.ComponentRef<typeof InputField>, AInputProps>(
({
isPassword = false,
isDisabled = false,
isReadOnly = false,
isInvalid = false,
isHovered = false,
isFocused = false,
variant = 'outline',
size = 'md',
leftIcon,
className,
style,
...rest
}, ref) => {
const [showPassword, setShowPassword] = useState(false)
return (
<NativeInput
variant={variant}
size={size}
isDisabled={isDisabled}
isReadOnly={isReadOnly}
isInvalid={isInvalid}
isHovered={isHovered}
isFocused={isFocused}
className={className}
style={style}
>
{leftIcon && (
<InputSlot className="pl-3">
<InputIcon as={leftIcon} />
</InputSlot>
)}
<InputField
ref={ref}
type={isPassword ? (showPassword ? 'text' : 'password') : 'text'}
{...rest}
/>
{isPassword && (
<InputSlot className="pr-3">
<Pressable onPress={() => setShowPassword(prev => !prev)}>
<InputIcon as={showPassword ? EyeIcon : EyeOffIcon} />
</Pressable>
</InputSlot>
)}
</NativeInput>
)
},
)
export default AInput
@@ -0,0 +1,50 @@
import type { icons } from 'lucide-react-native'
import { useRouter } from 'expo-router'
import { Text, TouchableOpacity } from 'react-native'
import LucideIcon from './LucideIcon'
export interface ListItemProps {
icon: keyof typeof icons
text: string
href?: string
onPress?: () => void
showRightIcon?: boolean
}
export default function ListItem({ icon, text, href, onPress, showRightIcon = true }: ListItemProps) {
const router = useRouter()
const handlePress = () => {
if (onPress) {
onPress()
return
}
if (href) {
router.navigate(href)
}
}
return (
<TouchableOpacity
className="flex flex-row items-center py-2"
onPress={handlePress}
>
<LucideIcon icon={icon} size={18} strokeWidth={1.5} />
<Text className="text-base ml-3">{text}</Text>
{
showRightIcon && (
<LucideIcon
icon="ChevronRight"
className="ml-auto"
color="#ccc"
size={22}
strokeWidth={1.5}
/>
)
}
</TouchableOpacity>
)
}
@@ -0,0 +1,42 @@
import type { GestureResponderEvent, ViewStyle } from 'react-native'
import { icons } from 'lucide-react-native'
import React from 'react'
import { Pressable } from 'react-native'
export interface LucideIconProps {
icon: keyof typeof icons
size?: number
color?: string
strokeWidth?: number
onPress?: (event: GestureResponderEvent) => void
style?: ViewStyle
clickable?: boolean
className?: string
}
export default function LucideIcon({
icon,
size = 16,
color = '#000',
strokeWidth = 2,
onPress,
style,
clickable = true,
className,
}: LucideIconProps) {
const IconComponent = icons[icon]
if (!IconComponent)
return null
if (!clickable) {
return <IconComponent size={size} color={color} strokeWidth={strokeWidth} style={style} className={className} />
}
// clickable 为 true,包 Pressable
return (
<Pressable onPress={onPress} style={style} className={className}>
<IconComponent size={size} color={color} strokeWidth={strokeWidth} />
</Pressable>
)
}
@@ -0,0 +1,94 @@
import type { ViewStyle } from 'react-native'
import { Text } from 'react-native'
import { CloseIcon, Icon } from '@/components/ui/icon'
import {
Modal,
ModalBackdrop,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/ui/modal'
import AButton from '../ao/Button'
interface AModalProps {
isOpen?: boolean
title?: string | React.ReactNode
body?: string | React.ReactNode
footer?: React.ReactNode
onClose?: () => void
onConfirm?: () => void
onCancel?: () => void
showBackdrop?: boolean
showCloseIcon?: boolean
className?: string
style?: ViewStyle
}
export default function AModal({
isOpen,
title,
body,
footer,
onClose,
onConfirm,
showBackdrop = true,
showCloseIcon = false,
className,
style,
}: AModalProps) {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
>
{ showBackdrop && <ModalBackdrop />}
<ModalContent
className={className}
style={{
...style,
width: '80%',
}}
>
<ModalHeader>
{title && (typeof title === 'string' ? <Text>{title}</Text> : title)}
{
showCloseIcon && (
<ModalCloseButton>
<Icon as={CloseIcon} />
</ModalCloseButton>
)
}
</ModalHeader>
<ModalBody>
{body && (typeof body === 'string' ? <Text>{body}</Text> : body)}
</ModalBody>
<ModalFooter>
{
footer || (
<>
<AButton
variant="outline"
action="secondary"
className="mr-3"
onPress={onClose}
>
取消
</AButton>
<AButton
onPress={onConfirm}
>
确认
</AButton>
</>
)
}
</ModalFooter>
</ModalContent>
</Modal>
)
}
@@ -0,0 +1,206 @@
import type { icons } from 'lucide-react-native'
import type { StyleProp, TextStyle, ViewStyle } from 'react-native'
import React, { useEffect, useRef, useState } from 'react'
import { Platform, Pressable, Text, View } from 'react-native'
import PagerView from 'react-native-pager-view'
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'
import LucideIcon from './LucideIcon'
export interface SegmentedTabItem {
key: string
label: string
icon?: keyof typeof icons
content?: React.ReactNode
}
interface SegmentedTabsProps {
tabs: SegmentedTabItem[]
value?: number
defaultIndex?: number
onChange?: (index: number) => void
className?: string
style?: StyleProp<ViewStyle>
tabHeight?: number
borderRadius?: number
spacing?: number
sliderColor?: string
sliderStyle?: StyleProp<ViewStyle>
selectedTextStyle?: StyleProp<TextStyle>
unselectedTextStyle?: StyleProp<TextStyle>
selectedColor?: string
unselectedColor?: string
fontSize?: number
iconSize?: number
width?: number
}
export default function SegmentedTabs({
tabs,
value,
defaultIndex = 0,
onChange,
className,
style,
tabHeight = 36,
borderRadius = 2,
spacing = 4,
sliderColor = '#fff',
sliderStyle,
selectedTextStyle,
unselectedTextStyle,
selectedColor = '#000',
unselectedColor = '#666',
fontSize = 14,
iconSize = 14,
width,
}: SegmentedTabsProps) {
const [selectedIndex, setSelectedIndex] = useState(value ?? defaultIndex)
const [headerMeasuredWidth, setHeaderMeasuredWidth] = useState(0)
const [headerMeasuredHeight, setHeaderMeasuredHeight] = useState(tabHeight)
const [ready, setReady] = useState(false)
const pagerRef = useRef<PagerView>(null)
const sliderX = useSharedValue(0)
const initialized = useRef(false)
const actualHeaderWidth = width ?? headerMeasuredWidth
const tabWidth = actualHeaderWidth ? actualHeaderWidth / tabs.length : 0
const sliderHeight = Math.max(headerMeasuredHeight - spacing * 2, 0)
const sliderWidth = tabWidth - spacing * 2
useEffect(() => {
if (value !== undefined)
setSelectedIndex(value)
}, [value])
useEffect(() => {
if (!ready || tabWidth === 0)
return
const target = selectedIndex * tabWidth + spacing
if (!initialized.current) {
sliderX.value = target
initialized.current = true
}
else {
sliderX.value = withSpring(target, { damping: 18, stiffness: 150, mass: 1 })
}
}, [selectedIndex, tabWidth, ready])
const animatedSliderStyle = useAnimatedStyle(() => ({
transform: [{ translateX: sliderX.value }],
}))
const handlePress = (index: number) => {
if (value === undefined)
setSelectedIndex(index)
onChange?.(index)
pagerRef.current?.setPage(index)
}
const handlePageSelected = (e: any) => {
const index = e.nativeEvent.position
setSelectedIndex(index)
onChange?.(index)
}
const handleHeaderLayout = (event: any) => {
const { width, height } = event.nativeEvent.layout
setHeaderMeasuredWidth(width)
setHeaderMeasuredHeight(height || tabHeight)
if (width > 0 && !ready)
setReady(true)
}
return (
<View style={{ flex: 1 }}>
{/* Tab Header */}
<View
className={className}
style={[
{
width,
flexDirection: 'row',
backgroundColor: '#f2f2f2',
borderRadius,
height: tabHeight,
overflow: 'hidden',
},
style,
]}
onLayout={handleHeaderLayout}
>
{ready && (
<Animated.View
style={[
{
position: 'absolute',
top: spacing,
left: 0,
width: sliderWidth,
height: sliderHeight,
borderRadius,
backgroundColor: sliderColor,
...Platform.select({ android: { elevation: 6 } }),
},
sliderStyle,
animatedSliderStyle,
]}
/>
)}
{tabs.map((tab, i) => {
const isActive = i === selectedIndex
return (
<Pressable
key={tab.key}
onPress={() => handlePress(i)}
style={{
width: tabWidth,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: spacing,
}}
>
{tab.icon && (
<LucideIcon
icon={tab.icon}
size={iconSize}
color={isActive ? selectedColor : unselectedColor}
/>
)}
<Text
style={[
{ color: isActive ? selectedColor : unselectedColor, fontSize, fontWeight: 'bold' },
isActive ? selectedTextStyle : unselectedTextStyle,
]}
>
{tab.label}
</Text>
</Pressable>
)
})}
</View>
{/* PagerView 内容 */}
<PagerView
ref={pagerRef}
style={{ flex: 1, marginTop: 16 }}
initialPage={defaultIndex}
onPageSelected={handlePageSelected}
>
{tabs.map(tab => (
<View key={tab.key} style={{ flex: 1 }}>
{tab.content}
</View>
))}
</PagerView>
</View>
)
}
@@ -0,0 +1,89 @@
import React, { useMemo, useRef, useState } from 'react'
import { Pressable, Text, View } from 'react-native'
import PagerView from 'react-native-pager-view'
interface TabItem {
label: string
key: string
content?: React.ReactNode
}
interface TabProps {
data?: TabItem[]
defaultIndex?: number
onChange?: (index: number) => void
tabBarClass?: string
tabItemClass?: string
activeTabItemClass?: string
}
export default function Tab({
data,
defaultIndex = 0,
onChange,
tabBarClass = 'flex flex-row gap-2 overflow-hidden',
tabItemClass = 'items-center justify-center rounded-md px-4 py-2 bg-gray-200',
activeTabItemClass = 'items-center justify-center rounded-md px-4 py-2 bg-gray-800',
}: TabProps) {
const [selectedIndex, setSelectedIndex] = useState(defaultIndex)
const pagerRef = useRef<PagerView>(null)
if (!data || data.length === 0)
return null
// 判断是否至少有一个 tab 含 content
const hasContent = useMemo(
() => data.some(item => item.content),
[data],
)
const handlePress = (index: number) => {
setSelectedIndex(index)
onChange?.(index)
if (hasContent)
pagerRef.current?.setPage(index)
}
const onPageSelected = (e: any) => {
const index = e.nativeEvent.position
setSelectedIndex(index)
onChange?.(index)
}
return (
<View className="flex-1">
{/* tab bar */}
<View className={tabBarClass}>
{data.map((item, index) => {
const isActive = index === selectedIndex
return (
<Pressable
key={item.key}
onPress={() => handlePress(index)}
className={isActive ? activeTabItemClass : tabItemClass}
>
<Text className={isActive ? 'text-white' : 'text-gray-700'}>
{item.label}
</Text>
</Pressable>
)
})}
</View>
{hasContent && (
<PagerView
ref={pagerRef}
style={{ flex: 1, marginTop: 16 }}
initialPage={defaultIndex}
onPageSelected={onPageSelected}
>
{data.map(item => (
<View key={item.key} style={{ flex: 1 }}>
{item.content}
</View>
))}
</PagerView>
)}
</View>
)
}
@@ -0,0 +1,31 @@
import Button from './Button'
import { Cell, CellGroup } from './Cell'
import Checkbox from './Checkbox'
import DatePicker from './DatePicker'
import Divider from './Divider'
import Form, { FormRef } from './Form'
import FormItem from './FormItem'
import Input from './Input'
import ListItem from './ListItem'
import LucideIcon from './LucideIcon'
import Modal from './Modal'
import SegmentedTabs from './SegmentedTabs'
import Tab from './Tab'
export {
Button as AButton,
Cell as ACell,
CellGroup as ACellGroup,
Checkbox as ACheckbox,
DatePicker as ADatePicker,
Divider as ADivider,
Form as AForm,
FormItem as AFormItem,
FormRef as AFormRef,
Input as AInput,
ListItem as AListItem,
Modal as AModal,
SegmentedTabs as ASegmentedTabs,
Tab as ATab,
LucideIcon,
}