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