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,
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">
+18 -75
View File
@@ -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,