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,112 @@
import type { ViewStyle } from 'react-native'
import { Stack, useRouter } from 'expo-router'
import React from 'react'
import { Image, Text, TouchableOpacity, View } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { LucideIcon } from '../common/ao'
export type StackScreenProps = React.ComponentProps<typeof Stack.Screen>
interface CustomHeaderProps {
showHeader?: boolean
title?: string | React.ReactNode
left?: React.ReactNode
right?: React.ReactNode
safeArea?: boolean
screenProps?: StackScreenProps
className?: string
style?: ViewStyle
}
export default function CustomHeader(props: CustomHeaderProps) {
const {
showHeader = true,
title,
left,
right,
safeArea = true,
screenProps,
className = '',
style,
} = props
const insets = useSafeAreaInsets()
const router = useRouter()
if (!showHeader) {
return (
<Stack.Screen
{...screenProps}
options={{
...screenProps?.options,
headerShown: true,
header: () => (
<View
className={`${className}`}
style={[
{ paddingTop: safeArea ? insets.top : 0 },
style,
]}
/>
),
}}
/>
)
}
return (
<Stack.Screen
{...screenProps}
options={{
...screenProps?.options,
headerShown: true,
header: () => (
<SafeAreaView
className={`${className}`}
style={[
style,
]}
>
<View
className="flex-row items-center px-2"
style={[
{ height: 48 },
]}
>
{
left || (
<View className="w-10 h-full justify-center">
<TouchableOpacity onPress={() => router.back()}>
<LucideIcon icon="ChevronLeft" size={28} clickable={false} />
</TouchableOpacity>
</View>
)
}
{/* 左侧 */}
{/* 标题 */}
<View className="flex-1 items-center justify-center">
{typeof title === 'string'
? <Text className="text-xl">{title}</Text>
: title}
</View>
{right || (
<View className="w-10 h-full items-center justify-center">
<Image
source={require('@/static/images/logo.png')}
style={{ width: 22, height: 22 }}
/>
</View>
)}
</View>
</SafeAreaView>
),
}}
/>
)
}
@@ -0,0 +1,138 @@
import type { ViewStyle } from 'react-native'
import { useEffect, useRef, useState } from 'react'
import { Animated, Text, TouchableOpacity, View } from 'react-native'
import PagerView from 'react-native-pager-view'
export interface PagerRoute {
key: string
title: string
element?: React.ReactNode
}
interface PagerTabsProps {
routes: PagerRoute[]
left?: React.ReactNode
right?: React.ReactNode
selectedIndex?: number // 外部控制选中页
onChange?: (key: string) => void
indicatorWidth?: number
className?: string
style?: ViewStyle
}
export default function PagerTabs({
routes,
left,
right,
selectedIndex,
onChange,
indicatorWidth = 38,
className,
style,
}: PagerTabsProps) {
const pagerRef = useRef<PagerView>(null)
const [index, setIndex] = useState(selectedIndex ?? 0)
const [tabWidth, setTabWidth] = useState(0)
const scrollX = useRef(new Animated.Value(selectedIndex ?? 0)).current
useEffect(() => {
if (typeof selectedIndex === 'number' && selectedIndex !== index) {
setIndex(selectedIndex)
requestAnimationFrame(() => {
if (pagerRef.current) {
pagerRef.current.setPage(selectedIndex)
}
})
}
}, [selectedIndex])
const onTabPress = (i: number) => {
pagerRef.current?.setPage(i)
}
const onPageScroll = (e: any) => {
const { position, offset } = e.nativeEvent
scrollX.setValue(position + offset)
}
const onPageSelected = (position: number) => {
setIndex(position)
if (onChange && routes[position]) {
onChange(routes[position].key)
}
}
const tabWidthPerTab = tabWidth / routes.length
const indicatorTranslateX = scrollX.interpolate({
inputRange: routes.map((_, i) => i),
outputRange: routes.map(
(_, i) => i * tabWidthPerTab + (tabWidthPerTab - indicatorWidth) / 2,
),
extrapolate: 'clamp',
})
return (
<View
className={`${className} flex-1`}
style={[style]}
>
{/* Header */}
<View className="flex flex-row items-center">
{left}
{/* Tabs */}
<View
className="flex-1 flex-row relative"
onLayout={(e) => {
const w = e.nativeEvent.layout.width
if (w !== tabWidth)
setTabWidth(w)
}}
>
{routes.map((r, i) => (
<TouchableOpacity
key={r.key}
className="flex-1 items-center justify-center py-2"
onPress={() => onTabPress(i)}
>
<Text
className={`text-lg ${index === i ? 'text-[#333] font-bold' : 'text-gray-500'}`}
>
{r.title}
</Text>
</TouchableOpacity>
))}
{tabWidth > 0 && (
<Animated.View
className="absolute bottom-0 h-0.5 bg-black"
style={{
width: indicatorWidth,
transform: [{ translateX: indicatorTranslateX }],
}}
/>
)}
</View>
{right}
</View>
{/* Pager */}
<PagerView
ref={pagerRef}
style={{ flex: 1 }}
initialPage={selectedIndex ?? 0}
onPageScroll={onPageScroll}
onPageSelected={e => onPageSelected(e.nativeEvent.position)}
>
{routes.map(r => (
<View key={r.key} className="flex-1">
{r.element ?? null}
</View>
))}
</PagerView>
</View>
)
}
@@ -0,0 +1,79 @@
import { usePathname, useRouter } from 'expo-router'
import { useRef } from 'react'
import { Image, Pressable, Text, View } from 'react-native'
import Animated, { SlideInLeft, SlideInRight } from 'react-native-reanimated'
import { SafeAreaView } from 'react-native-safe-area-context'
export interface StepLayoutItem {
path: string // 步骤路径
title?: string // 步骤标题
onBack?: () => void // 可选的返回按钮回调
showHearder?: boolean // 是否显示 header,单独放在每个步骤中
headerRight?: React.ReactNode
}
interface StepLayoutProps {
steps: StepLayoutItem[] // 步骤列表
children: React.ReactNode // 子内容
showHearder?: boolean // 控制整体 header 是否显示
}
export default function StepLayout({ steps, children, showHearder = true }: StepLayoutProps) {
const router = useRouter()
const pathname = usePathname()
// 是否能返回
const canGoBack = router.canGoBack()
// 当前步骤
const currentStep = steps.find(step => step.path === pathname) ?? steps[0]
// 是否显示header
const headerVisible = showHearder !== false && currentStep.showHearder !== false
const hasMounted = useRef(false)
const enteringAnimation = hasMounted.current
? canGoBack
? SlideInRight.duration(300)
: SlideInLeft.duration(300)
: undefined
hasMounted.current = true
return (
<SafeAreaView style={{ flex: 1 }}>
<Animated.View
key={pathname}
entering={enteringAnimation}
style={{ flex: 1 }}
>
{headerVisible && (
<View className="flex-row items-center h-12 px-2 ">
<Pressable
onPress={canGoBack ? (currentStep.onBack ?? (() => router.back())) : undefined}
style={{ opacity: canGoBack ? 1 : 0 }}
>
{/* <Ionicons name="arrow-back" size={22} /> */}
</Pressable>
<Text className="flex-1 text-center text-lg font-semibold">
{currentStep.title}
</Text>
<View className="h-full flex items-center justify-center">
{currentStep.headerRight ?? (
<Image
source={require('@/static/images/logo.png')}
style={{ width: 22, height: 22 }}
/>
)}
</View>
</View>
)}
<View style={{ flex: 1 }}>{children}</View>
</Animated.View>
</SafeAreaView>
)
}
@@ -0,0 +1,152 @@
import type { Href } from 'expo-router'
import { usePathname, useRouter } from 'expo-router'
import { TabList, Tabs, TabSlot, TabTrigger } from 'expo-router/ui'
import { icons } from 'lucide-react-native'
import { useEffect, useState } from 'react'
import { Pressable, View } from 'react-native'
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
import Animated, { useAnimatedStyle, useSharedValue, withSequence, withTiming } from 'react-native-reanimated'
import { scheduleOnRN } from 'react-native-worklets'
interface AppTabbarProps {
data: TabbarItem[]
}
export interface TabbarItem {
icon: keyof typeof icons
iconSize?: number
activeIcon?: keyof typeof icons
name: string
href?: Href
onPress?: () => void
}
function TabButton({
icon,
activeIcon,
iconSize = 24,
isFocused = false,
onPress,
}: {
icon: keyof typeof icons
activeIcon?: keyof typeof icons
iconSize?: number
isFocused?: boolean
onPress?: () => void
}) {
const scale = useSharedValue(1)
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}))
useEffect(() => {
if (isFocused) {
scale.value = withSequence(
withTiming(0.9, { duration: 120 }),
withTiming(1, { duration: 160 }),
)
}
}, [isFocused])
const IconComponent = icons[isFocused && activeIcon ? activeIcon : icon]
return (
<Pressable
onPress={onPress}
style={{ flex: 1, alignItems: 'center', justifyContent: 'center', height: '100%' }}
>
<Animated.View style={animatedStyle}>
<IconComponent width={iconSize} height={iconSize} stroke={isFocused ? '#333' : '#aaa'} />
</Animated.View>
</Pressable>
)
}
export default function CustomTabbar({ data }: AppTabbarProps) {
const router = useRouter()
const pathname = usePathname()
const [activeTab, setActiveTab] = useState<string>(() => {
const match = data.find(item => item.href === pathname)
return match?.name ?? data[0]?.name
})
// 反转方向
const [swipeDirection, setSwipeDirection] = useState<'left' | 'right'>('left')
function updateSwipeDirection(tabName: string) {
const currentIndex = data.findIndex(d => d.name === activeTab)
const targetIndex = data.findIndex(d => d.name === tabName)
if (currentIndex === -1 || targetIndex === -1 || currentIndex === targetIndex)
return
setSwipeDirection(targetIndex > currentIndex ? 'left' : 'right')
setActiveTab(tabName)
}
// 点击调用
const handlPressTab = (item: TabbarItem) => {
updateSwipeDirection(item.name)
setActiveTab(item.name)
if (typeof item.onPress === 'function')
item.onPress()
}
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Tabs>
<View style={{ flex: 1 }}>
{(() => {
const currentTab = data.find(d => d.name === activeTab)
if (!currentTab)
return null
return (
<Animated.View
style={{
flex: 1,
backgroundColor: 'transparent',
}}
>
<TabSlot />
</Animated.View>
)
})()}
</View>
<TabList asChild>
<View style={{ flexDirection: 'row', height: 48, alignItems: 'center' }}>
{data.map((item) => {
if (item.href) {
return (
<TabTrigger key={item.name} name={item.name} href={item.href} asChild>
<TabButton
icon={item.icon}
activeIcon={item.activeIcon}
iconSize={item.iconSize}
isFocused={activeTab === item.name}
onPress={() => handlPressTab(item)}
/>
</TabTrigger>
)
}
return (
<TabButton
key={item.name}
icon={item.icon}
activeIcon={item.activeIcon}
iconSize={item.iconSize}
isFocused={activeTab === item.name}
onPress={() => handlPressTab(item)}
/>
)
})}
</View>
</TabList>
</Tabs>
</GestureHandlerRootView>
)
}