u
This commit is contained in:
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,
|
||||
}
|
||||
Reference in new issue
Block a user