Khởi tạo ban đầu
This commit is contained in:
45
components/ui/collapsible.tsx
Normal file
45
components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-theme-context';
|
||||
|
||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const theme = useColorScheme();
|
||||
|
||||
return (
|
||||
<ThemedView>
|
||||
<TouchableOpacity
|
||||
style={styles.heading}
|
||||
onPress={() => setIsOpen((value) => !value)}
|
||||
activeOpacity={0.8}>
|
||||
<IconSymbol
|
||||
name="chevron.right"
|
||||
size={18}
|
||||
weight="medium"
|
||||
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
||||
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
||||
/>
|
||||
|
||||
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
heading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
content: {
|
||||
marginTop: 6,
|
||||
marginLeft: 24,
|
||||
},
|
||||
});
|
||||
32
components/ui/icon-symbol.ios.tsx
Normal file
32
components/ui/icon-symbol.ios.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
|
||||
import { StyleProp, ViewStyle } from 'react-native';
|
||||
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
weight = 'regular',
|
||||
}: {
|
||||
name: SymbolViewProps['name'];
|
||||
size?: number;
|
||||
color: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return (
|
||||
<SymbolView
|
||||
weight={weight}
|
||||
tintColor={color}
|
||||
resizeMode="scaleAspectFit"
|
||||
name={name}
|
||||
style={[
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
61
components/ui/icon-symbol.tsx
Normal file
61
components/ui/icon-symbol.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
// Fallback for using MaterialIcons on Android and web.
|
||||
|
||||
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||
import { SymbolViewProps, SymbolWeight } from "expo-symbols";
|
||||
import { ComponentProps } from "react";
|
||||
import { OpaqueColorValue, type StyleProp, type TextStyle } from "react-native";
|
||||
|
||||
type IconMapping = Record<
|
||||
SymbolViewProps["name"],
|
||||
ComponentProps<typeof MaterialIcons>["name"]
|
||||
>;
|
||||
type IconSymbolName = keyof typeof MAPPING;
|
||||
|
||||
/**
|
||||
* Add your SF Symbols to Material Icons mappings here.
|
||||
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
|
||||
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
|
||||
*/
|
||||
const MAPPING = {
|
||||
gear: "settings",
|
||||
"paperplane.fill": "send",
|
||||
"chevron.left.forwardslash.chevron.right": "code",
|
||||
"chevron.right": "chevron-right",
|
||||
"ferry.fill": "directions-boat",
|
||||
"map.fill": "map",
|
||||
"chevron.down": "arrow-drop-down",
|
||||
"chevron.up": "arrow-drop-up",
|
||||
"exclamationmark.triangle.fill": "warning",
|
||||
"book.closed.fill": "book",
|
||||
"dot.radiowaves.left.and.right": "sensors",
|
||||
xmark: "close",
|
||||
pencil: "edit",
|
||||
trash: "delete",
|
||||
} as IconMapping;
|
||||
|
||||
/**
|
||||
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
|
||||
* This ensures a consistent look across platforms, and optimal resource usage.
|
||||
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
|
||||
*/
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
}: {
|
||||
name: IconSymbolName;
|
||||
size?: number;
|
||||
color: string | OpaqueColorValue;
|
||||
style?: StyleProp<TextStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return (
|
||||
<MaterialIcons
|
||||
color={color}
|
||||
size={size}
|
||||
name={MAPPING[name]}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
578
components/ui/modal.tsx
Normal file
578
components/ui/modal.tsx
Normal file
@@ -0,0 +1,578 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { ReactNode, useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
Pressable,
|
||||
Modal as RNModal,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
// Types
|
||||
export interface ModalProps {
|
||||
/** Whether the modal dialog is visible or not */
|
||||
open?: boolean;
|
||||
/** The modal dialog's title */
|
||||
title?: ReactNode;
|
||||
/** Whether a close (x) button is visible on top right or not */
|
||||
closable?: boolean;
|
||||
/** Custom close icon */
|
||||
closeIcon?: ReactNode;
|
||||
/** Whether to close the modal dialog when the mask (area outside the modal) is clicked */
|
||||
maskClosable?: boolean;
|
||||
/** Centered Modal */
|
||||
centered?: boolean;
|
||||
/** Width of the modal dialog */
|
||||
width?: number | string;
|
||||
/** Whether to apply loading visual effect for OK button or not */
|
||||
confirmLoading?: boolean;
|
||||
/** Text of the OK button */
|
||||
okText?: string;
|
||||
/** Text of the Cancel button */
|
||||
cancelText?: string;
|
||||
/** Button type of the OK button */
|
||||
okType?: "primary" | "default" | "dashed" | "text" | "link";
|
||||
/** Footer content, set as footer={null} when you don't need default buttons */
|
||||
footer?: ReactNode | null;
|
||||
/** Whether show mask or not */
|
||||
mask?: boolean;
|
||||
/** The z-index of the Modal */
|
||||
zIndex?: number;
|
||||
/** Specify a function that will be called when a user clicks the OK button */
|
||||
onOk?: (e?: any) => void | Promise<void>;
|
||||
/** Specify a function that will be called when a user clicks mask, close button on top right or Cancel button */
|
||||
onCancel?: (e?: any) => void;
|
||||
/** Callback when the animation ends when Modal is turned on and off */
|
||||
afterOpenChange?: (open: boolean) => void;
|
||||
/** Specify a function that will be called when modal is closed completely */
|
||||
afterClose?: () => void;
|
||||
/** Custom className */
|
||||
className?: string;
|
||||
/** Modal body content */
|
||||
children?: ReactNode;
|
||||
/** Whether to unmount child components on close */
|
||||
destroyOnClose?: boolean;
|
||||
/** The ok button props */
|
||||
okButtonProps?: any;
|
||||
/** The cancel button props */
|
||||
cancelButtonProps?: any;
|
||||
/** Whether support press esc to close */
|
||||
keyboard?: boolean;
|
||||
}
|
||||
|
||||
export interface ConfirmModalProps extends Omit<ModalProps, "open"> {
|
||||
/** Type of the confirm modal */
|
||||
type?: "info" | "success" | "error" | "warning" | "confirm";
|
||||
/** Content */
|
||||
content?: ReactNode;
|
||||
/** Custom icon */
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
// Modal Component
|
||||
const Modal: React.FC<ModalProps> & {
|
||||
info: (props: ConfirmModalProps) => ModalInstance;
|
||||
success: (props: ConfirmModalProps) => ModalInstance;
|
||||
error: (props: ConfirmModalProps) => ModalInstance;
|
||||
warning: (props: ConfirmModalProps) => ModalInstance;
|
||||
confirm: (props: ConfirmModalProps) => ModalInstance;
|
||||
useModal: () => [
|
||||
{
|
||||
info: (props: ConfirmModalProps) => ModalInstance;
|
||||
success: (props: ConfirmModalProps) => ModalInstance;
|
||||
error: (props: ConfirmModalProps) => ModalInstance;
|
||||
warning: (props: ConfirmModalProps) => ModalInstance;
|
||||
confirm: (props: ConfirmModalProps) => ModalInstance;
|
||||
},
|
||||
ReactNode
|
||||
];
|
||||
} = ({
|
||||
open = false,
|
||||
title,
|
||||
closable = true,
|
||||
closeIcon,
|
||||
maskClosable = true,
|
||||
centered = false,
|
||||
width = 520,
|
||||
confirmLoading = false,
|
||||
okText = "OK",
|
||||
cancelText = "Cancel",
|
||||
okType = "primary",
|
||||
footer,
|
||||
mask = true,
|
||||
zIndex = 1000,
|
||||
onOk,
|
||||
onCancel,
|
||||
afterOpenChange,
|
||||
afterClose,
|
||||
className,
|
||||
children,
|
||||
destroyOnClose = false,
|
||||
okButtonProps,
|
||||
cancelButtonProps,
|
||||
keyboard = true,
|
||||
}) => {
|
||||
const [visible, setVisible] = useState(open);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(open);
|
||||
if (afterOpenChange) {
|
||||
afterOpenChange(open);
|
||||
}
|
||||
}, [open, afterOpenChange]);
|
||||
|
||||
const handleOk = async () => {
|
||||
if (onOk) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await onOk();
|
||||
// Không tự động đóng modal - để parent component quyết định
|
||||
} catch (error) {
|
||||
console.error("Modal onOk error:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
setVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
} else {
|
||||
// Nếu không có onCancel, tự động đóng modal
|
||||
setVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMaskPress = () => {
|
||||
if (maskClosable) {
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestClose = () => {
|
||||
if (keyboard) {
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible && afterClose) {
|
||||
const timer = setTimeout(() => {
|
||||
afterClose();
|
||||
}, 300); // Wait for animation to complete
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [visible, afterClose]);
|
||||
|
||||
const renderFooter = () => {
|
||||
if (footer === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (footer !== undefined) {
|
||||
return <View style={styles.footer}>{footer}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.cancelButton]}
|
||||
onPress={handleCancel}
|
||||
disabled={loading || confirmLoading}
|
||||
{...cancelButtonProps}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>{cancelText}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
styles.okButton,
|
||||
okType === "primary" && styles.primaryButton,
|
||||
(loading || confirmLoading) && styles.disabledButton,
|
||||
]}
|
||||
onPress={handleOk}
|
||||
disabled={loading || confirmLoading}
|
||||
{...okButtonProps}
|
||||
>
|
||||
{loading || confirmLoading ? (
|
||||
<ActivityIndicator color="#fff" size="small" />
|
||||
) : (
|
||||
<Text style={styles.okButtonText}>{okText}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const modalWidth =
|
||||
typeof width === "number" ? width : Dimensions.get("window").width * 0.9;
|
||||
|
||||
return (
|
||||
<RNModal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={handleRequestClose}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.overlay,
|
||||
centered && styles.centered,
|
||||
{ zIndex },
|
||||
!mask && styles.noMask,
|
||||
]}
|
||||
onPress={handleMaskPress}
|
||||
>
|
||||
<Pressable
|
||||
style={[styles.modal, { width: modalWidth, maxWidth: "90%" }]}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
{(title || closable) && (
|
||||
<View style={styles.header}>
|
||||
{title && (
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{closable && (
|
||||
<TouchableOpacity
|
||||
style={styles.closeButton}
|
||||
onPress={handleCancel}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
{closeIcon || (
|
||||
<Ionicons name="close" size={24} color="#666" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<View style={styles.body}>
|
||||
{(!destroyOnClose || visible) && children}
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
{renderFooter()}
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</RNModal>
|
||||
);
|
||||
};
|
||||
|
||||
// Confirm Modal Component
|
||||
const ConfirmModal: React.FC<
|
||||
ConfirmModalProps & { visible: boolean; onClose: () => void }
|
||||
> = ({
|
||||
visible,
|
||||
onClose,
|
||||
type = "confirm",
|
||||
title,
|
||||
content,
|
||||
icon,
|
||||
okText = "OK",
|
||||
cancelText = "Cancel",
|
||||
onOk,
|
||||
onCancel,
|
||||
...restProps
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const getIcon = () => {
|
||||
if (icon !== undefined) return icon;
|
||||
|
||||
const iconProps = { size: 24, style: { marginRight: 12 } };
|
||||
switch (type) {
|
||||
case "info":
|
||||
return (
|
||||
<Ionicons name="information-circle" color="#1890ff" {...iconProps} />
|
||||
);
|
||||
case "success":
|
||||
return (
|
||||
<Ionicons name="checkmark-circle" color="#52c41a" {...iconProps} />
|
||||
);
|
||||
case "error":
|
||||
return <Ionicons name="close-circle" color="#ff4d4f" {...iconProps} />;
|
||||
case "warning":
|
||||
return <Ionicons name="warning" color="#faad14" {...iconProps} />;
|
||||
default:
|
||||
return <Ionicons name="help-circle" color="#1890ff" {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const handleOk = async () => {
|
||||
if (onOk) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await onOk();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Confirm modal onOk error:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={visible}
|
||||
title={title}
|
||||
onOk={handleOk}
|
||||
onCancel={type === "confirm" ? handleCancel : undefined}
|
||||
okText={okText}
|
||||
cancelText={cancelText}
|
||||
confirmLoading={loading}
|
||||
footer={
|
||||
type === "confirm" ? undefined : (
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.okButton, styles.primaryButton]}
|
||||
onPress={handleOk}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" size="small" />
|
||||
) : (
|
||||
<Text style={styles.okButtonText}>{okText}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
{...restProps}
|
||||
>
|
||||
<View style={styles.confirmContent}>
|
||||
{getIcon()}
|
||||
<Text style={styles.confirmText}>{content}</Text>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// Modal Instance
|
||||
export interface ModalInstance {
|
||||
destroy: () => void;
|
||||
update: (config: ConfirmModalProps) => void;
|
||||
}
|
||||
|
||||
// Container for imperatively created modals - Not used in React Native
|
||||
// Static methods will return instance but won't render imperatively
|
||||
// Use Modal.useModal() hook for proper context support
|
||||
|
||||
const createConfirmModal = (config: ConfirmModalProps): ModalInstance => {
|
||||
console.warn(
|
||||
"Modal static methods are not fully supported in React Native. Please use Modal.useModal() hook for better context support."
|
||||
);
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
console.warn(
|
||||
"Modal.destroy() called but static modals are not supported in React Native"
|
||||
);
|
||||
},
|
||||
update: (newConfig: ConfirmModalProps) => {
|
||||
console.warn(
|
||||
"Modal.update() called but static modals are not supported in React Native"
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Static methods
|
||||
Modal.info = (props: ConfirmModalProps) =>
|
||||
createConfirmModal({ ...props, type: "info" });
|
||||
Modal.success = (props: ConfirmModalProps) =>
|
||||
createConfirmModal({ ...props, type: "success" });
|
||||
Modal.error = (props: ConfirmModalProps) =>
|
||||
createConfirmModal({ ...props, type: "error" });
|
||||
Modal.warning = (props: ConfirmModalProps) =>
|
||||
createConfirmModal({ ...props, type: "warning" });
|
||||
Modal.confirm = (props: ConfirmModalProps) =>
|
||||
createConfirmModal({ ...props, type: "confirm" });
|
||||
|
||||
// useModal hook
|
||||
Modal.useModal = () => {
|
||||
const [modals, setModals] = useState<ReactNode[]>([]);
|
||||
|
||||
const createModal = (
|
||||
config: ConfirmModalProps,
|
||||
type: ConfirmModalProps["type"]
|
||||
) => {
|
||||
const id = `modal-${Date.now()}-${Math.random()}`;
|
||||
|
||||
const destroy = () => {
|
||||
setModals((prev) => prev.filter((modal: any) => modal.key !== id));
|
||||
};
|
||||
|
||||
const update = (newConfig: ConfirmModalProps) => {
|
||||
setModals((prev) =>
|
||||
prev.map((modal: any) =>
|
||||
modal.key === id ? (
|
||||
<ConfirmModal
|
||||
key={id}
|
||||
visible={true}
|
||||
onClose={destroy}
|
||||
{...config}
|
||||
{...newConfig}
|
||||
type={type}
|
||||
/>
|
||||
) : (
|
||||
modal
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const modalElement = (
|
||||
<ConfirmModal
|
||||
key={id}
|
||||
visible={true}
|
||||
onClose={destroy}
|
||||
{...config}
|
||||
type={type}
|
||||
/>
|
||||
);
|
||||
|
||||
setModals((prev) => [...prev, modalElement]);
|
||||
|
||||
return { destroy, update };
|
||||
};
|
||||
|
||||
const modalMethods = {
|
||||
info: (props: ConfirmModalProps) => createModal(props, "info"),
|
||||
success: (props: ConfirmModalProps) => createModal(props, "success"),
|
||||
error: (props: ConfirmModalProps) => createModal(props, "error"),
|
||||
warning: (props: ConfirmModalProps) => createModal(props, "warning"),
|
||||
confirm: (props: ConfirmModalProps) => createModal(props, "confirm"),
|
||||
};
|
||||
|
||||
const contextHolder = <>{modals}</>;
|
||||
|
||||
return [modalMethods, contextHolder];
|
||||
};
|
||||
|
||||
// Styles
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.45)",
|
||||
justifyContent: "flex-start",
|
||||
paddingTop: 100,
|
||||
alignItems: "center",
|
||||
},
|
||||
centered: {
|
||||
justifyContent: "center",
|
||||
paddingTop: 0,
|
||||
},
|
||||
noMask: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
modal: {
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 8,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
maxHeight: "90%",
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#f0f0f0",
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
color: "#000",
|
||||
flex: 1,
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
marginLeft: 12,
|
||||
},
|
||||
body: {
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 20,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 20,
|
||||
paddingTop: 12,
|
||||
gap: 8,
|
||||
},
|
||||
button: {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 6,
|
||||
minWidth: 70,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: 36,
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: "#fff",
|
||||
borderWidth: 1,
|
||||
borderColor: "#d9d9d9",
|
||||
},
|
||||
cancelButtonText: {
|
||||
color: "#000",
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
},
|
||||
okButton: {
|
||||
backgroundColor: "#1890ff",
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: "#1890ff",
|
||||
},
|
||||
okButtonText: {
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
confirmContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
confirmText: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
color: "#000",
|
||||
lineHeight: 22,
|
||||
},
|
||||
});
|
||||
|
||||
export default Modal;
|
||||
262
components/ui/slice-switch.tsx
Normal file
262
components/ui/slice-switch.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Animated,
|
||||
OpaqueColorValue,
|
||||
Pressable,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from "react-native";
|
||||
|
||||
const SIZE_PRESETS = {
|
||||
sm: { width: 64, height: 32 },
|
||||
md: { width: 80, height: 40 },
|
||||
lg: { width: 96, height: 48 },
|
||||
} as const;
|
||||
|
||||
type SwitchSize = keyof typeof SIZE_PRESETS;
|
||||
|
||||
const DEFAULT_TOGGLE_DURATION = 400;
|
||||
|
||||
// Default both backgrounds to a grey tone when not provided
|
||||
const DEFAULT_INACTIVE_BG = "#D3DAD9";
|
||||
const DEFAULT_ACTIVE_BG = "#D3DAD9";
|
||||
const PRESSED_SCALE = 0.96;
|
||||
const PRESS_FEEDBACK_DURATION = 120;
|
||||
|
||||
type IoniconName = ComponentProps<typeof Ionicons>["name"];
|
||||
|
||||
type SliceSwitchProps = {
|
||||
size?: SwitchSize;
|
||||
leftIcon?: IoniconName;
|
||||
leftIconColor?: string | OpaqueColorValue | undefined;
|
||||
rightIconColor?: string | OpaqueColorValue | undefined;
|
||||
rightIcon?: IoniconName;
|
||||
duration?: number;
|
||||
activeBackgroundColor?: string;
|
||||
inactiveBackgroundColor?: string;
|
||||
inactiveOverlayColor?: string;
|
||||
activeOverlayColor?: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
onChange?: (value: boolean) => void;
|
||||
value?: boolean;
|
||||
};
|
||||
|
||||
const SliceSwitch = ({
|
||||
size = "md",
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
duration,
|
||||
activeBackgroundColor = DEFAULT_ACTIVE_BG,
|
||||
inactiveBackgroundColor = DEFAULT_INACTIVE_BG,
|
||||
leftIconColor = "#fff",
|
||||
rightIconColor = "#fff",
|
||||
inactiveOverlayColor = "#000",
|
||||
activeOverlayColor = "#000",
|
||||
style,
|
||||
onChange,
|
||||
value,
|
||||
}: SliceSwitchProps) => {
|
||||
const { width: containerWidth, height: containerHeight } =
|
||||
SIZE_PRESETS[size] ?? SIZE_PRESETS.md;
|
||||
const animationDuration = Math.max(duration ?? DEFAULT_TOGGLE_DURATION, 0);
|
||||
const [isOn, setIsOn] = useState(value ?? false);
|
||||
const [bgOn, setBgOn] = useState(value ?? false);
|
||||
const progress = useRef(new Animated.Value(value ? 1 : 0)).current;
|
||||
const pressScale = useRef(new Animated.Value(1)).current;
|
||||
const overlayTranslateX = useRef(
|
||||
new Animated.Value(value ? containerWidth / 2 : 0)
|
||||
).current;
|
||||
const listenerIdRef = useRef<string | number | null>(null);
|
||||
|
||||
// Sync with external value prop if provided
|
||||
useEffect(() => {
|
||||
if (value !== undefined && value !== isOn) {
|
||||
animateToValue(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const animateToValue = (next: boolean) => {
|
||||
const targetValue = next ? 1 : 0;
|
||||
const overlayTarget = next ? containerWidth / 2 : 0;
|
||||
|
||||
progress.stopAnimation();
|
||||
overlayTranslateX.stopAnimation();
|
||||
|
||||
if (animationDuration <= 0) {
|
||||
progress.setValue(targetValue);
|
||||
overlayTranslateX.setValue(overlayTarget);
|
||||
setIsOn(next);
|
||||
setBgOn(next);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOn(next);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(progress, {
|
||||
toValue: targetValue,
|
||||
duration: animationDuration,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(overlayTranslateX, {
|
||||
toValue: overlayTarget,
|
||||
duration: animationDuration,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => {
|
||||
setBgOn(next);
|
||||
});
|
||||
|
||||
// Remove any previous listener
|
||||
if (listenerIdRef.current != null) {
|
||||
progress.removeListener(listenerIdRef.current as string);
|
||||
listenerIdRef.current = null;
|
||||
}
|
||||
|
||||
// Swap image & background exactly at 50% progress
|
||||
let swapped = false;
|
||||
listenerIdRef.current = progress.addListener(({ value }) => {
|
||||
if (swapped) return;
|
||||
if (next && value >= 0.5) {
|
||||
swapped = true;
|
||||
setBgOn(next);
|
||||
if (listenerIdRef.current != null) {
|
||||
progress.removeListener(listenerIdRef.current as string);
|
||||
listenerIdRef.current = null;
|
||||
}
|
||||
}
|
||||
if (!next && value <= 0.5) {
|
||||
swapped = true;
|
||||
setBgOn(next);
|
||||
if (listenerIdRef.current != null) {
|
||||
progress.removeListener(listenerIdRef.current as string);
|
||||
listenerIdRef.current = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggle = () => {
|
||||
const next = !isOn;
|
||||
if (value === undefined) {
|
||||
animateToValue(next);
|
||||
}
|
||||
onChange?.(next);
|
||||
};
|
||||
|
||||
const handlePressIn = () => {
|
||||
pressScale.stopAnimation();
|
||||
Animated.timing(pressScale, {
|
||||
toValue: PRESSED_SCALE,
|
||||
duration: PRESS_FEEDBACK_DURATION,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
const handlePressOut = () => {
|
||||
pressScale.stopAnimation();
|
||||
Animated.timing(pressScale, {
|
||||
toValue: 1,
|
||||
duration: PRESS_FEEDBACK_DURATION,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handleToggle}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
accessibilityRole="switch"
|
||||
accessibilityState={{ checked: isOn }}
|
||||
style={[styles.pressable, style]}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.shadowWrapper,
|
||||
{
|
||||
transform: [{ scale: pressScale }],
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
borderRadius: containerHeight / 2,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
borderRadius: containerHeight / 2,
|
||||
backgroundColor: bgOn
|
||||
? activeBackgroundColor
|
||||
: inactiveBackgroundColor,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: containerWidth / 2,
|
||||
height: containerHeight * 0.95,
|
||||
top: containerHeight * 0.01,
|
||||
left: 0,
|
||||
borderRadius: containerHeight * 0.95 / 2,
|
||||
zIndex: 10,
|
||||
backgroundColor: bgOn ? activeOverlayColor : inactiveOverlayColor,
|
||||
transform: [{ translateX: overlayTranslateX }],
|
||||
}}
|
||||
/>
|
||||
<View className="h-full w-1/2 items-center justify-center ">
|
||||
<Ionicons
|
||||
name={leftIcon ?? "sunny"}
|
||||
size={20}
|
||||
color={leftIconColor ?? "#fff"}
|
||||
/>
|
||||
</View>
|
||||
<View className="h-full w-1/2 items-center justify-center ">
|
||||
<Ionicons
|
||||
name={rightIcon ?? "moon"}
|
||||
size={20}
|
||||
color={rightIconColor ?? "#fff"}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pressable: {
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
shadowWrapper: {
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.15,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowRadius: 6,
|
||||
elevation: 6,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
},
|
||||
knob: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export default SliceSwitch;
|
||||
Reference in New Issue
Block a user