Initial commit: expo-popcore-app
This commit is contained in:
71
components/ui/Block.tsx
Normal file
71
components/ui/Block.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { forwardRef } from 'react'
|
||||
import { View, TouchableOpacity, ViewStyle, Pressable } from 'react-native'
|
||||
import Animated from 'react-native-reanimated'
|
||||
|
||||
const AnimatedView = Animated.createAnimatedComponent(View)
|
||||
|
||||
interface BlockProps {
|
||||
onClick?: () => void
|
||||
onLongPress?: () => void
|
||||
animated?: boolean
|
||||
style?: ViewStyle
|
||||
children?: React.ReactNode
|
||||
opacity?: number
|
||||
touchProps?: any
|
||||
className?: string
|
||||
}
|
||||
|
||||
const Block = forwardRef<View, BlockProps>((props, ref) => {
|
||||
const {
|
||||
onClick = null,
|
||||
onLongPress,
|
||||
animated,
|
||||
children,
|
||||
opacity,
|
||||
touchProps,
|
||||
className = '',
|
||||
...reset
|
||||
} = props
|
||||
|
||||
const handlePress = () => {
|
||||
onClick && onClick()
|
||||
}
|
||||
|
||||
if ((onClick || onLongPress) && opacity) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={opacity || 0.85}
|
||||
onPress={handlePress}
|
||||
onLongPress={onLongPress}
|
||||
>
|
||||
<View className={className} {...reset} ref={ref}>
|
||||
{children}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
} else if (onClick || onLongPress) {
|
||||
return (
|
||||
<Pressable onPress={handlePress} onLongPress={onLongPress} {...touchProps}>
|
||||
<View className={className} {...reset} ref={ref}>
|
||||
{children}
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
} else if (animated) {
|
||||
return (
|
||||
<AnimatedView className={className} {...reset} ref={ref}>
|
||||
{children}
|
||||
</AnimatedView>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<View className={className} {...reset} ref={ref}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// 添加displayName以便在React DevTools中显示组件名称
|
||||
Block.displayName = 'Block'
|
||||
export default Block
|
||||
80
components/ui/Img.tsx
Normal file
80
components/ui/Img.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React, { forwardRef, useMemo } from 'react'
|
||||
import { Image as ExpoImage, ImageProps as ExpoImageProps } from 'expo-image'
|
||||
import { TouchableOpacity, TouchableOpacityProps } from 'react-native'
|
||||
|
||||
interface ImgProps extends ExpoImageProps {
|
||||
onClick?: () => void
|
||||
touchProps?: TouchableOpacityProps
|
||||
className?: string
|
||||
src?: string | number
|
||||
errorSource?: string | number
|
||||
}
|
||||
|
||||
const Img = forwardRef<ExpoImage, ImgProps>((props, ref) => {
|
||||
const {
|
||||
onClick,
|
||||
touchProps = {},
|
||||
className = '',
|
||||
style,
|
||||
src,
|
||||
errorSource,
|
||||
source: propSource,
|
||||
...reset
|
||||
} = props
|
||||
|
||||
// 判断是否为网络图片
|
||||
const isNetworkImage = (uri: string | number): boolean => {
|
||||
if (typeof uri === 'number') return false
|
||||
return uri.startsWith('http://') || uri.startsWith('https://')
|
||||
}
|
||||
|
||||
// 构建图片源
|
||||
const imageSource = useMemo(() => {
|
||||
// 如果提供了source属性,优先使用
|
||||
if (propSource) return propSource
|
||||
|
||||
if (!src) return undefined
|
||||
|
||||
if (typeof src === 'number') {
|
||||
// 本地图片资源(require导入的资源ID)
|
||||
return src
|
||||
} else {
|
||||
// 网络图片或本地文件路径
|
||||
if (isNetworkImage(src)) {
|
||||
return {
|
||||
uri: src,
|
||||
cache: 'immutable', // 使用expo-image的缓存机制
|
||||
}
|
||||
} else {
|
||||
// 本地文件路径
|
||||
return { uri: src }
|
||||
}
|
||||
}
|
||||
}, [src, propSource])
|
||||
|
||||
const imgProps = {
|
||||
style,
|
||||
className,
|
||||
ref,
|
||||
source: imageSource,
|
||||
errorSource,
|
||||
...reset,
|
||||
}
|
||||
|
||||
const handlePress = () => {
|
||||
onClick && onClick()
|
||||
}
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<TouchableOpacity onPress={handlePress} {...touchProps}>
|
||||
<ExpoImage {...imgProps} />
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
return <ExpoImage {...imgProps} />
|
||||
})
|
||||
|
||||
Img.displayName = 'Img'
|
||||
export default Img
|
||||
300
components/ui/ModalPortal.jsx
Normal file
300
components/ui/ModalPortal.jsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
forwardRef,
|
||||
} from 'react'
|
||||
import Animated, {
|
||||
Extrapolation,
|
||||
FadeIn,
|
||||
FadeInDown,
|
||||
FadeInLeft,
|
||||
FadeInRight,
|
||||
FadeInUp,
|
||||
FadeOut,
|
||||
FadeOutDown,
|
||||
FadeOutLeft,
|
||||
FadeOutRight,
|
||||
FadeOutUp,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated'
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
||||
import { BackHandler, Pressable, StyleSheet, View, useWindowDimensions } from 'react-native'
|
||||
import Overlay from './Overlay'
|
||||
|
||||
export const defaultDirection = 'down'
|
||||
export const defaultConfig = {
|
||||
animationInTiming: 150,
|
||||
animationOutTiming: 150,
|
||||
hideBackdrop: false,
|
||||
backdropColor: 'rgba(0, 0, 0, 0.1)',
|
||||
dampingFactor: 0.2,
|
||||
swipeDirection: defaultDirection,
|
||||
swipeVelocityThreshold: 500,
|
||||
onBackButtonPress: undefined,
|
||||
onBackdropPress: undefined,
|
||||
style: {},
|
||||
|
||||
// 点击穿透
|
||||
// pointerEvents: 'auto',
|
||||
}
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
||||
|
||||
const defaultAnimationInMap = {
|
||||
fade: FadeIn,
|
||||
zoom: ZoomIn,
|
||||
up: FadeInUp,
|
||||
down: FadeInDown,
|
||||
left: FadeInLeft,
|
||||
right: FadeInRight,
|
||||
}
|
||||
|
||||
const defaultAnimationOutMap = {
|
||||
fade: FadeOut,
|
||||
zoom: ZoomOut,
|
||||
up: FadeOutUp,
|
||||
down: FadeOutDown,
|
||||
left: FadeOutLeft,
|
||||
right: FadeOutRight,
|
||||
}
|
||||
|
||||
const ModalPortal = forwardRef((props, ref) => {
|
||||
const [config, setConfig] = useState(defaultConfig)
|
||||
const [modalContent, setModalContent] = useState()
|
||||
|
||||
const translationX = useSharedValue(0)
|
||||
const translationY = useSharedValue(0)
|
||||
const prevTranslationX = useSharedValue(0)
|
||||
const prevTranslationY = useSharedValue(0)
|
||||
|
||||
const onHideRef = useRef(() => {})
|
||||
|
||||
const { width, height } = useWindowDimensions()
|
||||
|
||||
const hide = useCallback(async (props) => {
|
||||
setModalContent(undefined)
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, config.animationOutTiming)
|
||||
})
|
||||
|
||||
translationX.value = 0
|
||||
translationY.value = 0
|
||||
|
||||
onHideRef.current(props)
|
||||
}, [])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
hide,
|
||||
show: async (newComponent, newConfig = {}) => {
|
||||
if (modalContent) await hide()
|
||||
|
||||
const mergedConfig = { ...defaultConfig, ...newConfig }
|
||||
|
||||
setConfig(mergedConfig)
|
||||
if (React.isValidElement(newComponent) && newComponent) {
|
||||
setModalContent(newComponent)
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
onHideRef.current = resolve
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
const onBackdropPress = useMemo(() => {
|
||||
return config.onBackdropPress ?? (() => hide())
|
||||
}, [config.onBackdropPress, hide])
|
||||
|
||||
const isHorizontal = config.swipeDirection === 'left' || config.swipeDirection === 'right'
|
||||
|
||||
const rangeMap = useMemo(() => {
|
||||
return {
|
||||
up: -height,
|
||||
down: height,
|
||||
left: -width,
|
||||
right: width,
|
||||
}
|
||||
}, [height, width])
|
||||
|
||||
const pan = Gesture.Pan()
|
||||
.enabled(!!config.swipeDirection)
|
||||
.minDistance(1)
|
||||
.onStart(() => {
|
||||
prevTranslationX.value = translationX.value
|
||||
prevTranslationY.value = translationY.value
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
const translationValue = isHorizontal ? event.translationX : event.translationY
|
||||
|
||||
const prevTranslationValue = isHorizontal ? prevTranslationX.value : prevTranslationY.value
|
||||
|
||||
const shouldDampMap = {
|
||||
up: translationValue > 0,
|
||||
down: translationValue < 0,
|
||||
left: translationValue > 0,
|
||||
right: translationValue < 0,
|
||||
}
|
||||
|
||||
const shouldDamp = shouldDampMap[config.swipeDirection ?? defaultDirection]
|
||||
|
||||
const dampedTranslation = shouldDamp
|
||||
? prevTranslationValue + translationValue * config.dampingFactor
|
||||
: prevTranslationValue + translationValue
|
||||
|
||||
if (isHorizontal) {
|
||||
translationX.value = dampedTranslation
|
||||
} else {
|
||||
translationY.value = dampedTranslation
|
||||
}
|
||||
})
|
||||
.onEnd((event) => {
|
||||
const velocityThreshold = config.swipeVelocityThreshold
|
||||
|
||||
const shouldHideMap = {
|
||||
up: event.velocityY < -velocityThreshold,
|
||||
down: event.velocityY > velocityThreshold,
|
||||
right: event.velocityX > velocityThreshold,
|
||||
left: event.velocityX < -velocityThreshold,
|
||||
}
|
||||
|
||||
const shouldHide = shouldHideMap[config.swipeDirection ?? defaultDirection]
|
||||
|
||||
if (!shouldHide) {
|
||||
translationX.value = withSpring(0, {
|
||||
velocity: event.velocityX,
|
||||
damping: 75,
|
||||
})
|
||||
translationY.value = withSpring(0, {
|
||||
velocity: event.velocityY,
|
||||
damping: 75,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const mainTranslation = isHorizontal ? translationX : translationY
|
||||
|
||||
mainTranslation.value = withSpring(
|
||||
rangeMap[config.swipeDirection ?? defaultDirection],
|
||||
{ velocity: event.velocityX, overshootClamping: true },
|
||||
(success) => success && runOnJS(hide)(),
|
||||
)
|
||||
|
||||
return
|
||||
})
|
||||
|
||||
const animatedStyles = useAnimatedStyle(
|
||||
() => ({
|
||||
transform: [{ translateX: translationX.value }, { translateY: translationY.value }],
|
||||
}),
|
||||
[translationX.value, translationY.value],
|
||||
)
|
||||
|
||||
const animatedBackdropStyles = useAnimatedStyle(() => {
|
||||
const translationValue = isHorizontal ? translationX.value : translationY.value
|
||||
|
||||
return {
|
||||
opacity: interpolate(
|
||||
translationValue,
|
||||
[rangeMap[config.defaultDirection ?? defaultDirection], 0],
|
||||
[0, 1],
|
||||
Extrapolation.CLAMP,
|
||||
),
|
||||
}
|
||||
}, [config.swipeDirection, translationX.value, translationY.value, rangeMap])
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalContent) return
|
||||
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
if (config.onBackButtonPress) {
|
||||
config.onBackButtonPress()
|
||||
} else {
|
||||
hide()
|
||||
}
|
||||
return true
|
||||
})
|
||||
return () => backHandler.remove()
|
||||
}, [config.onBackButtonPress, hide, modalContent])
|
||||
|
||||
const isBackdropVisible = modalContent && !config.hideBackdrop
|
||||
|
||||
return (
|
||||
<Overlay>
|
||||
<View pointerEvents={isBackdropVisible ? 'auto' : 'box-none'} style={StyleSheet.absoluteFill}>
|
||||
{modalContent && (
|
||||
<>
|
||||
<Animated.View
|
||||
pointerEvents={isBackdropVisible ? 'auto' : 'none'}
|
||||
entering={FadeIn.duration(config.animationInTiming)}
|
||||
exiting={FadeOut.duration(config.animationOutTiming)}
|
||||
style={styles.backdropContainer}
|
||||
>
|
||||
<AnimatedPressable
|
||||
testID="magic-modal-backdrop"
|
||||
style={[
|
||||
styles.backdrop,
|
||||
animatedBackdropStyles,
|
||||
{
|
||||
backgroundColor: isBackdropVisible ? config.backdropColor : 'transparent',
|
||||
},
|
||||
]}
|
||||
onPress={onBackdropPress}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View pointerEvents="box-none" style={[styles.overlay, animatedStyles]}>
|
||||
<Animated.View
|
||||
pointerEvents="box-none"
|
||||
style={[styles.overlay, config.style]}
|
||||
// entering={FadeInUp}
|
||||
entering={
|
||||
config.entering ??
|
||||
defaultAnimationInMap[config.swipeDirection ?? defaultDirection].duration(
|
||||
config.animationInTiming,
|
||||
)
|
||||
}
|
||||
exiting={
|
||||
config.exiting ??
|
||||
defaultAnimationOutMap[config.swipeDirection ?? defaultDirection].duration(
|
||||
config.animationOutTiming,
|
||||
)
|
||||
}
|
||||
>
|
||||
<GestureDetector gesture={pan}>{modalContent}</GestureDetector>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</Overlay>
|
||||
)
|
||||
})
|
||||
|
||||
ModalPortal.displayName = 'ModalPortal'
|
||||
export default ModalPortal
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
backdropContainer: {
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
})
|
||||
9
components/ui/Overlay.js
Normal file
9
components/ui/Overlay.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Fragment } from 'react'
|
||||
import { Platform } from 'react-native'
|
||||
import { FullWindowOverlay } from 'react-native-screens'
|
||||
|
||||
// 为了兼容 iOS 和 Android,我们需要使用 react-native-screens 的 FullWindowOverlay 组件
|
||||
/** Don't use .ios file extension as bunchee can't bundle it properly */
|
||||
|
||||
const Overlay = Platform.OS === 'ios' ? FullWindowOverlay : Fragment
|
||||
export default Overlay
|
||||
48
components/ui/Text.tsx
Normal file
48
components/ui/Text.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React, { forwardRef } from 'react'
|
||||
import {
|
||||
Text as RNText,
|
||||
TouchableOpacity,
|
||||
TextProps as RNTextProps,
|
||||
TouchableOpacityProps,
|
||||
StyleProp,
|
||||
TextStyle,
|
||||
} from 'react-native'
|
||||
import Animated from 'react-native-reanimated'
|
||||
|
||||
interface TextProps extends RNTextProps {
|
||||
onClick?: () => void
|
||||
touchProps?: TouchableOpacityProps
|
||||
animated?: boolean
|
||||
style?: StyleProp<TextStyle>
|
||||
className?: string
|
||||
}
|
||||
|
||||
const Text = forwardRef<RNText, TextProps>((props, ref) => {
|
||||
const { onClick, touchProps = {}, animated, style, className = '', children, ...reset } = props
|
||||
|
||||
const textProps = {
|
||||
className,
|
||||
ref: ref,
|
||||
...reset,
|
||||
}
|
||||
|
||||
const handlePress = () => {
|
||||
onClick && onClick()
|
||||
}
|
||||
|
||||
if (animated) {
|
||||
return <Animated.Text {...textProps}>{children}</Animated.Text>
|
||||
}
|
||||
if (onClick) {
|
||||
return (
|
||||
<TouchableOpacity activeOpacity={1} onPress={handlePress} {...touchProps}>
|
||||
<RNText {...textProps}>{children}</RNText>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
return <RNText {...textProps}>{children}</RNText>
|
||||
})
|
||||
|
||||
Text.displayName = 'Text'
|
||||
export default Text
|
||||
253
components/ui/Toast.tsx
Normal file
253
components/ui/Toast.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import React, { ReactNode } from 'react'
|
||||
import Text from './Text'
|
||||
import Block from './Block'
|
||||
|
||||
import { ActivityIndicator } from 'react-native'
|
||||
|
||||
// 定义insets接口
|
||||
interface Insets {
|
||||
safeAreaInsetsBottom: number
|
||||
safeAreaInsetsTop: number
|
||||
safeAreaInsetsLeft: number
|
||||
safeAreaInsetsRight: number
|
||||
}
|
||||
|
||||
// 定义全局变量接口
|
||||
declare global {
|
||||
interface Window {
|
||||
toast?: {
|
||||
show: (component: ReactNode, config?: any) => void
|
||||
hide: () => void
|
||||
}
|
||||
loading?: {
|
||||
show: (component: ReactNode, config?: any) => void
|
||||
hide: () => void
|
||||
}
|
||||
modal?: {
|
||||
show: (component: ReactNode, config?: any) => void
|
||||
hide: () => void
|
||||
}
|
||||
actionSheet?: {
|
||||
show: (component: ReactNode, config?: any) => void
|
||||
hide: () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 参数接口定义
|
||||
interface ShowParams {
|
||||
renderContent?: () => ReactNode
|
||||
title?: string
|
||||
duration?: number
|
||||
hideBackdrop?: boolean
|
||||
}
|
||||
|
||||
interface ShowActionSheetParams {
|
||||
itemList?: string[]
|
||||
renderContent?: () => ReactNode
|
||||
}
|
||||
|
||||
const isString = (variable: any): variable is string =>
|
||||
Object.prototype.toString.call(variable) === '[object String]'
|
||||
|
||||
const Toast = (function () {
|
||||
// 初始化默认insets
|
||||
let insets: Insets = {
|
||||
safeAreaInsetsBottom: 0,
|
||||
safeAreaInsetsTop: 0,
|
||||
safeAreaInsetsLeft: 0,
|
||||
safeAreaInsetsRight: 0,
|
||||
}
|
||||
|
||||
// // 使用回调函数获取insets
|
||||
// StaticSafeAreaInsets.getSafeAreaInsets((staticInsets) => {
|
||||
// insets = staticInsets
|
||||
// })
|
||||
|
||||
let toastTimer: number | null = null
|
||||
const show = (params?: ShowParams): void => {
|
||||
const { renderContent, title, duration = 2000, hideBackdrop = true } = params || {}
|
||||
hide()
|
||||
|
||||
const renderBody = (): ReactNode => {
|
||||
if (renderContent) {
|
||||
return renderContent()
|
||||
}
|
||||
return title && <Text className="text-[16px] text-white">{title}</Text>
|
||||
}
|
||||
|
||||
; (global as any).toast?.show(
|
||||
<Block className="z-[9999] flex items-center justify-center">
|
||||
<Block className="relative mx-[20px] mt-[-40px] rounded-[2px] bg-black/85 px-[12px] py-[8px]">
|
||||
{renderBody()}
|
||||
</Block>
|
||||
</Block>,
|
||||
{
|
||||
style: {},
|
||||
swipeDirection: null,
|
||||
hideBackdrop,
|
||||
onBackdropPress: () => { },
|
||||
entering: false,
|
||||
exiting: false,
|
||||
},
|
||||
)
|
||||
|
||||
if (duration > 0) {
|
||||
toastTimer = setTimeout(() => {
|
||||
hide()
|
||||
}, duration)
|
||||
}
|
||||
}
|
||||
|
||||
const hide = (): void => {
|
||||
; (global as any).toast?.hide()
|
||||
if (toastTimer) {
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// loading
|
||||
let loadingTimer: number | null = null
|
||||
const showLoading = (params?: ShowParams): void => {
|
||||
const { renderContent, title, duration = 1500, hideBackdrop = false } = params || {}
|
||||
hideLoading()
|
||||
|
||||
const renderBody = (): ReactNode => {
|
||||
if (renderContent) {
|
||||
return renderContent()
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ActivityIndicator size="small" />
|
||||
{!!title && (
|
||||
<Block className="mt-[12px] items-center px-[10px]">
|
||||
<Text className="text-[14px] text-white">{title}</Text>
|
||||
</Block>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
; (global as any).loading?.show(
|
||||
<Block className="z-[9999] flex items-center justify-center">
|
||||
<Block className="mt-[-40px] min-h-[100px] min-w-[100px] items-center justify-center overflow-hidden rounded-[8px] bg-black/85 py-[20px]">
|
||||
{renderBody()}
|
||||
</Block>
|
||||
</Block>,
|
||||
{
|
||||
style: {},
|
||||
swipeDirection: null,
|
||||
hideBackdrop,
|
||||
backdropColor: 'rgba(0,0,0,0.1)',
|
||||
onBackdropPress: () => { },
|
||||
entering: false,
|
||||
exiting: false,
|
||||
},
|
||||
)
|
||||
|
||||
if (duration > 0) {
|
||||
loadingTimer = setTimeout(() => {
|
||||
hideLoading()
|
||||
}, duration)
|
||||
}
|
||||
}
|
||||
|
||||
const hideLoading = (): void => {
|
||||
; (global as any).loading?.hide()
|
||||
if (loadingTimer) {
|
||||
clearTimeout(loadingTimer)
|
||||
loadingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// modal
|
||||
const showModal = (ele: ReactNode, config: any = {}): void => {
|
||||
; (global as any).modal?.show(ele, {
|
||||
swipeDirection: null,
|
||||
...config,
|
||||
})
|
||||
}
|
||||
|
||||
const hideModal = (): void => {
|
||||
; (global as any).modal?.hide()
|
||||
}
|
||||
|
||||
// actionsheet
|
||||
// 兼容半屏组件
|
||||
const showActionSheet = ({ itemList, renderContent }: ShowActionSheetParams): Promise<number> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const handleSelect = (item: string): void => {
|
||||
hideActionSheet()
|
||||
const itemIndex = itemList?.findIndex((i) => i === item) || -1
|
||||
resolve(itemIndex)
|
||||
}
|
||||
|
||||
const handleCancel = (): void => {
|
||||
hideActionSheet()
|
||||
reject()
|
||||
}
|
||||
|
||||
const renderBody = (): ReactNode => {
|
||||
if (Array.isArray(itemList)) {
|
||||
return (
|
||||
<Block className="bg-white2 mb-[-1000px] gap-[1px] overflow-hidden rounded-t-[12px]">
|
||||
{itemList.map((item, index) => (
|
||||
<Block
|
||||
onClick={() => handleSelect(item)}
|
||||
key={index}
|
||||
className="items-center justify-center bg-white py-[16px]"
|
||||
>
|
||||
<Text className="text-[16px] text-black">{item}</Text>
|
||||
</Block>
|
||||
))}
|
||||
<Block className="bg-white2 mt-[8px] w-full" />
|
||||
<Block
|
||||
onClick={handleCancel}
|
||||
style={{ paddingBottom: insets.safeAreaInsetsBottom + 20 + 1000 }}
|
||||
className="flex items-center justify-center bg-white py-[16px]"
|
||||
>
|
||||
<Text className="text-[16px] text-black">取消</Text>
|
||||
</Block>
|
||||
</Block>
|
||||
)
|
||||
}
|
||||
|
||||
if (!renderContent) return null
|
||||
|
||||
return (
|
||||
<Block className="">
|
||||
{/* <ModalHeader /> */}
|
||||
{/* 安卓闪动,向上2px */}
|
||||
<Block className="mt-[-2px]">{renderContent()}</Block>
|
||||
<Block
|
||||
className="mb-[-1000px] w-full bg-white"
|
||||
style={{ height: insets.safeAreaInsetsBottom + 20 + 1000, position: 'relative' }}
|
||||
/>
|
||||
</Block>
|
||||
)
|
||||
}
|
||||
|
||||
; (global as any).actionSheet?.show(<Block className="">{renderBody()}</Block>, {
|
||||
style: { justifyContent: 'flex-end' },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const hideActionSheet = (): void => {
|
||||
; (global as any).actionSheet?.hide()
|
||||
}
|
||||
|
||||
return {
|
||||
showActionSheet,
|
||||
hideActionSheet,
|
||||
showModal,
|
||||
hideModal,
|
||||
showLoading,
|
||||
hideLoading,
|
||||
show,
|
||||
hide,
|
||||
}
|
||||
})()
|
||||
|
||||
export default Toast
|
||||
428
components/ui/alert-dialog.tsx
Normal file
428
components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
type ViewStyle,
|
||||
type TextStyle,
|
||||
StyleSheet,
|
||||
StatusBar,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { BlurView } from "expo-blur";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button, buttonVariants } from "./button";
|
||||
|
||||
interface AlertDialogContextValue {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const AlertDialogContext = React.createContext<AlertDialogContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useAlertDialogContext = () => {
|
||||
const context = React.useContext(AlertDialogContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"AlertDialog components must be used within AlertDialog provider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface AlertDialogProps {
|
||||
children: React.ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function AlertDialog({ children, open, onOpenChange }: AlertDialogProps) {
|
||||
const [internalOpen, setInternalOpen] = React.useState(false);
|
||||
|
||||
const isControlled = open !== undefined;
|
||||
const isOpen = isControlled ? open : internalOpen;
|
||||
|
||||
const handleSetOpen = React.useCallback(
|
||||
(value: boolean) => {
|
||||
if (!isControlled) {
|
||||
setInternalOpen(value);
|
||||
}
|
||||
onOpenChange?.(value);
|
||||
},
|
||||
[isControlled, onOpenChange]
|
||||
);
|
||||
|
||||
const value: AlertDialogContextValue = {
|
||||
open: isOpen,
|
||||
setOpen: handleSetOpen,
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialogContext.Provider value={value}>
|
||||
{children}
|
||||
</AlertDialogContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface AlertDialogTriggerProps {
|
||||
children: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
children,
|
||||
asChild,
|
||||
style,
|
||||
}: AlertDialogTriggerProps) {
|
||||
const { setOpen } = useAlertDialogContext();
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children as React.ReactElement<any>, {
|
||||
onPress: () => setOpen(true),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable onPress={() => setOpen(true)} style={style}>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
interface AlertDialogPortalProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function AlertDialogPortal({ children }: AlertDialogPortalProps) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
interface AlertDialogOverlayProps {
|
||||
className?: string;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({ className, style }: AlertDialogOverlayProps) {
|
||||
return (
|
||||
<View
|
||||
className={cn("absolute inset-0", className)}
|
||||
style={[styles.overlay, style]}
|
||||
>
|
||||
{Platform.OS === 'ios' ? (
|
||||
<BlurView
|
||||
intensity={40}
|
||||
tint="dark"
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
) : null}
|
||||
<View
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
{ backgroundColor: "#00000080" },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface AlertDialogContentProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
}: AlertDialogContentProps) {
|
||||
const { open, setOpen } = useAlertDialogContext();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (Platform.OS === "android") {
|
||||
if (open) {
|
||||
StatusBar.setBackgroundColor("rgba(0,0,0,0.5)", true);
|
||||
} else {
|
||||
StatusBar.setBackgroundColor("transparent", true);
|
||||
}
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
visible={open}
|
||||
animationType="none"
|
||||
statusBarTranslucent={Platform.OS === 'android'}
|
||||
onRequestClose={() => setOpen(false)}
|
||||
hardwareAccelerated
|
||||
>
|
||||
<View style={styles.modalContainer}>
|
||||
<Pressable
|
||||
style={styles.backdrop}
|
||||
onPress={() => setOpen(false)}
|
||||
android_ripple={null}
|
||||
>
|
||||
<AlertDialogOverlay />
|
||||
</Pressable>
|
||||
|
||||
<View
|
||||
className={cn(
|
||||
"bg-background w-[90%] max-w-[500px] rounded-lg border border-border p-6 shadow-2xl gap-4",
|
||||
className
|
||||
)}
|
||||
style={[styles.content, style]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface AlertDialogHeaderProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
}: AlertDialogHeaderProps) {
|
||||
return (
|
||||
<View
|
||||
className={cn("flex-col gap-2", className)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface AlertDialogFooterProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
}: AlertDialogFooterProps) {
|
||||
return (
|
||||
<View
|
||||
className={cn("flex flex-row gap-2 justify-end", className)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface AlertDialogTitleProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
style?: TextStyle;
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
}: AlertDialogTitleProps) {
|
||||
return (
|
||||
<Text
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
interface AlertDialogDescriptionProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
style?: TextStyle;
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
}: AlertDialogDescriptionProps) {
|
||||
return (
|
||||
<Text
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
interface AlertDialogActionProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
onPress?: () => void;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
children,
|
||||
className,
|
||||
onPress,
|
||||
style,
|
||||
}: AlertDialogActionProps) {
|
||||
const { setOpen } = useAlertDialogContext();
|
||||
|
||||
const handlePress = () => {
|
||||
onPress?.();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onPress={handlePress}
|
||||
className={cn("flex-1", className)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface AlertDialogCancelProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
onPress?: () => void;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
children,
|
||||
className,
|
||||
onPress,
|
||||
style,
|
||||
}: AlertDialogCancelProps) {
|
||||
const { setOpen } = useAlertDialogContext();
|
||||
|
||||
const handlePress = () => {
|
||||
onPress?.();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onPress={handlePress}
|
||||
className={cn("flex-1", className)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeleteAlertDialogActionProps {
|
||||
onPress?: () => void;
|
||||
style?: ViewStyle;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DeleteAlertDialogAction({
|
||||
onPress,
|
||||
style,
|
||||
className,
|
||||
}: DeleteAlertDialogActionProps) {
|
||||
return (
|
||||
<AlertDialogAction
|
||||
onPress={onPress}
|
||||
className={className}
|
||||
style={{ ...styles.deleteActionButton, ...style }}
|
||||
>
|
||||
<Text style={styles.deleteActionText}>确认删除</Text>
|
||||
</AlertDialogAction>
|
||||
);
|
||||
}
|
||||
|
||||
function CancelAlertDialogAction({
|
||||
onPress,
|
||||
style,
|
||||
className,
|
||||
}: DeleteAlertDialogActionProps) {
|
||||
return (
|
||||
<AlertDialogAction
|
||||
onPress={onPress}
|
||||
className={className}
|
||||
style={{ ...styles.cancelActionButton, ...style }}
|
||||
>
|
||||
<Text style={styles.cancelActionText}>暂不</Text>
|
||||
</AlertDialogAction>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
...Platform.select({
|
||||
android: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}),
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
overlay: {
|
||||
zIndex: 1,
|
||||
},
|
||||
content: {
|
||||
zIndex: 2,
|
||||
},
|
||||
deleteActionButton: {
|
||||
backgroundColor: "#FA3F2C",
|
||||
alignItems: 'center'
|
||||
},
|
||||
deleteActionText: {
|
||||
color: "#FFFFFF",
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
},
|
||||
cancelActionButton: {
|
||||
backgroundColor: '#262A31',
|
||||
alignItems: 'center'
|
||||
},
|
||||
cancelActionText: {
|
||||
color: "#CCCCCC",
|
||||
fontSize: 14,
|
||||
}
|
||||
});
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
DeleteAlertDialogAction,
|
||||
CancelAlertDialogAction
|
||||
};
|
||||
116
components/ui/button.tsx
Normal file
116
components/ui/button.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import * as React from "react";
|
||||
import { Pressable, Text, type PressableProps, type ViewStyle } from "react-native";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"flex-row items-center justify-center rounded-md active:opacity-70",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary",
|
||||
destructive: "bg-destructive",
|
||||
outline: "border border-input bg-background",
|
||||
secondary: "bg-secondary",
|
||||
ghost: "active:bg-accent",
|
||||
link: "",
|
||||
gradient: "",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 px-3",
|
||||
lg: "h-11 px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const buttonTextVariants = cva("text-sm font-medium text-center", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "text-primary-foreground",
|
||||
destructive: "text-destructive-foreground",
|
||||
outline: "text-foreground",
|
||||
secondary: "text-secondary-foreground",
|
||||
ghost: "text-foreground",
|
||||
link: "text-primary",
|
||||
gradient: "text-white",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export interface ButtonProps
|
||||
extends Omit<PressableProps, "style">,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
children?: React.ReactNode;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<
|
||||
React.ElementRef<typeof Pressable>,
|
||||
ButtonProps
|
||||
>(({ className, variant, size, children, disabled, style, ...props }, ref) => {
|
||||
const isGradient = variant === "gradient";
|
||||
|
||||
const content = typeof children === "string" ? (
|
||||
<Text className={cn(buttonTextVariants({ variant }))}>{children}</Text>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
|
||||
// 合并样式,确保 borderRadius 正确应用
|
||||
const borderRadius = style?.borderRadius ?? (isGradient ? 12 : undefined);
|
||||
const mergedStyle = borderRadius
|
||||
? [style, { borderRadius }]
|
||||
: style;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant, size }),
|
||||
disabled && "opacity-50",
|
||||
isGradient && "overflow-hidden",
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
style={mergedStyle}
|
||||
{...props}
|
||||
>
|
||||
{isGradient ? (
|
||||
<LinearGradient
|
||||
colors={["#9966FF", "#FF6699", "#FF9966"]}
|
||||
locations={[0.0015, 0.4985, 0.9956]}
|
||||
start={{ x: 1, y: 0 }}
|
||||
end={{ x: 0, y: 0 }}
|
||||
style={{
|
||||
flex: 1,
|
||||
alignSelf: "stretch",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: borderRadius ?? 12,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</LinearGradient>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
151
components/ui/carousel.tsx
Normal file
151
components/ui/carousel.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { useState, useRef } from "react";
|
||||
import { View as RNView, Dimensions, type ViewProps, type StyleProp, type ViewStyle } from "react-native";
|
||||
import RNCarousel, { ICarouselInstance } from "react-native-reanimated-carousel";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
// 扩展 View 以支持 className(NativeWind)
|
||||
const View = RNView as React.ComponentType<ViewProps & { className?: string }>;
|
||||
|
||||
const { width: screenWidth } = Dimensions.get("window");
|
||||
|
||||
export type CarouselApi = ICarouselInstance;
|
||||
|
||||
type CarouselProps = {
|
||||
orientation?: "horizontal" | "vertical";
|
||||
setApi?: (api: CarouselApi) => void;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
width?: number;
|
||||
height?: number;
|
||||
autoPlay?: boolean;
|
||||
autoPlayInterval?: number;
|
||||
loop?: boolean;
|
||||
};
|
||||
|
||||
type CarouselContextProps = {
|
||||
api: CarouselApi | null;
|
||||
orientation: "horizontal" | "vertical";
|
||||
width: number;
|
||||
height: number;
|
||||
autoPlay: boolean;
|
||||
autoPlayInterval: number;
|
||||
loop: boolean;
|
||||
};
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext);
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = "horizontal",
|
||||
setApi,
|
||||
className,
|
||||
children,
|
||||
width = screenWidth,
|
||||
height = 410,
|
||||
autoPlay = false,
|
||||
autoPlayInterval = 3000,
|
||||
loop = true,
|
||||
onIndexChange,
|
||||
}: CarouselProps & { onIndexChange?: (index: number) => void }) {
|
||||
const [api, setInternalApi] = useState<CarouselApi | null>(null);
|
||||
const carouselRef = useRef<ICarouselInstance>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (carouselRef.current) {
|
||||
const instance = carouselRef.current;
|
||||
setInternalApi(instance);
|
||||
setApi?.(instance);
|
||||
}
|
||||
}, [setApi]);
|
||||
|
||||
// 提取子元素到数组,过滤掉非 ReactElement 的内容
|
||||
// 如果子元素是 CarouselContent,需要提取其内部的子元素
|
||||
const childrenArray = React.Children.toArray(children);
|
||||
const items = childrenArray.reduce<React.ReactElement[]>((acc, child) => {
|
||||
if (React.isValidElement(child)) {
|
||||
const props = child.props as { children?: React.ReactNode };
|
||||
if (props.children) {
|
||||
const innerChildren = React.Children.toArray(props.children).filter(
|
||||
(innerChild): innerChild is React.ReactElement => React.isValidElement(innerChild)
|
||||
);
|
||||
return [...acc, ...innerChildren];
|
||||
}
|
||||
return [...acc, child];
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
api,
|
||||
orientation,
|
||||
width,
|
||||
height,
|
||||
autoPlay,
|
||||
autoPlayInterval,
|
||||
loop,
|
||||
}}
|
||||
>
|
||||
<View className={cn("relative", className)}>
|
||||
<RNCarousel
|
||||
ref={carouselRef}
|
||||
loop={loop}
|
||||
width={width}
|
||||
height={height}
|
||||
autoPlay={autoPlay}
|
||||
autoPlayInterval={autoPlayInterval}
|
||||
data={items}
|
||||
scrollAnimationDuration={500}
|
||||
vertical={orientation === "vertical"}
|
||||
renderItem={({ item }) => item as React.ReactElement}
|
||||
onSnapToItem={onIndexChange}
|
||||
/>
|
||||
</View>
|
||||
</CarouselContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselContent({
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
// CarouselContent 在新架构中主要用于包裹 CarouselItem
|
||||
// 实际渲染由 Carousel 的 renderItem 处理
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function CarouselItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
const { width, height } = useCarousel();
|
||||
|
||||
return (
|
||||
<View
|
||||
className={cn("flex justify-center items-center", className)}
|
||||
style={{ width, height }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export { Carousel, CarouselContent, CarouselItem, useCarousel };
|
||||
137
components/ui/delete-confirm-dialog.tsx
Normal file
137
components/ui/delete-confirm-dialog.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { StyleSheet, Platform } from 'react-native'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
DeleteAlertDialogAction,
|
||||
CancelAlertDialogAction,
|
||||
} from './alert-dialog'
|
||||
|
||||
interface DeleteConfirmDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function DeleteConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
title = '删除该条记录?',
|
||||
description = '删除后不可恢复',
|
||||
}: DeleteConfirmDialogProps) {
|
||||
const handleConfirm = () => {
|
||||
onConfirm()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent style={styles.dialogContent}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle style={styles.dialogTitle}>
|
||||
{title}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription style={styles.dialogDescription}>
|
||||
{description}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter style={styles.dialogFooter}>
|
||||
<CancelAlertDialogAction
|
||||
onPress={() => onOpenChange(false)}
|
||||
style={styles.cancelButton}
|
||||
/>
|
||||
<DeleteAlertDialogAction
|
||||
onPress={handleConfirm}
|
||||
style={styles.confirmDeleteButton}
|
||||
/>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
dialogContent: {
|
||||
backgroundColor: '#1C1E22',
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
width: '90%',
|
||||
maxWidth: 320,
|
||||
borderWidth: 0,
|
||||
// Android 阴影效果
|
||||
...Platform.select({
|
||||
android: {
|
||||
elevation: 24,
|
||||
},
|
||||
ios: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 8,
|
||||
},
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 16,
|
||||
},
|
||||
}),
|
||||
},
|
||||
dialogTitle: {
|
||||
color: '#F5F5F5',
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
textAlign: 'center',
|
||||
marginBottom: 8,
|
||||
// iOS 字体渲染优化
|
||||
...Platform.select({
|
||||
ios: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
}),
|
||||
},
|
||||
dialogDescription: {
|
||||
color: '#8A8A8A',
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
dialogFooter: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginTop: 24,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
cancelButton: {
|
||||
flex: 1,
|
||||
height: 44,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#262A31',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
// Android 触摸反馈
|
||||
...Platform.select({
|
||||
android: {
|
||||
elevation: 0,
|
||||
},
|
||||
}),
|
||||
},
|
||||
confirmDeleteButton: {
|
||||
flex: 1,
|
||||
height: 44,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#FA3F2C',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
// Android 触摸反馈
|
||||
...Platform.select({
|
||||
android: {
|
||||
elevation: 0,
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
443
components/ui/drawer.tsx
Normal file
443
components/ui/drawer.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
import React, { useEffect, useImperativeHandle, forwardRef, ReactNode, useCallback, useMemo } from 'react'
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
useWindowDimensions,
|
||||
BackHandler,
|
||||
Modal,
|
||||
Platform,
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { CloseIcon } from '@/components/icon'
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
withTiming,
|
||||
runOnJS,
|
||||
interpolate,
|
||||
Extrapolation,
|
||||
} from 'react-native-reanimated'
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
||||
|
||||
export type DrawerPosition = 'bottom' | 'top' | 'left' | 'right'
|
||||
|
||||
export interface DrawerProps {
|
||||
/**
|
||||
* 是否显示抽屉
|
||||
*/
|
||||
visible: boolean
|
||||
/**
|
||||
* 关闭回调
|
||||
*/
|
||||
onClose: () => void
|
||||
/**
|
||||
* 抽屉内容
|
||||
*/
|
||||
children: ReactNode
|
||||
/**
|
||||
* 抽屉位置,默认为 'bottom'
|
||||
*/
|
||||
position?: DrawerPosition
|
||||
/**
|
||||
* 抽屉高度(position 为 'top' 或 'bottom' 时使用)
|
||||
*/
|
||||
height?: number | string
|
||||
/**
|
||||
* 抽屉宽度(position 为 'left' 或 'right' 时使用)
|
||||
*/
|
||||
width?: number | string
|
||||
/**
|
||||
* 是否显示遮罩层,默认为 true
|
||||
*/
|
||||
showBackdrop?: boolean
|
||||
/**
|
||||
* 遮罩层颜色,默认为 'rgba(0, 0, 0, 0.5)'
|
||||
*/
|
||||
backdropColor?: string
|
||||
/**
|
||||
* 点击遮罩层是否关闭,默认为 true
|
||||
*/
|
||||
closeOnBackdropPress?: boolean
|
||||
/**
|
||||
* 是否支持手势拖拽关闭,默认为 true
|
||||
*/
|
||||
enableGesture?: boolean
|
||||
/**
|
||||
* 拖拽关闭的阈值(速度),默认为 500
|
||||
*/
|
||||
swipeVelocityThreshold?: number
|
||||
/**
|
||||
* 自定义样式
|
||||
*/
|
||||
style?: any
|
||||
/**
|
||||
* 遮罩层样式
|
||||
*/
|
||||
backdropStyle?: any
|
||||
/**
|
||||
* 是否显示关闭按钮,默认为 true
|
||||
*/
|
||||
showCloseButton?: boolean
|
||||
}
|
||||
|
||||
export interface DrawerRef {
|
||||
/**
|
||||
* 关闭抽屉
|
||||
*/
|
||||
close: () => void
|
||||
}
|
||||
|
||||
const Drawer = forwardRef<DrawerRef, DrawerProps>(
|
||||
(
|
||||
{
|
||||
visible,
|
||||
onClose,
|
||||
children,
|
||||
position = 'bottom',
|
||||
height,
|
||||
width,
|
||||
showBackdrop = true,
|
||||
backdropColor = 'rgba(0, 0, 0, 0.5)',
|
||||
closeOnBackdropPress = true,
|
||||
enableGesture = true,
|
||||
swipeVelocityThreshold = 500,
|
||||
style,
|
||||
backdropStyle,
|
||||
showCloseButton = true,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { width: screenWidth, height: screenHeight } = useWindowDimensions()
|
||||
const insets = useSafeAreaInsets()
|
||||
const translateX = useSharedValue(0)
|
||||
const translateY = useSharedValue(0)
|
||||
const backdropOpacity = useSharedValue(0)
|
||||
|
||||
// 计算抽屉尺寸
|
||||
const drawerHeight =
|
||||
height === undefined
|
||||
? screenHeight * 0.6
|
||||
: typeof height === 'string'
|
||||
? (parseFloat(height) / 100) * screenHeight
|
||||
: height
|
||||
const drawerWidth =
|
||||
width === undefined
|
||||
? screenWidth * 0.8
|
||||
: typeof width === 'string'
|
||||
? (parseFloat(width) / 100) * screenWidth
|
||||
: width
|
||||
|
||||
// 根据位置计算最大偏移量
|
||||
const getMaxOffset = () => {
|
||||
switch (position) {
|
||||
case 'bottom':
|
||||
return drawerHeight
|
||||
case 'top':
|
||||
return -drawerHeight
|
||||
case 'left':
|
||||
return -drawerWidth
|
||||
case 'right':
|
||||
return drawerWidth
|
||||
default:
|
||||
return drawerHeight
|
||||
}
|
||||
}
|
||||
|
||||
const maxOffset = getMaxOffset()
|
||||
const isHorizontal = position === 'left' || position === 'right'
|
||||
|
||||
// 关闭抽屉
|
||||
const close = useCallback(() => {
|
||||
translateX.value = 0
|
||||
translateY.value = 0
|
||||
backdropOpacity.value = withTiming(0, { duration: 200 })
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
close,
|
||||
}), [close])
|
||||
|
||||
// 处理返回键(仅 Android)
|
||||
useEffect(() => {
|
||||
if (!visible || Platform.OS !== 'android') return
|
||||
|
||||
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
close()
|
||||
return true
|
||||
})
|
||||
|
||||
return () => backHandler.remove()
|
||||
}, [visible, close])
|
||||
|
||||
// 显示/隐藏动画
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
backdropOpacity.value = withTiming(1, { duration: 200 })
|
||||
translateX.value = 0
|
||||
translateY.value = 0
|
||||
} else {
|
||||
backdropOpacity.value = withTiming(0, { duration: 200 })
|
||||
translateX.value = 0
|
||||
translateY.value = 0
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
// 手势处理
|
||||
const pan = Gesture.Pan()
|
||||
.enabled(enableGesture && visible)
|
||||
.onUpdate((event) => {
|
||||
if (isHorizontal) {
|
||||
const newTranslateX = event.translationX
|
||||
// 根据位置限制拖拽方向
|
||||
if (position === 'left' && newTranslateX > 0) {
|
||||
translateX.value = newTranslateX
|
||||
} else if (position === 'right' && newTranslateX < 0) {
|
||||
translateX.value = newTranslateX
|
||||
} else if (position === 'left' || position === 'right') {
|
||||
translateX.value = newTranslateX
|
||||
}
|
||||
} else {
|
||||
const newTranslateY = event.translationY
|
||||
// 根据位置限制拖拽方向
|
||||
if (position === 'bottom' && newTranslateY > 0) {
|
||||
translateY.value = newTranslateY
|
||||
} else if (position === 'top' && newTranslateY < 0) {
|
||||
translateY.value = newTranslateY
|
||||
} else if (position === 'bottom' || position === 'top') {
|
||||
translateY.value = newTranslateY
|
||||
}
|
||||
}
|
||||
|
||||
// 更新遮罩层透明度
|
||||
const currentOffset = isHorizontal ? translateX.value : translateY.value
|
||||
const opacity = interpolate(
|
||||
Math.abs(currentOffset),
|
||||
[0, Math.abs(maxOffset)],
|
||||
[1, 0],
|
||||
Extrapolation.CLAMP,
|
||||
)
|
||||
backdropOpacity.value = opacity
|
||||
})
|
||||
.onEnd((event) => {
|
||||
const velocity = isHorizontal ? event.velocityX : event.velocityY
|
||||
const translation = isHorizontal ? translateX.value : translateY.value
|
||||
|
||||
// 判断是否应该关闭
|
||||
const shouldClose =
|
||||
Math.abs(velocity) > swipeVelocityThreshold ||
|
||||
Math.abs(translation) > Math.abs(maxOffset) * 0.5
|
||||
|
||||
if (shouldClose) {
|
||||
// 关闭动画
|
||||
if (isHorizontal) {
|
||||
translateX.value = withSpring(
|
||||
maxOffset,
|
||||
{
|
||||
velocity: event.velocityX,
|
||||
damping: 20,
|
||||
stiffness: 90,
|
||||
},
|
||||
() => {
|
||||
runOnJS(close)()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
translateY.value = withSpring(
|
||||
maxOffset,
|
||||
{
|
||||
velocity: event.velocityY,
|
||||
damping: 20,
|
||||
stiffness: 90,
|
||||
},
|
||||
() => {
|
||||
runOnJS(close)()
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// 回弹动画
|
||||
if (isHorizontal) {
|
||||
translateX.value = withSpring(0, {
|
||||
velocity: event.velocityX,
|
||||
damping: 20,
|
||||
stiffness: 90,
|
||||
})
|
||||
} else {
|
||||
translateY.value = withSpring(0, {
|
||||
velocity: event.velocityY,
|
||||
damping: 20,
|
||||
stiffness: 90,
|
||||
})
|
||||
}
|
||||
backdropOpacity.value = withTiming(1, { duration: 200 })
|
||||
}
|
||||
})
|
||||
|
||||
// 计算安全区域样式
|
||||
const safeAreaStyle = useMemo(() => {
|
||||
const safeStyle: any = {}
|
||||
if (Platform.OS === 'ios') {
|
||||
switch (position) {
|
||||
case 'bottom':
|
||||
safeStyle.paddingBottom = insets.bottom
|
||||
break
|
||||
case 'top':
|
||||
safeStyle.paddingTop = insets.top
|
||||
break
|
||||
case 'left':
|
||||
case 'right':
|
||||
safeStyle.paddingTop = insets.top
|
||||
safeStyle.paddingBottom = insets.bottom
|
||||
break
|
||||
}
|
||||
}
|
||||
return safeStyle
|
||||
}, [position, insets])
|
||||
|
||||
// 抽屉内容动画样式
|
||||
const drawerAnimatedStyle = useAnimatedStyle(() => {
|
||||
const baseStyle: any = {
|
||||
overflow: position === 'bottom' ? 'visible' : 'hidden',
|
||||
}
|
||||
|
||||
switch (position) {
|
||||
case 'bottom':
|
||||
baseStyle.transform = [{ translateY: translateY.value }]
|
||||
baseStyle.bottom = 0
|
||||
baseStyle.left = 0
|
||||
baseStyle.right = 0
|
||||
baseStyle.height = drawerHeight
|
||||
baseStyle.borderTopLeftRadius = 20
|
||||
baseStyle.borderTopRightRadius = 20
|
||||
break
|
||||
case 'top':
|
||||
baseStyle.transform = [{ translateY: translateY.value }]
|
||||
baseStyle.top = 0
|
||||
baseStyle.left = 0
|
||||
baseStyle.right = 0
|
||||
baseStyle.height = drawerHeight
|
||||
baseStyle.borderBottomLeftRadius = 20
|
||||
baseStyle.borderBottomRightRadius = 20
|
||||
break
|
||||
case 'left':
|
||||
baseStyle.transform = [{ translateX: translateX.value }]
|
||||
baseStyle.left = 0
|
||||
baseStyle.top = 0
|
||||
baseStyle.bottom = 0
|
||||
baseStyle.width = drawerWidth
|
||||
baseStyle.borderTopRightRadius = 20
|
||||
baseStyle.borderBottomRightRadius = 20
|
||||
break
|
||||
case 'right':
|
||||
baseStyle.transform = [{ translateX: translateX.value }]
|
||||
baseStyle.right = 0
|
||||
baseStyle.top = 0
|
||||
baseStyle.bottom = 0
|
||||
baseStyle.width = drawerWidth
|
||||
baseStyle.borderTopLeftRadius = 20
|
||||
baseStyle.borderBottomLeftRadius = 20
|
||||
break
|
||||
}
|
||||
|
||||
return baseStyle
|
||||
}, [position, drawerHeight, drawerWidth])
|
||||
|
||||
// 遮罩层动画样式
|
||||
const backdropAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: backdropOpacity.value,
|
||||
}
|
||||
})
|
||||
|
||||
if (!visible) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
visible={visible}
|
||||
animationType="none"
|
||||
onRequestClose={close}
|
||||
statusBarTranslucent={Platform.OS === 'android'}
|
||||
>
|
||||
<View style={styles.container}>
|
||||
{/* 遮罩层 */}
|
||||
{showBackdrop && (
|
||||
<AnimatedPressable
|
||||
style={[
|
||||
styles.backdrop,
|
||||
backdropAnimatedStyle,
|
||||
{ backgroundColor: backdropColor },
|
||||
backdropStyle,
|
||||
]}
|
||||
onPress={closeOnBackdropPress ? close : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 抽屉内容 */}
|
||||
<GestureDetector gesture={pan}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.drawer,
|
||||
drawerAnimatedStyle,
|
||||
getPositionStyles(position),
|
||||
safeAreaStyle,
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{showCloseButton && (
|
||||
<Pressable
|
||||
style={styles.closeButton}
|
||||
onPress={close}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<CloseIcon />
|
||||
</Pressable>
|
||||
)}
|
||||
{children}
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
</Modal>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Drawer.displayName = 'Drawer'
|
||||
|
||||
// 根据位置获取样式(圆角已在动画样式中设置)
|
||||
const getPositionStyles = (position: DrawerPosition) => {
|
||||
return {}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
drawer: {
|
||||
position: 'absolute',
|
||||
backgroundColor: '#1C1E20',
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
top: Platform.select({ ios: 16, android: 16, default: 16 }),
|
||||
right: Platform.select({ ios: 16, android: 16, default: 16 }),
|
||||
zIndex: 10,
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
export default Drawer
|
||||
|
||||
402
components/ui/dropdown.tsx
Normal file
402
components/ui/dropdown.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
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',
|
||||
},
|
||||
})
|
||||
|
||||
8
components/ui/index.ts
Normal file
8
components/ui/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { default as Block } from './Block'
|
||||
export { default as Text } from './Text'
|
||||
export { default as Img } from './Img'
|
||||
export { default as ModalPortal } from './ModalPortal'
|
||||
export { default as Toast } from './Toast'
|
||||
export { default as StartGeneratingNotification } from './notifications/StartGeneratingNotification'
|
||||
export { default as Dropdown } from './dropdown'
|
||||
export type { DropdownOption, DropdownProps } from './dropdown'
|
||||
66
components/ui/media.tsx
Normal file
66
components/ui/media.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { forwardRef, useMemo } from "react";
|
||||
import { VideoView } from "expo-video";
|
||||
import Image from "./Img";
|
||||
import { VideoPlayer, type VideoPlayerProps } from "./video";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type MediaType = "image" | "video";
|
||||
|
||||
interface MediaProps extends Omit<VideoPlayerProps, "source"> {
|
||||
source: string | { uri: string };
|
||||
type?: MediaType;
|
||||
poster?: string;
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
const VIDEO_EXTENSIONS = /\.(mp4|mov|avi|mkv|webm|m4v|flv|wmv|3gp)$/i;
|
||||
const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i;
|
||||
|
||||
const detectMediaType = (source: string | { uri: string }): MediaType => {
|
||||
const uri = typeof source === "string" ? source : source.uri;
|
||||
|
||||
if (VIDEO_EXTENSIONS.test(uri)) {
|
||||
return "video";
|
||||
}
|
||||
|
||||
if (IMAGE_EXTENSIONS.test(uri)) {
|
||||
return "image";
|
||||
}
|
||||
|
||||
return "image";
|
||||
};
|
||||
|
||||
const Media = forwardRef<VideoView, MediaProps>(
|
||||
({ source, type, poster, visible = true, className, loop = true, ...props }, ref) => {
|
||||
const mediaType = useMemo(
|
||||
() => type || detectMediaType(source),
|
||||
[source, type]
|
||||
);
|
||||
if (mediaType === "video") {
|
||||
return (
|
||||
<VideoPlayer
|
||||
ref={ref}
|
||||
source={source}
|
||||
poster={poster}
|
||||
visible={visible}
|
||||
className={cn('w-full h-full', className)}
|
||||
autoPlay
|
||||
loop={loop}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
source={source}
|
||||
className={cn('w-full h-full', className)}
|
||||
contentFit="cover"
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Media.displayName = "Media";
|
||||
|
||||
export { Media, type MediaProps, type MediaType };
|
||||
142
components/ui/notifications/StartGeneratingNotification.tsx
Normal file
142
components/ui/notifications/StartGeneratingNotification.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React from 'react'
|
||||
import { ViewStyle, Pressable, StyleSheet, View, Text, Platform } from 'react-native'
|
||||
import { Image } from 'expo-image'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import { RightArrowIcon } from '@/components/icon'
|
||||
export interface NotificationProps {
|
||||
/**
|
||||
* 未读数量
|
||||
*/
|
||||
count?: number
|
||||
/**
|
||||
* 是否显示通知
|
||||
*/
|
||||
visible?: boolean
|
||||
/**
|
||||
* 点击事件
|
||||
*/
|
||||
onPress?: () => void
|
||||
/**
|
||||
* 自定义样式
|
||||
*/
|
||||
style?: ViewStyle
|
||||
/**
|
||||
* 最大显示数量,超过显示 99+
|
||||
*/
|
||||
maxCount?: number
|
||||
/**
|
||||
* 通知标题
|
||||
*/
|
||||
title?: string
|
||||
/**
|
||||
* 通知内容
|
||||
*/
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知组件
|
||||
* 基于 Figma 设计实现的通知横幅组件
|
||||
* 尺寸: 359x60
|
||||
*/
|
||||
const Notification: React.FC<NotificationProps> = ({
|
||||
count = 0,
|
||||
visible = true,
|
||||
onPress,
|
||||
style,
|
||||
title,
|
||||
message,
|
||||
}) => {
|
||||
const shouldShow = visible && count > 0
|
||||
|
||||
if (!shouldShow) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={[styles.container, style]}
|
||||
disabled={!onPress}
|
||||
>
|
||||
<BlurView
|
||||
intensity={Platform.OS === 'ios' ? 50 : 50}
|
||||
tint={Platform.OS === 'ios' ? 'dark' : 'default'}
|
||||
style={styles.notificationBanner}
|
||||
>
|
||||
<View style={styles.notificationBannerIcon}>
|
||||
<Image
|
||||
source={require('@/assets/images/generate.png')}
|
||||
style={styles.iconImage}
|
||||
contentFit="contain"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.contentWrapper}>
|
||||
{title && (
|
||||
<Text style={styles.title}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{message && (
|
||||
<Text style={styles.message} numberOfLines={1}>
|
||||
{message}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.badgeContainer}>
|
||||
<RightArrowIcon/>
|
||||
</View>
|
||||
</BlurView>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
},
|
||||
notificationBanner: {
|
||||
height: 60,
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderRadius: 12,
|
||||
backgroundColor: Platform.OS === 'ios' ? '#FFFFFF1A' : '#FFFFFF1A',
|
||||
overflow: 'hidden',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
notificationBannerIcon: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
iconImage: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
},
|
||||
contentWrapper: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
marginBottom: 2,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#F5F5F5',
|
||||
},
|
||||
message: {
|
||||
fontSize: 12,
|
||||
color: '#CCCCCC',
|
||||
},
|
||||
badgeContainer: {
|
||||
marginLeft: 12,
|
||||
minWidth: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
export default Notification
|
||||
|
||||
239
components/ui/tooltip.tsx
Normal file
239
components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
Modal,
|
||||
type ViewStyle,
|
||||
type TextStyle,
|
||||
LayoutChangeEvent,
|
||||
StyleSheet,
|
||||
} from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
type Side = "top" | "bottom" | "left" | "right";
|
||||
|
||||
interface TooltipContextValue {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
triggerRef: React.MutableRefObject<View | null>;
|
||||
side: Side;
|
||||
sideOffset: number;
|
||||
}
|
||||
|
||||
const TooltipContext = React.createContext<TooltipContextValue | null>(null);
|
||||
|
||||
const useTooltipContext = () => {
|
||||
const context = React.useContext(TooltipContext);
|
||||
if (!context) {
|
||||
throw new Error("Tooltip components must be used within TooltipProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface TooltipProviderProps {
|
||||
children: React.ReactNode;
|
||||
delayDuration?: number;
|
||||
}
|
||||
|
||||
const TooltipProvider: React.FC<TooltipProviderProps> = ({
|
||||
children,
|
||||
delayDuration = 200,
|
||||
}) => {
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
interface TooltipProps {
|
||||
children: React.ReactNode;
|
||||
side?: Side;
|
||||
sideOffset?: number;
|
||||
}
|
||||
|
||||
const Tooltip: React.FC<TooltipProps> = ({
|
||||
children,
|
||||
side = "top",
|
||||
sideOffset = 8,
|
||||
}) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const triggerRef = React.useRef<View | null>(null);
|
||||
|
||||
const value: TooltipContextValue = {
|
||||
open,
|
||||
setOpen,
|
||||
triggerRef,
|
||||
side,
|
||||
sideOffset,
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
interface TooltipTriggerProps {
|
||||
children: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
const TooltipTrigger = React.forwardRef<View, TooltipTriggerProps>(
|
||||
({ children, asChild, style }, ref) => {
|
||||
const { setOpen, triggerRef } = useTooltipContext();
|
||||
|
||||
const handleRef = (node: View | null) => {
|
||||
triggerRef.current = node;
|
||||
if (typeof ref === "function") {
|
||||
ref(node);
|
||||
} else if (ref) {
|
||||
ref.current = node;
|
||||
}
|
||||
};
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children as React.ReactElement<any>, {
|
||||
ref: handleRef,
|
||||
onPressIn: () => setOpen(true),
|
||||
onPressOut: () => setOpen(false),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
ref={handleRef as any}
|
||||
onPressIn={() => setOpen(true)}
|
||||
onPressOut={() => setOpen(false)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TooltipTrigger.displayName = "TooltipTrigger";
|
||||
|
||||
interface TooltipContentProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
style?: ViewStyle;
|
||||
textStyle?: TextStyle;
|
||||
}
|
||||
|
||||
const TooltipContent = React.forwardRef<View, TooltipContentProps>(
|
||||
({ children, className, style, textStyle }, ref) => {
|
||||
const { open, triggerRef, side, sideOffset } = useTooltipContext();
|
||||
const [layout, setLayout] = React.useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const [triggerLayout, setTriggerLayout] = React.useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
|
||||
const opacity = useSharedValue(0);
|
||||
const scale = useSharedValue(0.95);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open && triggerRef.current) {
|
||||
triggerRef.current.measure((x, y, width, height, pageX, pageY) => {
|
||||
setTriggerLayout({ x: pageX, y: pageY, width, height });
|
||||
});
|
||||
opacity.value = withTiming(1, { duration: 200 });
|
||||
scale.value = withSpring(1, { damping: 15, stiffness: 300 });
|
||||
} else {
|
||||
opacity.value = withTiming(0, { duration: 150 });
|
||||
scale.value = withTiming(0.95, { duration: 150 });
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
const handleLayout = (event: LayoutChangeEvent) => {
|
||||
const { width, height } = event.nativeEvent.layout;
|
||||
setLayout({ width, height });
|
||||
};
|
||||
|
||||
const getPosition = (): ViewStyle => {
|
||||
if (!triggerLayout || !layout) return {};
|
||||
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
|
||||
switch (side) {
|
||||
case "top":
|
||||
top = triggerLayout.y - layout.height - sideOffset;
|
||||
left = triggerLayout.x + triggerLayout.width / 2 - layout.width / 2;
|
||||
break;
|
||||
case "bottom":
|
||||
top = triggerLayout.y + triggerLayout.height + sideOffset;
|
||||
left = triggerLayout.x + triggerLayout.width / 2 - layout.width / 2;
|
||||
break;
|
||||
case "left":
|
||||
top = triggerLayout.y + triggerLayout.height / 2 - layout.height / 2;
|
||||
left = triggerLayout.x - layout.width - sideOffset;
|
||||
break;
|
||||
case "right":
|
||||
top = triggerLayout.y + triggerLayout.height / 2 - layout.height / 2;
|
||||
left = triggerLayout.x + triggerLayout.width + sideOffset;
|
||||
break;
|
||||
}
|
||||
|
||||
return { position: "absolute", top, left };
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<Modal transparent visible={open} animationType="none">
|
||||
<Animated.View
|
||||
ref={ref as any}
|
||||
onLayout={handleLayout}
|
||||
style={[
|
||||
styles.tooltip,
|
||||
getPosition(),
|
||||
animatedStyle,
|
||||
style,
|
||||
]}
|
||||
className={cn(
|
||||
"rounded-md border border-border bg-popover px-3 py-1.5 shadow-lg",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{typeof children === "string" ? (
|
||||
<Text
|
||||
className="text-sm text-popover-foreground"
|
||||
style={textStyle}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TooltipContent.displayName = "TooltipContent";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tooltip: {
|
||||
zIndex: 9999,
|
||||
},
|
||||
});
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
136
components/ui/video.tsx
Normal file
136
components/ui/video.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useVideoPlayer, VideoView } from "expo-video";
|
||||
import { forwardRef, useEffect, useState } from "react";
|
||||
import { View, Pressable, Text, ActivityIndicator } from "react-native";
|
||||
import { cn } from "../../lib/utils";
|
||||
import Image from "./Img";
|
||||
|
||||
interface VideoPlayerProps {
|
||||
source: string | { uri: string };
|
||||
poster?: string;
|
||||
visible?: boolean;
|
||||
className?: string;
|
||||
autoPlay?: boolean;
|
||||
loop?: boolean;
|
||||
muted?: boolean;
|
||||
controls?: boolean;
|
||||
onError?: (error: string) => void;
|
||||
onLoad?: () => void;
|
||||
}
|
||||
|
||||
const VideoPlayer = forwardRef<VideoView, VideoPlayerProps>(
|
||||
(
|
||||
{
|
||||
source,
|
||||
poster,
|
||||
visible = true,
|
||||
className,
|
||||
autoPlay = false,
|
||||
loop = false,
|
||||
muted = false,
|
||||
controls = true,
|
||||
onError,
|
||||
onLoad,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const videoSource = typeof source === "string" ? { uri: source } : source;
|
||||
|
||||
const player = useVideoPlayer(videoSource, (player) => {
|
||||
player.loop = loop;
|
||||
player.muted = muted;
|
||||
});
|
||||
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showPoster, setShowPoster] = useState(!!poster);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
player.pause();
|
||||
if (poster) {
|
||||
setShowPoster(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoPlay && visible) {
|
||||
player.play();
|
||||
}
|
||||
}, [visible, autoPlay, player, poster]);
|
||||
|
||||
useEffect(() => {
|
||||
const playingSubscription = player.addListener("playingChange", ({ isPlaying }) => {
|
||||
setIsPlaying(isPlaying);
|
||||
if (isPlaying) {
|
||||
setShowPoster(false);
|
||||
}
|
||||
});
|
||||
|
||||
const statusSubscription = player.addListener("statusChange", (payload) => {
|
||||
if (payload.status === "readyToPlay") {
|
||||
setIsLoading(false);
|
||||
onLoad?.();
|
||||
}
|
||||
|
||||
if (payload.status === "error" && payload.error) {
|
||||
onError?.(payload.error.message);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
playingSubscription.remove();
|
||||
statusSubscription.remove();
|
||||
};
|
||||
}, [player, onError, onLoad]);
|
||||
|
||||
const togglePlayback = () => {
|
||||
if (isPlaying) {
|
||||
player.pause();
|
||||
} else {
|
||||
player.play();
|
||||
setShowPoster(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className={cn("!relative !bg-black !overflow-hidden", className)}>
|
||||
{visible && (
|
||||
<VideoView
|
||||
ref={ref}
|
||||
player={player}
|
||||
className="!absolute !top-0 !left-0 !bottom-0 !right-0 !inset-0 !w-full !h-full"
|
||||
style={{ width: '100%', height: '100%', position: 'absolute', left: 0, top: 0 }}
|
||||
nativeControls={false}
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPoster && poster && (
|
||||
<Image
|
||||
source={poster}
|
||||
className="!absolute !top-0 !left-0 !bottom-0 !right-0 !inset-0 !w-full !h-full"
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{controls && !isLoading && visible && (
|
||||
<Pressable
|
||||
onPress={togglePlayback}
|
||||
className="absolute inset-0 items-center justify-center active:opacity-80"
|
||||
>
|
||||
{(!isPlaying || showPoster) && (
|
||||
<View className="w-16 h-16 items-center justify-center bg-black/50 rounded-full">
|
||||
<Text className="text-white text-2xl">▶</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
VideoPlayer.displayName = "VideoPlayer";
|
||||
|
||||
export { VideoPlayer, type VideoPlayerProps };
|
||||
Reference in New Issue
Block a user