403 lines
14 KiB
TypeScript
403 lines
14 KiB
TypeScript
import { useState, useRef, useEffect } from 'react'
|
||
import {
|
||
View,
|
||
Text,
|
||
StyleSheet,
|
||
Pressable,
|
||
Modal,
|
||
FlatList,
|
||
TouchableWithoutFeedback,
|
||
LayoutChangeEvent,
|
||
Platform,
|
||
useWindowDimensions,
|
||
} from 'react-native'
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||
import { DownArrowIcon } from '@/components/icon'
|
||
|
||
export interface DropdownOption<T = string> {
|
||
label: string
|
||
value: T
|
||
disabled?: boolean
|
||
}
|
||
|
||
export interface DropdownProps<T = string> {
|
||
/**
|
||
* 选项列表
|
||
*/
|
||
options: DropdownOption<T>[]
|
||
/**
|
||
* 当前选中的值
|
||
*/
|
||
value?: T
|
||
/**
|
||
* 选择回调
|
||
*/
|
||
onSelect?: (value: T, option: DropdownOption<T>) => void
|
||
/**
|
||
* 占位符文本
|
||
*/
|
||
placeholder?: string
|
||
/**
|
||
* 是否禁用
|
||
*/
|
||
disabled?: boolean
|
||
/**
|
||
* 自定义触发按钮渲染
|
||
*/
|
||
renderTrigger?: (selectedOption: DropdownOption<T> | undefined, isOpen: boolean, toggle: () => void) => React.ReactNode
|
||
/**
|
||
* 下拉列表的最大高度
|
||
*/
|
||
maxHeight?: number
|
||
/**
|
||
* 下拉菜单距离触发按钮的垂直偏移(默认4px)
|
||
*/
|
||
offsetTop?: number
|
||
/**
|
||
* 自定义样式
|
||
*/
|
||
style?: any
|
||
/**
|
||
* 触发按钮样式
|
||
*/
|
||
triggerStyle?: any
|
||
/**
|
||
* 下拉列表样式
|
||
*/
|
||
dropdownStyle?: any
|
||
/**
|
||
* 选项项样式
|
||
*/
|
||
optionStyle?: any
|
||
/**
|
||
* 选项文本样式
|
||
*/
|
||
optionTextStyle?: any
|
||
/**
|
||
* 自定义选项渲染函数
|
||
*/
|
||
renderOption?: (option: DropdownOption<T>, isSelected: boolean) => React.ReactNode
|
||
}
|
||
|
||
export default function Dropdown<T = string>({
|
||
options,
|
||
value,
|
||
onSelect,
|
||
placeholder = '请选择',
|
||
disabled = false,
|
||
renderTrigger,
|
||
maxHeight = 200,
|
||
offsetTop = 4,
|
||
style,
|
||
triggerStyle,
|
||
dropdownStyle,
|
||
optionStyle,
|
||
optionTextStyle,
|
||
renderOption,
|
||
}: DropdownProps<T>) {
|
||
const [isOpen, setIsOpen] = useState(false)
|
||
const [triggerLayout, setTriggerLayout] = useState({ x: 0, y: 0, width: 0, height: 0 })
|
||
const triggerRef = useRef<View>(null)
|
||
const layoutRef = useRef({ width: 0, height: 0 })
|
||
const { width: screenWidth } = useWindowDimensions()
|
||
const insets = useSafeAreaInsets()
|
||
|
||
// 获取当前选中的选项
|
||
const selectedOption = options.find(option => option.value === value)
|
||
|
||
// 测量触发按钮的位置(跨平台兼容)
|
||
const measureTrigger = () => {
|
||
if (!triggerRef.current) return
|
||
|
||
if (Platform.OS === 'android') {
|
||
// Android 上使用 measure 方法,它返回相对于父组件的坐标
|
||
triggerRef.current.measure((_x, _y, width, height, pageX, pageY) => {
|
||
const finalWidth = width || layoutRef.current.width || 200
|
||
const finalHeight = height || layoutRef.current.height || 40
|
||
// pageY 在 Android 上已经包含了状态栏高度,所以直接使用
|
||
setTriggerLayout({
|
||
x: pageX,
|
||
y: pageY,
|
||
width: finalWidth,
|
||
height: finalHeight
|
||
})
|
||
})
|
||
} else {
|
||
// iOS 和 Web 使用 measureInWindow
|
||
triggerRef.current.measureInWindow((x, y, width, height) => {
|
||
const finalWidth = width || layoutRef.current.width || 200
|
||
const finalHeight = height || layoutRef.current.height || 40
|
||
|
||
if (Platform.OS === 'web' && (width === 0 || height === 0)) {
|
||
// Web 平台使用 measure 作为后备
|
||
triggerRef.current?.measure((_x, _y, w, h, pageX, pageY) => {
|
||
setTriggerLayout({
|
||
x: pageX || x,
|
||
y: pageY || y,
|
||
width: w || finalWidth,
|
||
height: h || finalHeight
|
||
})
|
||
})
|
||
} else {
|
||
setTriggerLayout({
|
||
x,
|
||
y,
|
||
width: finalWidth,
|
||
height: finalHeight
|
||
})
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 测量触发按钮的位置
|
||
const handleTriggerLayout = (event: LayoutChangeEvent) => {
|
||
const { width, height } = event.nativeEvent.layout
|
||
layoutRef.current = { width, height }
|
||
// 延迟测量以确保布局已完成
|
||
setTimeout(() => {
|
||
measureTrigger()
|
||
}, 0)
|
||
}
|
||
|
||
// 处理选项选择
|
||
const handleSelect = (option: DropdownOption<T>) => {
|
||
if (option.disabled) return
|
||
onSelect?.(option.value, option)
|
||
setIsOpen(false)
|
||
}
|
||
|
||
// 切换下拉菜单显示状态
|
||
const toggleDropdown = () => {
|
||
if (disabled) return
|
||
// 在打开时重新测量位置
|
||
if (!isOpen) {
|
||
// 使用 setTimeout 确保在打开前完成测量
|
||
// Android 上需要稍微延迟以确保测量准确
|
||
const delay = Platform.OS === 'android' ? 50 : 0
|
||
setTimeout(() => {
|
||
measureTrigger()
|
||
}, delay)
|
||
}
|
||
setIsOpen(!isOpen)
|
||
}
|
||
|
||
// 当打开时,重新测量位置(处理滚动等情况)
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
// Android 上需要稍微延迟以确保测量准确
|
||
const delay = Platform.OS === 'android' ? 50 : 0
|
||
setTimeout(() => {
|
||
measureTrigger()
|
||
}, delay)
|
||
}
|
||
}, [isOpen])
|
||
|
||
// 关闭下拉菜单
|
||
const closeDropdown = () => {
|
||
setIsOpen(false)
|
||
}
|
||
|
||
// 默认触发按钮渲染
|
||
const defaultTrigger = (
|
||
<Pressable
|
||
style={[styles.trigger, triggerStyle, disabled && styles.triggerDisabled]}
|
||
onPress={toggleDropdown}
|
||
disabled={disabled}
|
||
>
|
||
<Text style={[styles.triggerText, !selectedOption && styles.triggerTextPlaceholder]}>
|
||
{selectedOption ? selectedOption.label : placeholder}
|
||
</Text>
|
||
<View style={[styles.arrowIcon, isOpen && styles.arrowIconRotated]}>
|
||
<DownArrowIcon />
|
||
</View>
|
||
</Pressable>
|
||
)
|
||
|
||
return (
|
||
<View style={[styles.container, style]} ref={triggerRef} onLayout={handleTriggerLayout}>
|
||
{renderTrigger ? renderTrigger(selectedOption, isOpen, toggleDropdown) : defaultTrigger}
|
||
|
||
<Modal
|
||
visible={isOpen}
|
||
transparent
|
||
animationType="fade"
|
||
onRequestClose={closeDropdown}
|
||
>
|
||
<TouchableWithoutFeedback onPress={closeDropdown}>
|
||
<View style={styles.modalOverlay}>
|
||
<TouchableWithoutFeedback>
|
||
{(() => {
|
||
const dropdownWidth = (dropdownStyle as any)?.minWidth || (dropdownStyle as any)?.width || Math.max(triggerLayout.width, 200)
|
||
const hasRightAlignment = (dropdownStyle as any)?.right !== undefined
|
||
const rightValue = (dropdownStyle as any)?.right
|
||
const calculatedLeft = hasRightAlignment
|
||
? screenWidth - (rightValue || 0) - dropdownWidth
|
||
: triggerLayout.x
|
||
|
||
// 从 dropdownStyle 中移除 right,避免样式冲突
|
||
const { right, ...restDropdownStyle } = dropdownStyle || {}
|
||
|
||
return (
|
||
<View
|
||
style={[
|
||
styles.dropdown,
|
||
{
|
||
top: triggerLayout.y + triggerLayout.height + offsetTop,
|
||
left: calculatedLeft,
|
||
width: dropdownWidth,
|
||
maxHeight,
|
||
},
|
||
restDropdownStyle,
|
||
]}
|
||
>
|
||
<FlatList
|
||
data={options}
|
||
keyExtractor={(item, index) => `option-${index}-${String(item.value)}`}
|
||
renderItem={({ item }) => {
|
||
const isSelected = item.value === value
|
||
if (renderOption) {
|
||
return (
|
||
<Pressable
|
||
style={[
|
||
styles.option,
|
||
isSelected && styles.optionSelected,
|
||
item.disabled && styles.optionDisabled,
|
||
optionStyle,
|
||
]}
|
||
onPress={() => handleSelect(item)}
|
||
disabled={item.disabled}
|
||
>
|
||
{renderOption(item, isSelected)}
|
||
</Pressable>
|
||
)
|
||
}
|
||
return (
|
||
<Pressable
|
||
style={[
|
||
styles.option,
|
||
isSelected && styles.optionSelected,
|
||
item.disabled && styles.optionDisabled,
|
||
optionStyle,
|
||
]}
|
||
onPress={() => handleSelect(item)}
|
||
disabled={item.disabled}
|
||
>
|
||
<Text
|
||
style={[
|
||
styles.optionText,
|
||
isSelected && styles.optionTextSelected,
|
||
item.disabled && styles.optionTextDisabled,
|
||
optionTextStyle,
|
||
]}
|
||
>
|
||
{item.label}
|
||
</Text>
|
||
</Pressable>
|
||
)
|
||
}}
|
||
showsVerticalScrollIndicator={false}
|
||
/>
|
||
</View>
|
||
)
|
||
})()}
|
||
</TouchableWithoutFeedback>
|
||
</View>
|
||
</TouchableWithoutFeedback>
|
||
</Modal>
|
||
</View>
|
||
)
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
position: 'relative',
|
||
},
|
||
trigger: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 8,
|
||
borderRadius: 8,
|
||
backgroundColor: '#272A30',
|
||
minHeight: 40,
|
||
},
|
||
triggerDisabled: {
|
||
opacity: 0.5,
|
||
},
|
||
triggerText: {
|
||
color: '#F5F5F5',
|
||
fontSize: 14,
|
||
fontWeight: '500',
|
||
flex: 1,
|
||
},
|
||
triggerTextPlaceholder: {
|
||
color: '#ABABAB',
|
||
},
|
||
arrowIcon: {
|
||
marginLeft: 8,
|
||
transform: [{ rotate: '0deg' }],
|
||
},
|
||
arrowIconRotated: {
|
||
transform: [{ rotate: '180deg' }],
|
||
},
|
||
modalOverlay: {
|
||
flex: 1,
|
||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||
},
|
||
dropdown: {
|
||
position: 'absolute',
|
||
backgroundColor: '#272A30',
|
||
borderRadius: 12,
|
||
overflow: 'hidden',
|
||
...Platform.select({
|
||
ios: {
|
||
shadowColor: '#000',
|
||
shadowOffset: {
|
||
width: 0,
|
||
height: 4,
|
||
},
|
||
shadowOpacity: 0.3,
|
||
shadowRadius: 8,
|
||
},
|
||
android: {
|
||
elevation: 8,
|
||
},
|
||
web: {
|
||
shadowColor: '#000',
|
||
shadowOffset: {
|
||
width: 0,
|
||
height: 4,
|
||
},
|
||
shadowOpacity: 0.3,
|
||
shadowRadius: 8,
|
||
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.3)',
|
||
},
|
||
}),
|
||
},
|
||
option: {
|
||
paddingHorizontal: 16,
|
||
paddingVertical: 12,
|
||
},
|
||
optionSelected: {
|
||
backgroundColor: '#3A3D42',
|
||
},
|
||
optionDisabled: {
|
||
opacity: 0.5,
|
||
},
|
||
optionText: {
|
||
color: '#F5F5F5',
|
||
fontSize: 14,
|
||
fontWeight: '400',
|
||
},
|
||
optionTextSelected: {
|
||
color: '#FFCF00',
|
||
fontWeight: '500',
|
||
},
|
||
optionTextDisabled: {
|
||
color: '#ABABAB',
|
||
},
|
||
})
|
||
|