u
This commit is contained in:
1 parent
069b4506e4
commit
bb780a42ab
10 files changed
+389
-246
No files matched your search
@@ -1,49 +1,140 @@
|
|||||||
import { StyleSheet, Text, View } from 'react-native'
|
import type Animated from 'react-native-reanimated'
|
||||||
|
import type { SortableGridRenderItem } from 'react-native-sortables'
|
||||||
|
import type { MoneyCategoryItem } from '@/types/money/category'
|
||||||
|
import { useCallback, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { Platform, Animated as RNAnimated, Text, Vibration, View } from 'react-native'
|
||||||
|
import { useAnimatedRef } from 'react-native-reanimated'
|
||||||
import Sortable from 'react-native-sortables'
|
import Sortable from 'react-native-sortables'
|
||||||
|
import { AButton, ASegmentedTabs, LucideIcon } from '@/components/common/ao'
|
||||||
|
import MoneyCategoryIcon from '@/components/common/MoneyCategoryIcon'
|
||||||
|
import CustomHeader from '@/components/custom/Header'
|
||||||
|
|
||||||
const DATA = [
|
// 生成 100 条假数据
|
||||||
'Poland',
|
function generateFakeCategories(): MoneyCategoryItem[] {
|
||||||
'Germany',
|
return Array.from({ length: 100 }, (_, index) => ({
|
||||||
'France',
|
id: index + 1,
|
||||||
'Italy',
|
name: `类别 ${index + 1}`,
|
||||||
'Spain',
|
icon: 'Coin', // 默认图标
|
||||||
'Portugal',
|
}))
|
||||||
'Greece',
|
}
|
||||||
'Great Britain',
|
|
||||||
'United States',
|
export default function Example() {
|
||||||
'Canada',
|
const [selectedTab, setSelectedTab] = useState(0)
|
||||||
'Australia',
|
const scrollableRef = useAnimatedRef<Animated.ScrollView>()
|
||||||
'New Zealand',
|
const deleteTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
]
|
|
||||||
|
const [categories, setCategories] = useState<MoneyCategoryItem[]>(generateFakeCategories())
|
||||||
|
const [draggingId, setDraggingId] = useState<number | null>(null)
|
||||||
|
const [isInDeleteZone, setIsInDeleteZone] = useState(false)
|
||||||
|
|
||||||
|
const renderItem = useCallback<SortableGridRenderItem<MoneyCategoryItem>>(
|
||||||
|
({ item }) => (
|
||||||
|
<View className="flex flex-col items-center justify-center">
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 46,
|
||||||
|
height: 46,
|
||||||
|
borderRadius: 28,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoneyCategoryIcon size={24} name={item.icon} />
|
||||||
|
</View>
|
||||||
|
<Text style={{ marginBottom: 4, fontSize: 14 }}>{item.name}</Text>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 拖入删除区时启动延迟删除
|
||||||
|
const handleItemEnter = () => {
|
||||||
|
if (!draggingId)
|
||||||
|
return
|
||||||
|
setIsInDeleteZone(true)
|
||||||
|
Vibration.vibrate(50)
|
||||||
|
|
||||||
|
// 延迟 2 秒删除
|
||||||
|
deleteTimeoutRef.current = setTimeout(() => {
|
||||||
|
setCategories(prev => prev.filter(c => c.id !== draggingId))
|
||||||
|
setDraggingId(null)
|
||||||
|
setIsInDeleteZone(false)
|
||||||
|
deleteTimeoutRef.current = null
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖出删除区取消删除
|
||||||
|
const handleItemLeave = () => {
|
||||||
|
setIsInDeleteZone(false)
|
||||||
|
if (deleteTimeoutRef.current) {
|
||||||
|
clearTimeout(deleteTimeoutRef.current)
|
||||||
|
deleteTimeoutRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function Flex() {
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1 }}>
|
<View className="flex-1">
|
||||||
<Sortable.Flex
|
<CustomHeader title="类别设置" />
|
||||||
gap={10}
|
|
||||||
padding={10}
|
<View className="flex-1 px-3">
|
||||||
flexDirection="column"
|
<Sortable.MultiZoneProvider>
|
||||||
width="fill"
|
<Sortable.PortalProvider enabled>
|
||||||
dragActivationDelay={10}
|
<RNAnimated.ScrollView ref={scrollableRef}>
|
||||||
>
|
|
||||||
{DATA.map(item => (
|
{/* Grid */}
|
||||||
<View style={styles.cell} key={item}>
|
<Sortable.Grid
|
||||||
<Text style={{ color: '#fff' }}>{item}</Text>
|
columnGap={10}
|
||||||
</View>
|
rowGap={10}
|
||||||
))}
|
columns={4}
|
||||||
</Sortable.Flex>
|
data={categories}
|
||||||
|
renderItem={renderItem}
|
||||||
|
scrollableRef={scrollableRef}
|
||||||
|
autoScrollActivationOffset={200}
|
||||||
|
showDropIndicator
|
||||||
|
onDragStart={data => setDraggingId(Number(data.key))}
|
||||||
|
onDragEnd={() => setDraggingId(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 删除区 */}
|
||||||
|
<Sortable.BaseZone
|
||||||
|
className="mt-4"
|
||||||
|
style={{
|
||||||
|
height: 42,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: isInDeleteZone ? '#FF6B6B' : '#FDE2E2',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
minActivationDistance={40}
|
||||||
|
onItemEnter={handleItemEnter}
|
||||||
|
onItemLeave={handleItemLeave}
|
||||||
|
>
|
||||||
|
<Text style={{
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: isInDeleteZone ? 18 : 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
拖到这里删除
|
||||||
|
</Text>
|
||||||
|
</Sortable.BaseZone>
|
||||||
|
|
||||||
|
{/* 新增按钮 */}
|
||||||
|
<View className="mt-3 mb-6 flex-row justify-end">
|
||||||
|
<AButton
|
||||||
|
className="rounded-full px-4 py-2"
|
||||||
|
leftIcon={{ icon: 'Plus', color: '#fff', size: 18 }}
|
||||||
|
>
|
||||||
|
添加分类
|
||||||
|
</AButton>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
</RNAnimated.ScrollView>
|
||||||
|
</Sortable.PortalProvider>
|
||||||
|
</Sortable.MultiZoneProvider>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
cell: {
|
|
||||||
backgroundColor: '#36877F',
|
|
||||||
borderRadius: 9999,
|
|
||||||
padding: 10,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignSelf: 'stretch', // ✅ 关键:让子元素撑满父容器宽度
|
|
||||||
width: '100%',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -5,6 +5,7 @@ import { Stack, useRouter } from 'expo-router'
|
|||||||
import * as SplashScreen from 'expo-splash-screen'
|
import * as SplashScreen from 'expo-splash-screen'
|
||||||
import { StatusBar } from 'expo-status-bar'
|
import { StatusBar } from 'expo-status-bar'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||||
import { KeyboardProvider } from 'react-native-keyboard-controller'
|
import { KeyboardProvider } from 'react-native-keyboard-controller'
|
||||||
import { MenuProvider } from 'react-native-popup-menu'
|
import { MenuProvider } from 'react-native-popup-menu'
|
||||||
import { AButton } from '@/components/common/ao'
|
import { AButton } from '@/components/common/ao'
|
||||||
@@ -15,7 +16,6 @@ import { useUserStore } from '@/store/user'
|
|||||||
import { ToastProviderWrapper } from '@/utils/toast'
|
import { ToastProviderWrapper } from '@/utils/toast'
|
||||||
import '@/styles/global.css'
|
import '@/styles/global.css'
|
||||||
import 'react-native-reanimated'
|
import 'react-native-reanimated'
|
||||||
import 'react-native-gesture-handler'
|
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync()
|
SplashScreen.preventAutoHideAsync()
|
||||||
|
|
||||||
@@ -74,25 +74,28 @@ export default function RootLayout() {
|
|||||||
<GluestackUIProvider>
|
<GluestackUIProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<ToastProviderWrapper>
|
<ToastProviderWrapper>
|
||||||
<Stack
|
<GestureHandlerRootView>
|
||||||
screenOptions={{
|
|
||||||
headerTitleAlign: 'center',
|
|
||||||
animation: 'slide_from_right',
|
|
||||||
contentStyle: {
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack.Screen
|
|
||||||
name="(tabs)"
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack.Screen name="+not-found" />
|
<Stack
|
||||||
</Stack>
|
screenOptions={{
|
||||||
<StatusBar style="auto" />
|
headerTitleAlign: 'center',
|
||||||
|
animation: 'slide_from_right',
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
name="(tabs)"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack.Screen name="+not-found" />
|
||||||
|
</Stack>
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
</GestureHandlerRootView>
|
||||||
</ToastProviderWrapper>
|
</ToastProviderWrapper>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</GluestackUIProvider>
|
</GluestackUIProvider>
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import type { SortableGridRenderItem } from 'react-native-sortables'
|
||||||
|
import type { MoneyCategoryItem } from '@/types/money/category'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Platform, Text, Vibration, View } from 'react-native'
|
||||||
|
|
||||||
|
import Animated, { useAnimatedRef } from 'react-native-reanimated'
|
||||||
|
import Sortable, { DragStartParams } from 'react-native-sortables'
|
||||||
|
import { AButton, ASegmentedTabs, LucideIcon } from '@/components/common/ao'
|
||||||
|
import MoneyCategoryIcon from '@/components/common/MoneyCategoryIcon'
|
||||||
|
import CustomHeader from '@/components/custom/Header'
|
||||||
|
import { useMoneyCategoryStore } from '@/store/moneyCategory'
|
||||||
|
|
||||||
|
export default function Example() {
|
||||||
|
const [selectedTab, setSelectedTab] = useState(0)
|
||||||
|
const moneyCategoryStore = useMoneyCategoryStore()
|
||||||
|
const scrollableRef = useAnimatedRef<Animated.ScrollView>()
|
||||||
|
|
||||||
|
const [categories, setCategories] = useState<MoneyCategoryItem[]>([])
|
||||||
|
|
||||||
|
const [draggingId, setDraggingId] = useState<string>('')
|
||||||
|
const [draggingDeleteZone, setDraggingDeleteZone] = useState<boolean>(false)
|
||||||
|
const [deleteTimer, setDeleteTimer] = useState<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCategories(selectedTab === 0 ? moneyCategoryStore.getExpenseCategories() : moneyCategoryStore.getIncomeCategories())
|
||||||
|
}, [selectedTab])
|
||||||
|
|
||||||
|
const renderItem = useCallback<SortableGridRenderItem<MoneyCategoryItem>>(
|
||||||
|
({ item }) => (
|
||||||
|
<View
|
||||||
|
className="flex flex-col items-center justify-center"
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 46,
|
||||||
|
height: 46,
|
||||||
|
borderRadius: 28,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoneyCategoryIcon size={24} name={item.icon} />
|
||||||
|
</View>
|
||||||
|
<Text style={{
|
||||||
|
marginBottom: 4,
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1">
|
||||||
|
<CustomHeader
|
||||||
|
title="类别设置"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="flex-1">
|
||||||
|
<View className="pt-2 mb-4 px-3 flex flex-row items-center justify-between">
|
||||||
|
<ASegmentedTabs
|
||||||
|
defaultIndex={selectedTab}
|
||||||
|
onChange={(index) => {
|
||||||
|
setSelectedTab(index)
|
||||||
|
}}
|
||||||
|
tabs={[
|
||||||
|
{
|
||||||
|
label: '收入',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '支出',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
style={{
|
||||||
|
width: 140,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="flex flex-row gap-2">
|
||||||
|
<AButton
|
||||||
|
className="rounded-full"
|
||||||
|
leftIcon={{
|
||||||
|
icon: 'Plus',
|
||||||
|
className: 'mr-0',
|
||||||
|
color: '#fff',
|
||||||
|
clickable: false,
|
||||||
|
size: 18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
添加分类
|
||||||
|
</AButton>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1 px-3">
|
||||||
|
<Sortable.MultiZoneProvider>
|
||||||
|
<Sortable.PortalProvider enabled>
|
||||||
|
<Animated.ScrollView
|
||||||
|
ref={scrollableRef}
|
||||||
|
>
|
||||||
|
<Sortable.Grid
|
||||||
|
columnGap={10}
|
||||||
|
rowGap={10}
|
||||||
|
columns={4}
|
||||||
|
data={categories}
|
||||||
|
renderItem={renderItem}
|
||||||
|
scrollableRef={scrollableRef}
|
||||||
|
autoScrollActivationOffset={200}
|
||||||
|
showDropIndicator
|
||||||
|
onDragStart={(data) => {
|
||||||
|
Vibration.vibrate(50)
|
||||||
|
setDraggingId(data.key)
|
||||||
|
}}
|
||||||
|
onDragEnd={() => {
|
||||||
|
setDraggingId('')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Animated.ScrollView>
|
||||||
|
|
||||||
|
<Sortable.BaseZone
|
||||||
|
onItemEnter={() => {
|
||||||
|
if (!draggingId)
|
||||||
|
return
|
||||||
|
|
||||||
|
setDraggingDeleteZone(true)
|
||||||
|
Vibration.vibrate(50)
|
||||||
|
|
||||||
|
// 设置 1 秒后删除
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setCategories(prev => prev.filter(c => c.id !== Number(draggingId)))
|
||||||
|
setDraggingId('')
|
||||||
|
setDraggingDeleteZone(false)
|
||||||
|
setDeleteTimer(null)
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
setDeleteTimer(timer as unknown as number)
|
||||||
|
}}
|
||||||
|
onItemLeave={() => {
|
||||||
|
setDraggingDeleteZone(false)
|
||||||
|
if (deleteTimer) {
|
||||||
|
clearTimeout(deleteTimer)
|
||||||
|
setDeleteTimer(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onItemDrop={() => {
|
||||||
|
setDraggingDeleteZone(false)
|
||||||
|
if (deleteTimer) {
|
||||||
|
clearTimeout(deleteTimer)
|
||||||
|
setDeleteTimer(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
minActivationDistance={40}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`flex flex-row items-center justify-center h-12 rounded-md gap-2 ${
|
||||||
|
draggingDeleteZone ? 'bg-red-600' : 'bg-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LucideIcon icon="Trash" size={18} color="#fff" />
|
||||||
|
<Text className="text-base text-white ">
|
||||||
|
拖到这里删除
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
</Sortable.BaseZone>
|
||||||
|
|
||||||
|
<View className="flex flex-row mb-8 mt-4 w-full gap-4">
|
||||||
|
<AButton
|
||||||
|
className="flex-1"
|
||||||
|
variant="outline"
|
||||||
|
leftIcon={{
|
||||||
|
icon: 'RefreshCcw',
|
||||||
|
className: 'mr-0',
|
||||||
|
color: '#333',
|
||||||
|
clickable: false,
|
||||||
|
size: 18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
恢复
|
||||||
|
</AButton>
|
||||||
|
|
||||||
|
<AButton
|
||||||
|
className="flex-1"
|
||||||
|
leftIcon={{
|
||||||
|
icon: 'Save',
|
||||||
|
className: 'mr-0',
|
||||||
|
color: '#fff',
|
||||||
|
clickable: false,
|
||||||
|
size: 18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</AButton>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
</Sortable.PortalProvider>
|
||||||
|
</Sortable.MultiZoneProvider>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -148,7 +148,12 @@ export default function MoenyRecordEditor() {
|
|||||||
<PagerTabs
|
<PagerTabs
|
||||||
selectedIndex={selectedCategory?.type}
|
selectedIndex={selectedCategory?.type}
|
||||||
left={(
|
left={(
|
||||||
<TouchableOpacity className="w-[30%] flex items-center px-4 flex-row">
|
<TouchableOpacity
|
||||||
|
className="w-[30%] flex items-center px-4 flex-row"
|
||||||
|
onPress={() => {
|
||||||
|
router.navigate('/money/category/setting')
|
||||||
|
}}
|
||||||
|
>
|
||||||
<LucideIcon icon="LayoutList" />
|
<LucideIcon icon="LayoutList" />
|
||||||
<Text className="ml-1">管理</Text>
|
<Text className="ml-1">管理</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import FormItem from './FormItem'
|
|||||||
import Input from './Input'
|
import Input from './Input'
|
||||||
import LucideIcon from './LucideIcon'
|
import LucideIcon from './LucideIcon'
|
||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
|
import SegmentedTabs from './SegmentedTabs'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Button as AButton,
|
Button as AButton,
|
||||||
@@ -19,5 +20,6 @@ export {
|
|||||||
FormRef as AFormRef,
|
FormRef as AFormRef,
|
||||||
Input as AInput,
|
Input as AInput,
|
||||||
Modal as AModal,
|
Modal as AModal,
|
||||||
|
SegmentedTabs as ASegmentedTabs,
|
||||||
LucideIcon,
|
LucideIcon,
|
||||||
}
|
}
|
||||||
@@ -70,14 +70,17 @@ export default function CustomHeader(props: CustomHeaderProps) {
|
|||||||
style,
|
style,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
|
||||||
|
{
|
||||||
|
left || (
|
||||||
|
<View className="w-10 h-full justify-center">
|
||||||
|
<TouchableOpacity onPress={() => router.back()}>
|
||||||
|
<LucideIcon icon="ArrowLeft" size={24} clickable={false} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
{/* 左侧 */}
|
{/* 左侧 */}
|
||||||
<View className="w-10 h-full justify-center">
|
|
||||||
{left || (
|
|
||||||
<TouchableOpacity onPress={() => router.back()}>
|
|
||||||
<LucideIcon icon="ArrowLeft" size={24} clickable={false} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 标题 */}
|
{/* 标题 */}
|
||||||
<View className="flex-1 items-center justify-center">
|
<View className="flex-1 items-center justify-center">
|
||||||
@@ -86,15 +89,15 @@ export default function CustomHeader(props: CustomHeaderProps) {
|
|||||||
: title}
|
: title}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 右侧 */}
|
{right || (
|
||||||
<View className="w-10 h-full items-center justify-center">
|
<View className="w-10 h-full items-center justify-center">
|
||||||
{right || (
|
|
||||||
<Image
|
<Image
|
||||||
source={require('@/static/images/logo.png')}
|
source={require('@/static/images/logo.png')}
|
||||||
style={{ width: 22, height: 22 }}
|
style={{ width: 22, height: 22 }}
|
||||||
/>
|
/>
|
||||||
)}
|
</View>
|
||||||
</View>
|
)}
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
import type { LayoutChangeEvent } from 'react-native'
|
|
||||||
import React, { createContext, useContext, useEffect, useRef, useState } from 'react'
|
|
||||||
import {
|
|
||||||
Animated,
|
|
||||||
LayoutChangeEvent as RNLayoutChangeEvent,
|
|
||||||
ScrollView,
|
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from 'react-native'
|
|
||||||
import PagerView from 'react-native-pager-view'
|
|
||||||
|
|
||||||
interface TabsContextType {
|
|
||||||
pagerRef: React.RefObject<PagerView>
|
|
||||||
scrollX: Animated.Value
|
|
||||||
index: number
|
|
||||||
setIndex: (i: number) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const TabsContext = createContext<TabsContextType | null>(null)
|
|
||||||
|
|
||||||
interface TabsProviderProps {
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TabsProvider({ children }: TabsProviderProps) {
|
|
||||||
const pagerRef = useRef<PagerView>(null!)
|
|
||||||
const scrollX = useRef(new Animated.Value(0)).current
|
|
||||||
const [index, setIndex] = useState(0)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TabsContext.Provider value={{ pagerRef, scrollX, index, setIndex }}>
|
|
||||||
{children}
|
|
||||||
</TabsContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------- Tabs (Header) ----------------
|
|
||||||
interface TabsProps {
|
|
||||||
tabs: string[]
|
|
||||||
leftContent?: ReactNode
|
|
||||||
rightContent?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
function useSafeTabsContext(): TabsContextType {
|
|
||||||
const ctx = useContext(TabsContext)
|
|
||||||
if (!ctx) {
|
|
||||||
const pagerRef = useRef<PagerView>(null!)
|
|
||||||
const scrollX = useRef(new Animated.Value(0)).current
|
|
||||||
const [index, setIndex] = useState(0)
|
|
||||||
return { pagerRef, scrollX, index, setIndex }
|
|
||||||
}
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
TabsProvider.Tabs = function Tabs({ tabs, leftContent, rightContent }: TabsProps) {
|
|
||||||
const ctx = useSafeTabsContext()
|
|
||||||
|
|
||||||
const tabLayouts = useRef<{ x: number, width: number }[]>([])
|
|
||||||
const scrollRef = useRef<ScrollView>(null)
|
|
||||||
|
|
||||||
const onTabLayout = (e: LayoutChangeEvent, i: number) => {
|
|
||||||
const { x, width } = e.nativeEvent.layout
|
|
||||||
tabLayouts.current[i] = { x, width }
|
|
||||||
}
|
|
||||||
|
|
||||||
const measured = tabLayouts.current.length === tabs.length
|
|
||||||
|
|
||||||
const underlineX = measured
|
|
||||||
? ctx.scrollX.interpolate({
|
|
||||||
inputRange: tabs.map((_, i) => i),
|
|
||||||
outputRange: tabLayouts.current.map(t => t.x),
|
|
||||||
})
|
|
||||||
: 0
|
|
||||||
|
|
||||||
const underlineWidth = measured
|
|
||||||
? ctx.scrollX.interpolate({
|
|
||||||
inputRange: tabs.map((_, i) => i),
|
|
||||||
outputRange: tabLayouts.current.map(t => t.width),
|
|
||||||
})
|
|
||||||
: 0
|
|
||||||
|
|
||||||
// 自动滚动 ScrollView 保持选中 Tab 可见
|
|
||||||
useEffect(() => {
|
|
||||||
if (!measured || !scrollRef.current)
|
|
||||||
return
|
|
||||||
const layout = tabLayouts.current[ctx.index]
|
|
||||||
if (!layout)
|
|
||||||
return
|
|
||||||
scrollRef.current.scrollTo({ x: Math.max(layout.x - 50, 0), animated: true })
|
|
||||||
}, [ctx.index, measured])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', borderBottomWidth: 1, borderColor: '#eee' }}>
|
|
||||||
{leftContent && <View>{leftContent}</View>}
|
|
||||||
|
|
||||||
<ScrollView
|
|
||||||
ref={scrollRef}
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
contentContainerStyle={{ flexDirection: 'row', position: 'relative' }}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
{tabs.map((tab, i) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={i}
|
|
||||||
onPress={() => {
|
|
||||||
ctx.pagerRef.current?.setPage(i)
|
|
||||||
ctx.setIndex(i)
|
|
||||||
}}
|
|
||||||
onLayout={e => onTabLayout(e, i)}
|
|
||||||
style={{ paddingHorizontal: 20, paddingVertical: 12 }}
|
|
||||||
>
|
|
||||||
<Text style={{ color: ctx.index === i ? '#000' : '#999' }}>{tab}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{measured && (
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
styles.underline,
|
|
||||||
{ width: underlineWidth, transform: [{ translateX: underlineX }] },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
{rightContent && <View>{rightContent}</View>}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------- Content ----------------
|
|
||||||
TabsProvider.Content = function Content({ tabs }: { tabs: string[] }) {
|
|
||||||
const ctx = useSafeTabsContext()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PagerView
|
|
||||||
ref={ctx.pagerRef}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
initialPage={0}
|
|
||||||
onPageScroll={e =>
|
|
||||||
ctx.scrollX.setValue(e.nativeEvent.position + e.nativeEvent.offset)}
|
|
||||||
onPageSelected={e => ctx.setIndex(e.nativeEvent.position)}
|
|
||||||
>
|
|
||||||
{tabs.map((tab, i) => (
|
|
||||||
<View key={i} style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
|
||||||
<Text style={{ fontSize: 20 }}>
|
|
||||||
{tab}
|
|
||||||
{' '}
|
|
||||||
页面内容
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</PagerView>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
underline: {
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 0,
|
|
||||||
height: 2,
|
|
||||||
backgroundColor: '#007AFF',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -30,8 +30,8 @@ function isWhiteListed(url: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const alovaInstance = createAlova({
|
const alovaInstance = createAlova({
|
||||||
// baseURL: 'http://192.168.5.6:8085/app',
|
baseURL: 'http://192.168.5.6:8086/app',
|
||||||
baseURL: 'http://192.168.1.136:8085/app',
|
// baseURL: 'http://192.168.1.136:8085/app',
|
||||||
requestAdapter: axiosRequestAdapter(),
|
requestAdapter: axiosRequestAdapter(),
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
cacheFor: null,
|
cacheFor: null,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface MoneyRecordBase {
|
|||||||
remark: string
|
remark: string
|
||||||
record_time: string
|
record_time: string
|
||||||
create_time: string
|
create_time: string
|
||||||
update_time: string
|
update_time?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询条件(请求参数)
|
// 查询条件(请求参数)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
server:
|
server:
|
||||||
port: 8085
|
port: 8086
|
||||||
baseUrl: '/app'
|
baseUrl: '/app'
|
||||||
|
|
||||||
snowflake:
|
snowflake:
|
||||||
|
|||||||
Reference in new issue
Block a user