expo-popcore-app/components/ui/dropdown.tsx

403 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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',
},
})