u
This commit is contained in:
1 parent
7a9a22e02e
commit
ba9ce9e81b
8 files changed
+377
-248
No files matched your search
@@ -86,7 +86,9 @@ export default function TabsLayout() {
|
||||
zIndex: 0,
|
||||
elevation: 24,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 100 },
|
||||
shadowOffset: { width: 0, height: 20 },
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#f2f3f5',
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity className="w-12 items-center justify-center">
|
||||
|
||||
@@ -1,85 +1,28 @@
|
||||
import SegmentedControl from '@react-native-segmented-control/segmented-control'
|
||||
import React, { useState } from 'react'
|
||||
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
|
||||
import { ATab } from '@/components/common/ao'
|
||||
import { AButton, ADateMorePicker, ATab } from '@/components/common/ao'
|
||||
|
||||
export default function DemoDatePickerUI() {
|
||||
const [index, setIndex] = useState(0)
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#f3f4f6', padding: 16 }}>
|
||||
{/* 快捷选项栏 */}
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ marginBottom: 12 }}>
|
||||
<ATab
|
||||
data={[
|
||||
{
|
||||
label: '不限',
|
||||
key: '不限',
|
||||
},
|
||||
{
|
||||
label: '今天',
|
||||
key: '今天',
|
||||
},
|
||||
{
|
||||
label: '本月',
|
||||
key: '本月',
|
||||
},
|
||||
{
|
||||
label: '本年',
|
||||
key: '本年',
|
||||
},
|
||||
|
||||
]}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
{/* SegmentedControl */}
|
||||
<SegmentedControl
|
||||
values={['时间', '日', '月', '年', '范围']}
|
||||
selectedIndex={index}
|
||||
onChange={event => setIndex(event.nativeEvent.selectedSegmentIndex)}
|
||||
style={styles.segmentedControl}
|
||||
sliderStyle={styles.sliderStyle}
|
||||
/>
|
||||
|
||||
{/* 日期选择器占位 */}
|
||||
<View style={styles.datePickerPlaceholder}>
|
||||
<Text style={{ color: '#888' }}>这里显示日期选择器</Text>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<AButton
|
||||
onPress={() => {
|
||||
setShow(true)
|
||||
}}
|
||||
className="mt-20"
|
||||
>
|
||||
测试
|
||||
</AButton>
|
||||
<ADateMorePicker
|
||||
visible={show}
|
||||
onClose={() => {
|
||||
setShow(false)
|
||||
}}
|
||||
>
|
||||
</ADateMorePicker>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
quickOption: {
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ccc',
|
||||
marginRight: 8,
|
||||
height: 32,
|
||||
},
|
||||
quickOptionText: {
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
},
|
||||
segmentedControl: {
|
||||
borderRadius: 0,
|
||||
height: 36,
|
||||
backgroundColor: '#e5e7eb',
|
||||
marginBottom: 12,
|
||||
},
|
||||
sliderStyle: {
|
||||
borderRadius: 0,
|
||||
backgroundColor: '#2196f3',
|
||||
},
|
||||
datePickerPlaceholder: {
|
||||
height: 200,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 6,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
})
|
||||
@@ -209,7 +209,7 @@ export default function Money() {
|
||||
category: category.id,
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
|
||||
<ADrawer
|
||||
visible={showActionsMenu}
|
||||
@@ -217,7 +217,7 @@ export default function Money() {
|
||||
setShowActionsMenu(false)
|
||||
}}
|
||||
menuPosition="right"
|
||||
height="60%"
|
||||
width="60%"
|
||||
showHeader={false}
|
||||
>
|
||||
<SafeAreaView className="flex-col px-3">
|
||||
|
||||
@@ -1,3 +1,112 @@
|
||||
export default function ADateMorePicker() {
|
||||
import SegmentedControl from '@react-native-segmented-control/segmented-control'
|
||||
import { DateRangePicker } from '@yz1311/react-native-wheel-picker'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Text, View } from 'react-native'
|
||||
import Button from './Button'
|
||||
import Drawer from './Drawer'
|
||||
import Tab from './Tab'
|
||||
|
||||
export type DatePickerMode = 'year' | 'month' | 'date' | 'time' | 'datetime'
|
||||
export type DateFormatType
|
||||
= | 'YYYY'
|
||||
| 'YYYY-MM'
|
||||
| 'YYYY-MM-DD'
|
||||
| 'HH:mm:ss'
|
||||
| 'YYYY-MM-DD HH:mm:ss'
|
||||
|
||||
interface DateMorePickerProps {
|
||||
startDate?: Date | string
|
||||
endDate?: Date | string
|
||||
visible?: boolean
|
||||
onClose?: () => void
|
||||
onConfirm?: (value: string) => void
|
||||
mode?: DatePickerMode
|
||||
format?: DateFormatType
|
||||
}
|
||||
|
||||
export default function DateMorePicker({
|
||||
visible,
|
||||
onClose,
|
||||
}: DateMorePickerProps) {
|
||||
const [localVisible, setLocalVisible] = useState(visible || false)
|
||||
const [index, setIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
setLocalVisible(!!visible)
|
||||
}, [visible])
|
||||
|
||||
function handleClose() {
|
||||
setLocalVisible(false)
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
visible={localVisible}
|
||||
hideModal={handleClose}
|
||||
showHeader={false}
|
||||
height="60%"
|
||||
>
|
||||
{/* 外层必须 flex:1 */}
|
||||
<View className="flex-1 p-3">
|
||||
{/* ===== 顶部固定区域 ===== */}
|
||||
<View
|
||||
className="gap-2"
|
||||
style={{ flexShrink: 0, height: 200 }}
|
||||
>
|
||||
<View>
|
||||
<Text className="text-lg mb-1">快速选择</Text>
|
||||
<Tab
|
||||
data={[
|
||||
{ label: '自定义', key: '自定义' },
|
||||
{ label: '不限', key: '不限' },
|
||||
{ label: '今天', key: '今天' },
|
||||
{ label: '本月', key: '本月' },
|
||||
{ label: '本年', key: '本年' },
|
||||
{ label: '范围', key: '范围' },
|
||||
]}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="text-lg mb-1">选择模式</Text>
|
||||
<SegmentedControl
|
||||
values={['日', '月', '年', '时间', '日期']}
|
||||
selectedIndex={index}
|
||||
style={{ width: '100%', borderRadius: 2 }}
|
||||
sliderStyle={{ borderRadius: 2 }}
|
||||
onChange={(e: any) =>
|
||||
setIndex(e.nativeEvent.selectedSegmentIndex)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* ===== 中间:吃掉剩余高度 ===== */}
|
||||
<View style={{ flex: 1, justifyContent: 'center' }}>
|
||||
<DateRangePicker
|
||||
isModal={false}
|
||||
showHeader={false}
|
||||
pickerWrapperStyle={{
|
||||
height: 24,
|
||||
minHeight: 24,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* ===== 底部固定按钮 ===== */}
|
||||
<View
|
||||
className="flex-row gap-3 pt-2"
|
||||
style={{ flexShrink: 0, height: 100 }}
|
||||
>
|
||||
<Button className="flex-1" variant="outline" onPress={handleClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button className="flex-1">
|
||||
确认
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { DatePicker } from '@yz1311/react-native-wheel-picker'
|
||||
import dayjs from 'dayjs'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Animated, Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
|
||||
import { AButton, ATab } from '@/components/common/ao'
|
||||
import { toDate } from '@/utils/time'
|
||||
|
||||
// 定义 mode 类型
|
||||
@@ -87,7 +86,7 @@ export default function ADatePicker({
|
||||
<View className="px-3 pt-2 flex flex-col gap-2">
|
||||
<View className="w-full">
|
||||
<Text>快速选择</Text>
|
||||
<ATab
|
||||
{/* <ATab
|
||||
data={[
|
||||
{
|
||||
label: '自定义',
|
||||
@@ -117,7 +116,7 @@ export default function ADatePicker({
|
||||
onChange={(key) => {
|
||||
setMode('datetime')
|
||||
}}
|
||||
/>
|
||||
/> */}
|
||||
</View>
|
||||
|
||||
<View className="w-full">
|
||||
@@ -150,8 +149,8 @@ export default function ADatePicker({
|
||||
/>
|
||||
|
||||
<View className="flex flex-row gap-4 px-3 pb-1 v">
|
||||
<AButton variant="outline" className="flex-1">取消</AButton>
|
||||
<AButton className="flex-1">确认</AButton>
|
||||
{/* <AButton variant="outline" className="flex-1">取消</AButton>
|
||||
<AButton className="flex-1">确认</AButton> */}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { DimensionValue, ViewStyle } from 'react-native'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Dimensions, StyleSheet, Text, View } from 'react-native'
|
||||
import Animated, {
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import type { ViewStyle } from 'react-native'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
Modal,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
|
||||
import LucideIcon from './LucideIcon'
|
||||
|
||||
const SCREEN = Dimensions.get('screen')
|
||||
@@ -17,13 +19,13 @@ interface DrawerMenuProps {
|
||||
visible: boolean
|
||||
duration?: number
|
||||
menuPosition?: 'left' | 'right' | 'top' | 'bottom'
|
||||
height?: DimensionValue
|
||||
height?: number | string
|
||||
width?: number | string
|
||||
hideModal: () => void
|
||||
children?: ReactNode
|
||||
header?: ReactNode
|
||||
isCircle?: boolean
|
||||
CircleNum?: number
|
||||
isShadowHideModal?: boolean
|
||||
title?: string
|
||||
showHeader?: boolean
|
||||
}
|
||||
@@ -31,134 +33,189 @@ interface DrawerMenuProps {
|
||||
export default function Drawer({
|
||||
visible,
|
||||
height = 300,
|
||||
width = 300,
|
||||
hideModal,
|
||||
duration = 300,
|
||||
isCircle,
|
||||
menuPosition = 'bottom',
|
||||
children,
|
||||
CircleNum = 12,
|
||||
isShadowHideModal = true,
|
||||
header,
|
||||
title,
|
||||
showHeader = true,
|
||||
}: DrawerMenuProps) {
|
||||
const progress = useSharedValue(0)
|
||||
const drag = useSharedValue(0)
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current
|
||||
const slideAnim = useRef(new Animated.Value(0)).current
|
||||
|
||||
const [innerVisible, setInnerVisible] = useState(false)
|
||||
|
||||
/* ================= 显隐 ================= */
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setInnerVisible(true)
|
||||
progress.value = withTiming(1, { duration })
|
||||
/* ================= 尺寸计算 ================= */
|
||||
const drawerHeight = useMemo(() => {
|
||||
if (typeof height === 'string' && height.endsWith('%')) {
|
||||
return (Number.parseFloat(height) / 100) * SCREEN.height
|
||||
}
|
||||
else {
|
||||
progress.value = withTiming(0, { duration }, () => {
|
||||
runOnJS(setInnerVisible)(false)
|
||||
})
|
||||
return Number(height)
|
||||
}, [height])
|
||||
|
||||
const drawerWidth = useMemo(() => {
|
||||
if (typeof width === 'string' && width.endsWith('%')) {
|
||||
return (Number.parseFloat(width) / 100) * SCREEN.width
|
||||
}
|
||||
}, [visible])
|
||||
return Number(width)
|
||||
}, [width])
|
||||
|
||||
/* ================= Drawer 尺寸 ================= */
|
||||
const drawerBox = useMemo<ViewStyle>(() => {
|
||||
switch (menuPosition) {
|
||||
case 'left':
|
||||
return { width: height, left: 0, top: 0, bottom: 0 }
|
||||
return { width: drawerWidth, left: 0, top: 0, bottom: 0 }
|
||||
case 'right':
|
||||
return { width: height, right: 0, top: 0, bottom: 0 }
|
||||
return { width: drawerWidth, right: 0, top: 0, bottom: 0 }
|
||||
case 'top':
|
||||
return { height, left: 0, right: 0, top: 0 }
|
||||
return { height: drawerHeight, left: 0, right: 0, top: 0 }
|
||||
case 'bottom':
|
||||
default:
|
||||
return { height, left: 0, right: 0, bottom: 0 }
|
||||
return { height: drawerHeight, left: 0, right: 0, bottom: 0 }
|
||||
}
|
||||
}, [menuPosition, height])
|
||||
}, [menuPosition, drawerHeight, drawerWidth])
|
||||
|
||||
/* ================= 动画样式(关键) ================= */
|
||||
const drawerStyle = useAnimatedStyle(() => {
|
||||
/* ================= 动画方向 ================= */
|
||||
const transformStyle = useMemo(() => {
|
||||
switch (menuPosition) {
|
||||
case 'bottom': {
|
||||
const y = interpolate(progress.value, [0, 1], [SCREEN.height, 0], 'clamp')
|
||||
return { transform: [{ translateY: y + drag.value }] }
|
||||
}
|
||||
case 'top': {
|
||||
const y = interpolate(progress.value, [0, 1], [-SCREEN.height, 0], 'clamp')
|
||||
return { transform: [{ translateY: y }] }
|
||||
}
|
||||
case 'left': {
|
||||
const x = interpolate(progress.value, [0, 1], [-SCREEN.width, 0], 'clamp')
|
||||
return { transform: [{ translateX: x }] }
|
||||
}
|
||||
case 'right': {
|
||||
const x = interpolate(progress.value, [0, 1], [SCREEN.width, 0], 'clamp')
|
||||
return { transform: [{ translateX: x }] }
|
||||
}
|
||||
case 'left':
|
||||
case 'right':
|
||||
return { transform: [{ translateX: slideAnim }] }
|
||||
default:
|
||||
return { transform: [{ translateY: slideAnim }] }
|
||||
}
|
||||
}, [menuPosition, slideAnim])
|
||||
|
||||
/* ================= 显隐动画(关键修改点) ================= */
|
||||
useEffect(() => {
|
||||
let initialValue = 0
|
||||
|
||||
switch (menuPosition) {
|
||||
case 'bottom':
|
||||
initialValue = drawerHeight
|
||||
break
|
||||
case 'top':
|
||||
initialValue = -drawerHeight
|
||||
break
|
||||
case 'left':
|
||||
initialValue = -drawerWidth
|
||||
break
|
||||
case 'right':
|
||||
initialValue = drawerWidth
|
||||
break
|
||||
}
|
||||
|
||||
if (visible) {
|
||||
setInnerVisible(true)
|
||||
|
||||
// ⚠️ 关键:等 Animated.View mount 之后,再初始化位置
|
||||
requestAnimationFrame(() => {
|
||||
fadeAnim.setValue(0)
|
||||
slideAnim.setValue(initialValue)
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start()
|
||||
})
|
||||
}
|
||||
else {
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: initialValue,
|
||||
duration,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => {
|
||||
setInnerVisible(false)
|
||||
})
|
||||
}
|
||||
}, [visible, drawerHeight, drawerWidth, menuPosition, duration])
|
||||
|
||||
/* ================= 圆角 ================= */
|
||||
const borderRadiusStyle = useMemo(() => {
|
||||
if (!isCircle)
|
||||
return {}
|
||||
switch (menuPosition) {
|
||||
case 'bottom':
|
||||
return { borderTopLeftRadius: CircleNum, borderTopRightRadius: CircleNum }
|
||||
case 'top':
|
||||
return { borderBottomLeftRadius: CircleNum, borderBottomRightRadius: CircleNum }
|
||||
case 'left':
|
||||
return { borderTopRightRadius: CircleNum, borderBottomRightRadius: CircleNum }
|
||||
case 'right':
|
||||
return { borderTopLeftRadius: CircleNum, borderBottomLeftRadius: CircleNum }
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
const maskStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(progress.value, [0, 1], [0, 0.45], 'clamp'),
|
||||
}))
|
||||
|
||||
|
||||
if (!innerVisible)
|
||||
return null
|
||||
}, [menuPosition, isCircle, CircleNum])
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFill} pointerEvents="box-none" className="z-[99]">
|
||||
{/* 遮罩 */}
|
||||
<Animated.View
|
||||
style={[styles.mask, maskStyle]}
|
||||
onTouchEnd={() => isShadowHideModal && hideModal()}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<View style={[styles.drawerBox, drawerBox]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.drawer,
|
||||
drawerStyle,
|
||||
{
|
||||
borderTopLeftRadius: isCircle ? CircleNum : 0,
|
||||
borderTopRightRadius: isCircle ? CircleNum : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{showHeader && (
|
||||
header ?? (
|
||||
<View style={styles.headTitle}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<LucideIcon
|
||||
icon="X"
|
||||
size={22}
|
||||
style={styles.close}
|
||||
onPress={hideModal}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
)}
|
||||
|
||||
{children}
|
||||
<Modal
|
||||
visible={innerVisible}
|
||||
transparent
|
||||
animationType="none"
|
||||
onRequestClose={hideModal}
|
||||
>
|
||||
<View style={StyleSheet.absoluteFill} pointerEvents="box-none">
|
||||
{/* 遮罩 */}
|
||||
<Animated.View style={[styles.overlay, { opacity: fadeAnim }]}>
|
||||
<TouchableOpacity style={{ flex: 1 }} activeOpacity={1} onPress={hideModal} />
|
||||
</Animated.View>
|
||||
|
||||
{/* Drawer */}
|
||||
<View style={[styles.drawerBox, drawerBox]}>
|
||||
{innerVisible && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.pickerContainer,
|
||||
transformStyle,
|
||||
borderRadiusStyle,
|
||||
]}
|
||||
>
|
||||
{showHeader && (
|
||||
header ?? (
|
||||
<View style={styles.headTitle}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<LucideIcon icon="X" size={22} style={styles.close} onPress={hideModal} />
|
||||
</View>
|
||||
)
|
||||
)}
|
||||
|
||||
{children}
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
mask: {
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: '#000',
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
drawerBox: {
|
||||
position: 'absolute',
|
||||
},
|
||||
drawer: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
headTitle: {
|
||||
padding: 12,
|
||||
alignItems: 'center',
|
||||
@@ -173,4 +230,12 @@ const styles = StyleSheet.create({
|
||||
right: 16,
|
||||
top: 12,
|
||||
},
|
||||
pickerContainer: {
|
||||
backgroundColor: '#fff',
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
})
|
||||
@@ -1,6 +1,15 @@
|
||||
import React, { useMemo, useRef, useState } from 'react'
|
||||
import { Pressable, ScrollView, Text, View } from 'react-native'
|
||||
import PagerView from 'react-native-pager-view'
|
||||
import type {
|
||||
LayoutChangeEvent,
|
||||
} from 'react-native'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
ScrollView,
|
||||
Text,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from 'react-native'
|
||||
|
||||
interface TabItem {
|
||||
label: string
|
||||
@@ -22,93 +31,93 @@ export default function Tab({
|
||||
if (!data || data.length === 0)
|
||||
return null
|
||||
|
||||
// 默认选中 key(没传就用第一个)
|
||||
/* ================= state ================= */
|
||||
const initialKey = defaultKey ?? data[0].key
|
||||
const [selectedKey, setSelectedKey] = useState(initialKey)
|
||||
|
||||
const pagerRef = useRef<PagerView>(null)
|
||||
/* ================= refs ================= */
|
||||
const scrollRef = useRef<ScrollView>(null)
|
||||
|
||||
// 是否有 content
|
||||
const hasContent = useMemo(
|
||||
() => data.some(item => item.content),
|
||||
[data],
|
||||
)
|
||||
// 每个 tab 的 layout 信息
|
||||
const itemLayouts = useRef<
|
||||
Record<
|
||||
TabItem['key'],
|
||||
{ x: number, width: number }
|
||||
>
|
||||
>({})
|
||||
|
||||
// key -> index
|
||||
const getIndexByKey = (key: TabItem['key']) =>
|
||||
data.findIndex(item => item.key === key)
|
||||
|
||||
// index -> key
|
||||
const getKeyByIndex = (index: number) =>
|
||||
data[index]?.key
|
||||
const containerWidth = useRef(0)
|
||||
|
||||
/* ================= handlers ================= */
|
||||
const handlePress = (key: TabItem['key']) => {
|
||||
setSelectedKey(key)
|
||||
onChange?.(key)
|
||||
|
||||
if (hasContent) {
|
||||
const index = getIndexByKey(key)
|
||||
if (index !== -1)
|
||||
pagerRef.current?.setPage(index)
|
||||
}
|
||||
scrollToCenter(key)
|
||||
}
|
||||
|
||||
const onPageSelected = (e: any) => {
|
||||
const index = e.nativeEvent.position
|
||||
const key = getKeyByIndex(index)
|
||||
const scrollToCenter = (key: TabItem['key']) => {
|
||||
const layout = itemLayouts.current[key]
|
||||
if (!layout || !scrollRef.current)
|
||||
return
|
||||
|
||||
if (key !== undefined) {
|
||||
setSelectedKey(key)
|
||||
onChange?.(key)
|
||||
}
|
||||
const offset
|
||||
= layout.x + layout.width / 2 - containerWidth.current / 2
|
||||
|
||||
scrollRef.current.scrollTo({
|
||||
x: Math.max(0, offset),
|
||||
animated: true,
|
||||
})
|
||||
}
|
||||
|
||||
/* ================= effects ================= */
|
||||
useEffect(() => {
|
||||
// 首次挂载后,让默认选中项居中
|
||||
requestAnimationFrame(() => {
|
||||
scrollToCenter(selectedKey)
|
||||
})
|
||||
}, [])
|
||||
|
||||
/* ================= render ================= */
|
||||
return (
|
||||
<View className="flex-1">
|
||||
{/* tab bar */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{data.map((item) => {
|
||||
const isActive = item.key === selectedKey
|
||||
return (
|
||||
<Pressable
|
||||
key={item.key}
|
||||
onPress={() => handlePress(item.key)}
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onLayout={(e: LayoutChangeEvent) => {
|
||||
containerWidth.current = e.nativeEvent.layout.width
|
||||
}}
|
||||
contentContainerStyle={{
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{data.map((item) => {
|
||||
const isActive = item.key === selectedKey
|
||||
|
||||
return (
|
||||
<TouchableWithoutFeedback
|
||||
key={item.key}
|
||||
onPress={() => handlePress(item.key)}
|
||||
>
|
||||
<View
|
||||
onLayout={(e) => {
|
||||
const { x, width } = e.nativeEvent.layout
|
||||
itemLayouts.current[item.key] = { x, width }
|
||||
}}
|
||||
className={
|
||||
isActive
|
||||
? 'items-center justify-center rounded-md px-4 py-2 bg-gray-800'
|
||||
: 'items-center justify-center rounded-md px-4 py-2 bg-gray-200'
|
||||
}
|
||||
>
|
||||
|
||||
<Text className={isActive ? 'text-white' : 'text-gray-700'}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
{hasContent && (
|
||||
<PagerView
|
||||
ref={pagerRef}
|
||||
style={{ flex: 1, marginTop: 16 }}
|
||||
initialPage={getIndexByKey(initialKey)}
|
||||
onPageSelected={onPageSelected}
|
||||
>
|
||||
{data.map(item => (
|
||||
<View key={item.key} style={{ flex: 1 }}>
|
||||
{item.content}
|
||||
</View>
|
||||
))}
|
||||
</PagerView>
|
||||
)}
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import Button from './Button'
|
||||
import { Cell, CellGroup } from './Cell'
|
||||
import Checkbox from './Checkbox'
|
||||
|
||||
import DateMorePicker from './DateMorePicker'
|
||||
import DatePicker from './DatePicker'
|
||||
import DateRangePicker from './DateRangePicker'
|
||||
import Divider from './Divider'
|
||||
@@ -22,6 +23,7 @@ export {
|
||||
Cell as ACell,
|
||||
CellGroup as ACellGroup,
|
||||
Checkbox as ACheckbox,
|
||||
DateMorePicker as ADateMorePicker,
|
||||
DatePicker as ADatePicker,
|
||||
DateRangePicker as ADateRangePicker,
|
||||
Divider as ADivider,
|
||||
|
||||
Reference in new issue
Block a user