This commit is contained in:
oneao committed 2026-02-03 22:19:45 +08:00
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, zIndex: 0,
elevation: 24, elevation: 24,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 100 }, shadowOffset: { width: 0, height: 20 },
borderTopWidth: 1,
borderTopColor: '#f2f3f5',
}} }}
> >
<TouchableOpacity className="w-12 items-center justify-center"> <TouchableOpacity className="w-12 items-center justify-center">
+18 -75
View File
@@ -1,85 +1,28 @@
import SegmentedControl from '@react-native-segmented-control/segmented-control' import SegmentedControl from '@react-native-segmented-control/segmented-control'
import React, { useState } from 'react' import React, { useState } from 'react'
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native' 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() { export default function DemoDatePickerUI() {
const [index, setIndex] = useState(0) const [show, setShow] = useState(false)
return ( return (
<View style={{ flex: 1, backgroundColor: '#f3f4f6', padding: 16 }}> <View className="flex-1">
{/* 快捷选项栏 */} <AButton
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ marginBottom: 12 }}> onPress={() => {
<ATab setShow(true)
data={[ }}
{ className="mt-20"
label: '不限', >
key: '不限', 测试
}, </AButton>
{ <ADateMorePicker
label: '今天', visible={show}
key: '今天', onClose={() => {
}, setShow(false)
{ }}
label: '本月', >
key: '本月', </ADateMorePicker>
},
{
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> </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, category: category.id,
})) }))
}} }}
/> />
<ADrawer <ADrawer
visible={showActionsMenu} visible={showActionsMenu}
@@ -217,7 +217,7 @@ export default function Money() {
setShowActionsMenu(false) setShowActionsMenu(false)
}} }}
menuPosition="right" menuPosition="right"
height="60%" width="60%"
showHeader={false} showHeader={false}
> >
<SafeAreaView className="flex-col px-3"> <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 dayjs from 'dayjs'
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { Animated, Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native' import { Animated, Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
import { AButton, ATab } from '@/components/common/ao'
import { toDate } from '@/utils/time' import { toDate } from '@/utils/time'
// 定义 mode 类型 // 定义 mode 类型
@@ -87,7 +86,7 @@ export default function ADatePicker({
<View className="px-3 pt-2 flex flex-col gap-2"> <View className="px-3 pt-2 flex flex-col gap-2">
<View className="w-full"> <View className="w-full">
<Text>快速选择</Text> <Text>快速选择</Text>
<ATab {/* <ATab
data={[ data={[
{ {
label: '自定义', label: '自定义',
@@ -117,7 +116,7 @@ export default function ADatePicker({
onChange={(key) => { onChange={(key) => {
setMode('datetime') setMode('datetime')
}} }}
/> /> */}
</View> </View>
<View className="w-full"> <View className="w-full">
@@ -150,8 +149,8 @@ export default function ADatePicker({
/> />
<View className="flex flex-row gap-4 px-3 pb-1 v"> <View className="flex flex-row gap-4 px-3 pb-1 v">
<AButton variant="outline" className="flex-1">取消</AButton> {/* <AButton variant="outline" className="flex-1">取消</AButton>
<AButton className="flex-1">确认</AButton> <AButton className="flex-1">确认</AButton> */}
</View> </View>
</Animated.View> </Animated.View>
</Modal> </Modal>
@@ -1,14 +1,16 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import type { DimensionValue, ViewStyle } from 'react-native' import type { ViewStyle } from 'react-native'
import React, { useEffect, useMemo, useState } from 'react' import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Dimensions, StyleSheet, Text, View } from 'react-native' import {
import Animated, { Animated,
interpolate, Dimensions,
runOnJS, Modal,
useAnimatedStyle, StyleSheet,
useSharedValue, Text,
withTiming, TouchableOpacity,
} from 'react-native-reanimated' View,
} from 'react-native'
import LucideIcon from './LucideIcon' import LucideIcon from './LucideIcon'
const SCREEN = Dimensions.get('screen') const SCREEN = Dimensions.get('screen')
@@ -17,13 +19,13 @@ interface DrawerMenuProps {
visible: boolean visible: boolean
duration?: number duration?: number
menuPosition?: 'left' | 'right' | 'top' | 'bottom' menuPosition?: 'left' | 'right' | 'top' | 'bottom'
height?: DimensionValue height?: number | string
width?: number | string
hideModal: () => void hideModal: () => void
children?: ReactNode children?: ReactNode
header?: ReactNode header?: ReactNode
isCircle?: boolean isCircle?: boolean
CircleNum?: number CircleNum?: number
isShadowHideModal?: boolean
title?: string title?: string
showHeader?: boolean showHeader?: boolean
} }
@@ -31,134 +33,189 @@ interface DrawerMenuProps {
export default function Drawer({ export default function Drawer({
visible, visible,
height = 300, height = 300,
width = 300,
hideModal, hideModal,
duration = 300, duration = 300,
isCircle, isCircle,
menuPosition = 'bottom', menuPosition = 'bottom',
children, children,
CircleNum = 12, CircleNum = 12,
isShadowHideModal = true,
header, header,
title, title,
showHeader = true, showHeader = true,
}: DrawerMenuProps) { }: DrawerMenuProps) {
const progress = useSharedValue(0) const fadeAnim = useRef(new Animated.Value(0)).current
const drag = useSharedValue(0) const slideAnim = useRef(new Animated.Value(0)).current
const [innerVisible, setInnerVisible] = useState(false) const [innerVisible, setInnerVisible] = useState(false)
/* ================= 显隐 ================= */ /* ================= 尺寸计算 ================= */
useEffect(() => { const drawerHeight = useMemo(() => {
if (visible) { if (typeof height === 'string' && height.endsWith('%')) {
setInnerVisible(true) return (Number.parseFloat(height) / 100) * SCREEN.height
progress.value = withTiming(1, { duration })
} }
else { return Number(height)
progress.value = withTiming(0, { duration }, () => { }, [height])
runOnJS(setInnerVisible)(false)
}) const drawerWidth = useMemo(() => {
if (typeof width === 'string' && width.endsWith('%')) {
return (Number.parseFloat(width) / 100) * SCREEN.width
} }
}, [visible]) return Number(width)
}, [width])
/* ================= Drawer 尺寸 ================= */ /* ================= Drawer 尺寸 ================= */
const drawerBox = useMemo<ViewStyle>(() => { const drawerBox = useMemo<ViewStyle>(() => {
switch (menuPosition) { switch (menuPosition) {
case 'left': case 'left':
return { width: height, left: 0, top: 0, bottom: 0 } return { width: drawerWidth, left: 0, top: 0, bottom: 0 }
case 'right': case 'right':
return { width: height, right: 0, top: 0, bottom: 0 } return { width: drawerWidth, right: 0, top: 0, bottom: 0 }
case 'top': case 'top':
return { height, left: 0, right: 0, top: 0 } return { height: drawerHeight, left: 0, right: 0, top: 0 }
case 'bottom': case 'bottom':
default: 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) { switch (menuPosition) {
case 'bottom': { case 'left':
const y = interpolate(progress.value, [0, 1], [SCREEN.height, 0], 'clamp') case 'right':
return { transform: [{ translateY: y + drag.value }] } return { transform: [{ translateX: slideAnim }] }
} default:
case 'top': { return { transform: [{ translateY: slideAnim }] }
const y = interpolate(progress.value, [0, 1], [-SCREEN.height, 0], 'clamp') }
return { transform: [{ translateY: y }] } }, [menuPosition, slideAnim])
}
case 'left': { /* ================= 显隐动画(关键修改点) ================= */
const x = interpolate(progress.value, [0, 1], [-SCREEN.width, 0], 'clamp') useEffect(() => {
return { transform: [{ translateX: x }] } let initialValue = 0
}
case 'right': { switch (menuPosition) {
const x = interpolate(progress.value, [0, 1], [SCREEN.width, 0], 'clamp') case 'bottom':
return { transform: [{ translateX: x }] } 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: default:
return {} return {}
} }
}) }, [menuPosition, isCircle, CircleNum])
const maskStyle = useAnimatedStyle(() => ({
opacity: interpolate(progress.value, [0, 1], [0, 0.45], 'clamp'),
}))
if (!innerVisible)
return null
return ( return (
<View style={StyleSheet.absoluteFill} pointerEvents="box-none" className="z-[99]"> <Modal
{/* 遮罩 */} visible={innerVisible}
<Animated.View transparent
style={[styles.mask, maskStyle]} animationType="none"
onTouchEnd={() => isShadowHideModal && hideModal()} onRequestClose={hideModal}
/> >
<View style={StyleSheet.absoluteFill} pointerEvents="box-none">
{/* Drawer */} {/* 遮罩 */}
<View style={[styles.drawerBox, drawerBox]}> <Animated.View style={[styles.overlay, { opacity: fadeAnim }]}>
<Animated.View <TouchableOpacity style={{ flex: 1 }} activeOpacity={1} onPress={hideModal} />
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}
</Animated.View> </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>
</View> </Modal>
) )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
mask: { overlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
backgroundColor: '#000', backgroundColor: 'rgba(0,0,0,0.5)',
}, },
drawerBox: { drawerBox: {
position: 'absolute', position: 'absolute',
}, },
drawer: {
flex: 1,
backgroundColor: '#fff',
},
headTitle: { headTitle: {
padding: 12, padding: 12,
alignItems: 'center', alignItems: 'center',
@@ -173,4 +230,12 @@ const styles = StyleSheet.create({
right: 16, right: 16,
top: 12, 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 type {
import { Pressable, ScrollView, Text, View } from 'react-native' LayoutChangeEvent,
import PagerView from 'react-native-pager-view' } from 'react-native'
import React, { useEffect, useRef, useState } from 'react'
import {
NativeScrollEvent,
NativeSyntheticEvent,
ScrollView,
Text,
TouchableWithoutFeedback,
View,
} from 'react-native'
interface TabItem { interface TabItem {
label: string label: string
@@ -22,93 +31,93 @@ export default function Tab({
if (!data || data.length === 0) if (!data || data.length === 0)
return null return null
// 默认选中 key(没传就用第一个) /* ================= state ================= */
const initialKey = defaultKey ?? data[0].key const initialKey = defaultKey ?? data[0].key
const [selectedKey, setSelectedKey] = useState(initialKey) const [selectedKey, setSelectedKey] = useState(initialKey)
const pagerRef = useRef<PagerView>(null) /* ================= refs ================= */
const scrollRef = useRef<ScrollView>(null)
// 是否有 content // 每个 tab 的 layout 信息
const hasContent = useMemo( const itemLayouts = useRef<
() => data.some(item => item.content), Record<
[data], TabItem['key'],
) { x: number, width: number }
>
>({})
// key -> index const containerWidth = useRef(0)
const getIndexByKey = (key: TabItem['key']) =>
data.findIndex(item => item.key === key)
// index -> key
const getKeyByIndex = (index: number) =>
data[index]?.key
/* ================= handlers ================= */
const handlePress = (key: TabItem['key']) => { const handlePress = (key: TabItem['key']) => {
setSelectedKey(key) setSelectedKey(key)
onChange?.(key) onChange?.(key)
scrollToCenter(key)
if (hasContent) {
const index = getIndexByKey(key)
if (index !== -1)
pagerRef.current?.setPage(index)
}
} }
const onPageSelected = (e: any) => { const scrollToCenter = (key: TabItem['key']) => {
const index = e.nativeEvent.position const layout = itemLayouts.current[key]
const key = getKeyByIndex(index) if (!layout || !scrollRef.current)
return
if (key !== undefined) { const offset
setSelectedKey(key) = layout.x + layout.width / 2 - containerWidth.current / 2
onChange?.(key)
} scrollRef.current.scrollTo({
x: Math.max(0, offset),
animated: true,
})
} }
/* ================= effects ================= */
useEffect(() => {
// 首次挂载后,让默认选中项居中
requestAnimationFrame(() => {
scrollToCenter(selectedKey)
})
}, [])
/* ================= render ================= */
return ( return (
<View className="flex-1"> <ScrollView
{/* tab bar */} ref={scrollRef}
<ScrollView horizontal
horizontal showsHorizontalScrollIndicator={false}
showsHorizontalScrollIndicator={false} keyboardShouldPersistTaps="handled"
contentContainerStyle={{ onLayout={(e: LayoutChangeEvent) => {
flexDirection: 'row', containerWidth.current = e.nativeEvent.layout.width
gap: 8, }}
}} contentContainerStyle={{
> flexDirection: 'row',
{data.map((item) => { gap: 8,
const isActive = item.key === selectedKey }}
return ( >
<Pressable {data.map((item) => {
key={item.key} const isActive = item.key === selectedKey
onPress={() => handlePress(item.key)}
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={ className={
isActive 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-800'
: 'items-center justify-center rounded-md px-4 py-2 bg-gray-200' : 'items-center justify-center rounded-md px-4 py-2 bg-gray-200'
} }
> >
<Text className={isActive ? 'text-white' : 'text-gray-700'}> <Text className={isActive ? 'text-white' : 'text-gray-700'}>
{item.label} {item.label}
</Text> </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> </View>
))} </TouchableWithoutFeedback>
</PagerView> )
)} })}
</View> </ScrollView>
) )
} }
@@ -3,6 +3,7 @@ import Button from './Button'
import { Cell, CellGroup } from './Cell' import { Cell, CellGroup } from './Cell'
import Checkbox from './Checkbox' import Checkbox from './Checkbox'
import DateMorePicker from './DateMorePicker'
import DatePicker from './DatePicker' import DatePicker from './DatePicker'
import DateRangePicker from './DateRangePicker' import DateRangePicker from './DateRangePicker'
import Divider from './Divider' import Divider from './Divider'
@@ -22,6 +23,7 @@ export {
Cell as ACell, Cell as ACell,
CellGroup as ACellGroup, CellGroup as ACellGroup,
Checkbox as ACheckbox, Checkbox as ACheckbox,
DateMorePicker as ADateMorePicker,
DatePicker as ADatePicker, DatePicker as ADatePicker,
DateRangePicker as ADateRangePicker, DateRangePicker as ADateRangePicker,
Divider as ADivider, Divider as ADivider,