feat: add BottomModal and Select components for Android (#4010)

* feat: add BottomModal component

* feat: implement Select component with modal for Android
This commit is contained in:
Whitewater 2025-06-26 03:29:03 +08:00 committed by GitHub
parent 35c713a82a
commit 9251204f19
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 318 additions and 0 deletions

View File

@ -0,0 +1,154 @@
import { cn } from "@follow/utils/utils"
import { useCallback, useState } from "react"
import type { StyleProp, ViewStyle } from "react-native"
import { FlatList, StyleSheet, Text, TouchableOpacity, View } from "react-native"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { CheckFilledIcon } from "@/src/icons/check_filled"
import { MingcuteDownLineIcon } from "@/src/icons/mingcute_down_line"
import { accentColor } from "@/src/theme/colors"
import { BottomModal } from "../modal/BottomModal"
import { FormLabel } from "./Label"
interface SelectProps<T> {
options: { label: string; value: T; subLabel?: string }[]
value: T
onValueChange: (value: T) => void
displayValue?: string
wrapperClassName?: string
wrapperStyle?: StyleProp<ViewStyle>
label?: string
}
export function Select<T>({
options,
value,
onValueChange,
displayValue,
wrapperClassName,
wrapperStyle,
label,
}: SelectProps<T>) {
const [isModalVisible, setModalVisible] = useState(false)
const selectedOption = options.find((opt) => opt.value === value)
const insets = useSafeAreaInsets()
const showOptions = useCallback(() => {
setModalVisible(true)
}, [])
const closeModal = useCallback(() => {
setModalVisible(false)
}, [])
const handleSelectOption = useCallback(
(optionValue: T) => {
onValueChange(optionValue)
closeModal()
},
[onValueChange, closeModal],
)
const renderOption = useCallback(
({ item }: { item: { label: string; value: T; subLabel?: string } }) => {
const isSelected = value === item.value
return (
<>
<TouchableOpacity
activeOpacity={0.7}
onPress={() => handleSelectOption(item.value)}
className="flex-row items-center justify-between p-4"
>
<View className="flex-1">
<Text
className={cn("text-label text-base", isSelected ? "font-semibold" : "font-normal")}
>
{item.label}
</Text>
{item.subLabel ? (
<Text className="text-secondary-label mt-0.5 text-xs">{item.subLabel}</Text>
) : null}
</View>
{isSelected && (
<View className="ml-2">
<CheckFilledIcon color={accentColor} height={20} width={20} />
</View>
)}
</TouchableOpacity>
<View
className={cn("bg-opaque-separator/70 ml-4")}
style={{ height: StyleSheet.hairlineWidth }}
/>
</>
)
},
[handleSelectOption, value],
)
const Trigger = (
<TouchableOpacity
className={cn(
"min-w-24 flex-1 shrink flex-row items-center rounded-lg pl-3",
wrapperClassName,
)}
hitSlop={30}
onPress={showOptions}
style={wrapperStyle}
>
<Text
className="text-accent flex-1 text-right font-semibold"
ellipsizeMode="middle"
numberOfLines={1}
>
{displayValue || selectedOption?.label || "Select"}
</Text>
<View className="ml-auto shrink-0 pl-1">
<MingcuteDownLineIcon color={accentColor} height={18} width={18} />
</View>
</TouchableOpacity>
)
const SelectModal = (
<BottomModal visible={isModalVisible} onClose={closeModal}>
<View className="border-opaque-separator flex-row items-center justify-between border-b p-4">
<Text className="text-label text-xl font-semibold">Select an option</Text>
<TouchableOpacity onPress={closeModal}>
<Text style={{ color: accentColor }} className="text-lg font-bold">
Done
</Text>
</TouchableOpacity>
</View>
<FlatList
data={options}
renderItem={renderOption}
keyExtractor={(item) => String(item.value)}
className="grow"
style={{
marginBottom: insets.bottom + 16,
}}
/>
</BottomModal>
)
if (!label) {
return (
<>
{Trigger}
{SelectModal}
</>
)
}
return (
<>
<View className="flex-1 flex-row items-center justify-between">
<FormLabel className="pl-2" label={label} />
<View className="flex-1">{Trigger}</View>
</View>
{SelectModal}
</>
)
}

View File

@ -0,0 +1,164 @@
import { cn } from "@follow/utils/utils"
import type { ReactNode } from "react"
import { useCallback, useEffect, useState } from "react"
import { Modal, Pressable } from "react-native"
import Animated, {
Easing,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated"
interface BottomModalProps {
/**
* Whether the modal is visible
*/
visible: boolean
/**
* Function to call when the modal should be closed (backdrop press or programmatically)
*/
onClose: () => void
/**
* Content to render inside the modal
*/
children: ReactNode
/**
* Modal container class name
*/
className?: string
/**
* Duration for the open animation in milliseconds
* @default 300
*/
openDuration?: number
/**
* Duration for the close animation in milliseconds
* @default 250
*/
closeDuration?: number
/**
* Allow closing the modal by tapping on the backdrop
* @default true
*/
closeOnBackdropPress?: boolean
/**
* Backdrop opacity when fully visible
* @default 0.5
*/
backdropOpacity?: number
}
/**
* An animated modal that slides up from the bottom of the screen with a fading backdrop.
* Great for bottom sheets, pickers, and other content that should appear from the bottom.
*/
export function BottomModal({
visible,
onClose,
children,
className = "max-h-[40%]",
openDuration = 300,
closeDuration = 250,
closeOnBackdropPress = true,
backdropOpacity = 0.3,
}: BottomModalProps) {
const [internalVisible, setInternalVisible] = useState(false)
const backdropAnim = useSharedValue(0)
const contentTranslateY = useSharedValue(300)
const backdropStyle = useAnimatedStyle(() => ({
opacity: backdropAnim.value,
}))
const modalContentStyle = useAnimatedStyle(() => ({
transform: [{ translateY: contentTranslateY.value }],
}))
const showModal = useCallback(() => {
setInternalVisible(true)
backdropAnim.value = withTiming(backdropOpacity, {
duration: openDuration,
easing: Easing.out(Easing.cubic),
})
contentTranslateY.value = withTiming(0, {
duration: openDuration,
easing: Easing.out(Easing.cubic),
})
}, [backdropAnim, contentTranslateY, backdropOpacity, openDuration])
const hideModal = useCallback(() => {
backdropAnim.value = withTiming(0, {
duration: closeDuration,
easing: Easing.in(Easing.cubic),
})
contentTranslateY.value = withTiming(
300,
{
duration: closeDuration,
easing: Easing.in(Easing.cubic),
},
() => {
runOnJS(setInternalVisible)(false)
runOnJS(onClose)()
},
)
}, [backdropAnim, contentTranslateY, closeDuration, onClose])
const handleBackdropPress = useCallback(() => {
if (closeOnBackdropPress) {
hideModal()
}
}, [closeOnBackdropPress, hideModal])
// Start animations when visibility changes
useEffect(() => {
if (visible) {
showModal()
} else if (internalVisible) {
hideModal()
}
}, [visible, showModal, hideModal, internalVisible])
if (!internalVisible) {
return null
}
return (
<Modal
visible={internalVisible}
transparent={true}
animationType="none"
onRequestClose={hideModal}
statusBarTranslucent
navigationBarTranslucent
>
<Animated.View className="absolute inset-0 bg-black" style={backdropStyle}>
<Pressable
className="flex-1"
onPress={handleBackdropPress}
android_ripple={{ color: "white" }}
/>
</Animated.View>
<Animated.View
className={cn(
"bg-system-background mt-auto flex-1 overflow-hidden rounded-t-2xl",
className,
)}
style={modalContentStyle}
>
{children}
</Animated.View>
</Modal>
)
}