This commit is contained in:
oneao committed 2025-11-09 20:59:22 +08:00
1 parent 8a6d29f5d0
commit 7ed98a04c1
11 files changed
+1127 -55

No files matched your search

@@ -0,0 +1,33 @@
import type { GestureResponderEvent, ViewStyle } from 'react-native'
import { icons } from 'lucide-react-native'
import React from 'react'
import { Pressable } from 'react-native'
interface LucideIconProps {
icon: keyof typeof icons
size?: number
color?: string
strokeWidth?: number
onPress?: (event: GestureResponderEvent) => void
style?: ViewStyle
}
export default function LucideIcon({
icon,
size = 16,
color = '#000',
strokeWidth = 2,
onPress,
style,
}: LucideIconProps) {
const IconComponent = icons[icon]
if (!IconComponent)
return null
return (
<Pressable onPress={onPress} style={style}>
<IconComponent size={size} color={color} strokeWidth={strokeWidth} />
</Pressable>
)
}
@@ -0,0 +1,247 @@
import type { icons } from 'lucide-react-native'
import type { LayoutChangeEvent, StyleProp, TextStyle, ViewStyle } from 'react-native'
import React, { useEffect, useRef, useState } from 'react'
import { Platform, Pressable, Text, View } from 'react-native'
import Animated, { useAnimatedStyle, useSharedValue, withSpring, withTiming } from 'react-native-reanimated'
import LucideIcon from './LucideIcon'
export interface SegmentedTabItem {
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
contentContainerStyle?: StyleProp<ViewStyle>
animateContent?: boolean
cacheContent?: boolean
headerWidth?: number
contentWidth?: 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,
contentContainerStyle,
animateContent = true,
cacheContent = true,
headerWidth,
contentWidth,
}: SegmentedTabsProps) {
const [selectedIndex, setSelectedIndex] = useState(value ?? defaultIndex)
const [headerMeasuredWidth, setHeaderMeasuredWidth] = useState(0)
const [contentMeasuredWidth, setContentMeasuredWidth] = useState(0)
const [ready, setReady] = useState(false)
const sliderX = useSharedValue(0)
const contentX = useSharedValue(0)
const initialized = useRef(false)
const actualHeaderWidth = headerWidth ?? headerMeasuredWidth
const actualContentWidth = contentWidth ?? contentMeasuredWidth
const tabWidth = actualHeaderWidth ? actualHeaderWidth / tabs.length : 0
const sliderWidth = tabWidth - spacing * 2
const sliderHeight = tabHeight - spacing * 2
// 外部 value 改变
useEffect(() => {
if (value !== undefined)
setSelectedIndex(value)
}, [value])
// 滑块和内容动画
useEffect(() => {
if (!ready || tabWidth === 0 || actualContentWidth === 0)
return
const target = selectedIndex * tabWidth + spacing
if (!initialized.current) {
sliderX.value = target
contentX.value = -selectedIndex * actualContentWidth
initialized.current = true
}
else {
sliderX.value = withSpring(target, { damping: 18, stiffness: 150, mass: 1 })
contentX.value = animateContent
? withTiming(-selectedIndex * actualContentWidth, { duration: 250 })
: -selectedIndex * actualContentWidth
}
}, [selectedIndex, tabWidth, actualContentWidth, ready])
const animatedSliderStyle = useAnimatedStyle(() => ({
transform: [{ translateX: sliderX.value }],
}))
const animatedContentStyle = useAnimatedStyle(() => ({
transform: [{ translateX: contentX.value }],
}))
const handlePress = (index: number) => {
if (value === undefined)
setSelectedIndex(index)
onChange?.(index)
}
const handleHeaderLayout = (event: LayoutChangeEvent) => {
const { width } = event.nativeEvent.layout
setHeaderMeasuredWidth(width)
if (width > 0 && actualContentWidth > 0 && !ready)
setReady(true)
}
const handleContentLayout = (event: LayoutChangeEvent) => {
const { width } = event.nativeEvent.layout
setContentMeasuredWidth(width)
if (width > 0 && actualHeaderWidth > 0 && !ready)
setReady(true)
}
return (
<View className="w-full h-full">
{/* Header */}
<View
className={className}
style={[
{
width: headerWidth,
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={i}
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>
{/* Content */}
<View
style={[
{ width: contentWidth ?? '100%', flex: 1, overflow: 'hidden', marginTop: 16 },
contentContainerStyle,
]}
onLayout={handleContentLayout}
>
<Animated.View
style={[
{ flexDirection: 'row', width: actualContentWidth * tabs.length },
animatedContentStyle,
]}
>
{tabs.map((tab, i) => {
const isActive = i === selectedIndex
const shouldRender = cacheContent || isActive
if (!shouldRender) {
// 占位保持宽度
return <View key={`placeholder-${i}`} style={{ width: actualContentWidth }} />
}
return (
<View
key={isActive ? `tab-${i}-${selectedIndex}` : i} // ✅ 动态 key,确保重新挂载
style={{ width: actualContentWidth }}
>
{tab.content}
</View>
)
})}
</Animated.View>
</View>
</View>
)
}
@@ -3,6 +3,7 @@ import Checkbox from './Checkbox'
import Form, { FormRef } from './Form'
import FormItem from './FormItem'
import Input from './Input'
import LucideIcon from './LucideIcon'
export {
Button as AButton,
@@ -11,4 +12,5 @@ export {
FormItem as AFormItem,
FormRef as AFormRef,
Input as AInput,
LucideIcon,
}
@@ -0,0 +1,355 @@
'use client'
import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils'
import type {
MotionComponentProps,
} from '@legendapp/motion'
import type {
ViewStyle,
} from 'react-native'
import { createModal as createDrawer } from '@gluestack-ui/core/modal/creator'
import { tva, useStyleContext, withStyleContext } from '@gluestack-ui/utils/nativewind-utils'
import {
AnimatePresence,
createMotionAnimatedComponent,
Motion,
} from '@legendapp/motion'
import { cssInterop } from 'nativewind'
import React from 'react'
import {
Dimensions,
Pressable,
ScrollView,
View,
} from 'react-native'
type IAnimatedPressableProps = React.ComponentProps<typeof Pressable>
& MotionComponentProps<typeof Pressable, ViewStyle, unknown, unknown, unknown>
const AnimatedPressable = createMotionAnimatedComponent(
Pressable,
) as React.ComponentType<IAnimatedPressableProps>
const SCOPE = 'MODAL'
const screenWidth = Dimensions.get('window').width
const screenHeight = Dimensions.get('window').height
const sizes: { [key: string]: number } = {
sm: 0.25,
md: 0.5,
lg: 0.75,
full: 1,
}
type IMotionViewProps = React.ComponentProps<typeof View>
& MotionComponentProps<typeof View, ViewStyle, unknown, unknown, unknown>
const MotionView = Motion.View as React.ComponentType<IMotionViewProps>
const UIDrawer = createDrawer({
Root: withStyleContext(View, SCOPE),
Backdrop: AnimatedPressable,
Content: MotionView,
Body: ScrollView,
CloseButton: Pressable,
Footer: View,
Header: View,
AnimatePresence,
})
cssInterop(AnimatedPressable, { className: 'style' })
cssInterop(MotionView, { className: 'style' })
const drawerStyle = tva({
base: 'w-full h-full web:pointer-events-none relative',
variants: {
size: {
sm: '',
md: '',
lg: '',
full: '',
},
anchor: {
left: 'items-start',
right: 'items-end',
top: 'justify-start',
bottom: 'justify-end',
},
},
})
const drawerBackdropStyle = tva({
base: 'absolute left-0 top-0 right-0 bottom-0 bg-background-dark web:cursor-default',
})
const drawerContentStyle = tva({
base: 'bg-background-0 overflow-scroll border-outline-100 p-6 absolute',
parentVariants: {
size: {
sm: 'w-1/4',
md: 'w-1/2',
lg: 'w-3/4',
full: 'w-full',
},
anchor: {
left: 'h-full border-r',
right: 'h-full border-l',
top: 'w-full border-b',
bottom: 'w-full border-t',
},
},
parentCompoundVariants: [
{
anchor: 'top',
size: 'sm',
class: 'h-1/4',
},
{
anchor: 'top',
size: 'md',
class: 'h-1/2',
},
{
anchor: 'top',
size: 'lg',
class: 'h-3/4',
},
{
anchor: 'top',
size: 'full',
class: 'h-full',
},
{
anchor: 'bottom',
size: 'sm',
class: 'h-1/4',
},
{
anchor: 'bottom',
size: 'md',
class: 'h-1/2',
},
{
anchor: 'bottom',
size: 'lg',
class: 'h-3/4',
},
{
anchor: 'bottom',
size: 'full',
class: 'h-full',
},
],
})
const drawerCloseButtonStyle = tva({
base: 'z-10 rounded data-[focus-visible=true]:web:bg-background-100 web:outline-0 cursor-pointer',
})
const drawerHeaderStyle = tva({
base: 'justify-between items-center flex-row',
})
const drawerBodyStyle = tva({
base: 'mt-4 mb-6 shrink-0',
})
const drawerFooterStyle = tva({
base: 'flex-row justify-end items-center',
})
type IDrawerProps = React.ComponentProps<typeof UIDrawer>
& VariantProps<typeof drawerStyle> & { className?: string }
type IDrawerBackdropProps = React.ComponentProps<typeof UIDrawer.Backdrop>
& VariantProps<typeof drawerBackdropStyle> & { className?: string }
type IDrawerContentProps = React.ComponentProps<typeof UIDrawer.Content>
& VariantProps<typeof drawerContentStyle> & { className?: string }
type IDrawerHeaderProps = React.ComponentProps<typeof UIDrawer.Header>
& VariantProps<typeof drawerHeaderStyle> & { className?: string }
type IDrawerBodyProps = React.ComponentProps<typeof UIDrawer.Body>
& VariantProps<typeof drawerBodyStyle> & { className?: string }
type IDrawerFooterProps = React.ComponentProps<typeof UIDrawer.Footer>
& VariantProps<typeof drawerFooterStyle> & { className?: string }
type IDrawerCloseButtonProps = React.ComponentProps<
typeof UIDrawer.CloseButton
>
& VariantProps<typeof drawerCloseButtonStyle> & { className?: string }
const Drawer = React.forwardRef<
React.ComponentRef<typeof UIDrawer>,
IDrawerProps
>(({ className, size = 'sm', anchor = 'left', ...props }, ref) => {
return (
<UIDrawer
ref={ref}
{...props}
pointerEvents="box-none"
className={drawerStyle({ size, anchor, class: className })}
context={{ size, anchor }}
/>
)
})
const DrawerBackdrop = React.forwardRef<
React.ComponentRef<typeof UIDrawer.Backdrop>,
IDrawerBackdropProps
>(({ className, ...props }, ref) => {
return (
<UIDrawer.Backdrop
ref={ref}
initial={{
opacity: 0,
}}
animate={{
opacity: 0.5,
}}
exit={{
opacity: 0,
}}
transition={{
type: 'spring',
damping: 18,
stiffness: 250,
opacity: {
type: 'timing',
duration: 250,
},
}}
{...props}
className={drawerBackdropStyle({
class: className,
})}
/>
)
})
const DrawerContent = React.forwardRef<
React.ComponentRef<typeof UIDrawer.Content>,
IDrawerContentProps
>(({ className, ...props }, ref) => {
const { size: parentSize, anchor: parentAnchor } = useStyleContext(SCOPE)
const drawerHeight = screenHeight * (sizes[parentSize] || sizes.md)
const drawerWidth = screenWidth * (sizes[parentSize] || sizes.md)
const isHorizontal = parentAnchor === 'left' || parentAnchor === 'right'
const initialObj = isHorizontal
? { x: parentAnchor === 'left' ? -drawerWidth : drawerWidth }
: { y: parentAnchor === 'top' ? -drawerHeight : drawerHeight }
const animateObj = isHorizontal ? { x: 0 } : { y: 0 }
const exitObj = isHorizontal
? { x: parentAnchor === 'left' ? -drawerWidth : drawerWidth }
: { y: parentAnchor === 'top' ? -drawerHeight : drawerHeight }
const customClass = isHorizontal
? `top-0 ${parentAnchor === 'left' ? 'left-0' : 'right-0'}`
: `left-0 ${parentAnchor === 'top' ? 'top-0' : 'bottom-0'}`
return (
<UIDrawer.Content
ref={ref}
initial={initialObj}
animate={animateObj}
exit={exitObj}
transition={{
type: 'timing',
duration: 300,
}}
{...props}
className={drawerContentStyle({
parentVariants: {
size: parentSize,
anchor: parentAnchor,
},
class: `${className} ${customClass}`,
})}
pointerEvents="auto"
/>
)
})
const DrawerHeader = React.forwardRef<
React.ComponentRef<typeof UIDrawer.Header>,
IDrawerHeaderProps
>(({ className, ...props }, ref) => {
return (
<UIDrawer.Header
ref={ref}
{...props}
className={drawerHeaderStyle({
class: className,
})}
/>
)
})
const DrawerBody = React.forwardRef<
React.ComponentRef<typeof UIDrawer.Body>,
IDrawerBodyProps
>(({ className, ...props }, ref) => {
return (
<UIDrawer.Body
ref={ref}
{...props}
className={drawerBodyStyle({
class: className,
})}
/>
)
})
const DrawerFooter = React.forwardRef<
React.ComponentRef<typeof UIDrawer.Footer>,
IDrawerFooterProps
>(({ className, ...props }, ref) => {
return (
<UIDrawer.Footer
ref={ref}
{...props}
className={drawerFooterStyle({
class: className,
})}
/>
)
})
const DrawerCloseButton = React.forwardRef<
React.ComponentRef<typeof UIDrawer.CloseButton>,
IDrawerCloseButtonProps
>(({ className, ...props }, ref) => {
return (
<UIDrawer.CloseButton
ref={ref}
{...props}
className={drawerCloseButtonStyle({
class: className,
})}
/>
)
})
Drawer.displayName = 'Drawer'
DrawerBackdrop.displayName = 'DrawerBackdrop'
DrawerContent.displayName = 'DrawerContent'
DrawerHeader.displayName = 'DrawerHeader'
DrawerBody.displayName = 'DrawerBody'
DrawerFooter.displayName = 'DrawerFooter'
DrawerCloseButton.displayName = 'DrawerCloseButton'
export {
Drawer,
DrawerBackdrop,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerFooter,
DrawerHeader,
}