Files
sgw-owner-app/components/DraggablePanel.tsx
2025-12-03 16:22:25 +07:00

254 lines
6.7 KiB
TypeScript

import { Ionicons } from "@expo/vector-icons";
import React, { useEffect, useState } from "react";
import {
Pressable,
StyleSheet,
Text,
TouchableOpacity,
useWindowDimensions,
View,
} from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
runOnJS,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from "react-native-reanimated";
interface DraggablePanelProps {
minHeightPct?: number;
maxHeightPct?: number;
initialState?: "min" | "max";
onExpandedChange?: (expanded: boolean) => void;
children?: React.ReactNode;
}
export default function DraggablePanel({
minHeightPct = 0.1,
maxHeightPct = 0.6,
initialState = "min",
onExpandedChange,
children,
}: DraggablePanelProps) {
const { height: screenHeight } = useWindowDimensions();
// Thêm chiều cao của bottom tab bar vào tính toán
const bottomOffset = 80; // 50 là chiều cao mặc định của tab bar
const minHeight = screenHeight * minHeightPct;
const maxHeight = screenHeight * maxHeightPct;
// State để quản lý icon
const [iconName, setIconName] = useState<"chevron-down" | "chevron-up">(
initialState === "max" ? "chevron-down" : "chevron-up"
);
// Sử dụng translateY để điều khiển vị trí
const translateY = useSharedValue(
initialState === "min"
? screenHeight - minHeight - bottomOffset
: screenHeight - maxHeight - bottomOffset
);
const isExpanded = useSharedValue(initialState === "max");
// Update khi screen height thay đổi (xoay màn hình)
useEffect(() => {
const currentHeight = isExpanded.value ? maxHeight : minHeight;
translateY.value = screenHeight - currentHeight - bottomOffset;
}, [screenHeight, minHeight, maxHeight, bottomOffset]);
const notifyExpandedChange = (expanded: boolean) => {
if (onExpandedChange) {
onExpandedChange(expanded);
}
};
const togglePanel = () => {
const newExpanded = !isExpanded.value;
isExpanded.value = newExpanded;
const targetY = newExpanded
? screenHeight - maxHeight - bottomOffset
: screenHeight - minHeight - bottomOffset;
translateY.value = withTiming(targetY, {
duration: 500,
});
notifyExpandedChange(newExpanded);
};
const startY = useSharedValue(0);
const panGesture = Gesture.Pan()
.onStart(() => {
startY.value = translateY.value;
})
.onUpdate((event) => {
// Cập nhật translateY theo gesture
const newY = startY.value + event.translationY;
// Clamp giá trị trong khoảng [screenHeight - maxHeight - bottomOffset, screenHeight - minHeight - bottomOffset]
const minY = screenHeight - maxHeight - bottomOffset;
const maxY = screenHeight - minHeight - bottomOffset;
if (newY >= minY && newY <= maxY) {
translateY.value = newY;
} else if (newY < minY) {
translateY.value = minY;
} else if (newY > maxY) {
translateY.value = maxY;
}
})
.onEnd((event) => {
// Tính toán vị trí để snap
const currentHeight = screenHeight - translateY.value - bottomOffset;
const midHeight = (minHeight + maxHeight) / 2;
// Kiểm tra velocity để quyết định snap
const snapToMax = event.velocityY < -100 || currentHeight > midHeight;
const targetY = snapToMax
? screenHeight - maxHeight - bottomOffset + 40
: screenHeight - minHeight - bottomOffset;
translateY.value = withSpring(
targetY,
{
damping: 20,
stiffness: 200,
},
() => {
"worklet";
isExpanded.value = snapToMax;
}
);
});
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [{ translateY: translateY.value }],
};
});
// Sử dụng useAnimatedReaction để cập nhật icon dựa trên chiều cao
useAnimatedReaction(
() => {
const currentHeight = screenHeight - translateY.value - bottomOffset;
return currentHeight > minHeight + 10;
},
(isCurrentlyExpanded) => {
const newIcon = isCurrentlyExpanded ? "chevron-down" : "chevron-up";
runOnJS(setIconName)(newIcon);
}
);
return (
<Animated.View style={[styles.panelContainer, animatedStyle]}>
<View style={[styles.panel, { height: maxHeight }]}>
{/* Header với drag handle và nút toggle */}
<GestureDetector gesture={panGesture}>
<Pressable onPress={togglePanel} style={styles.header}>
<View style={styles.dragHandle} />
<TouchableOpacity
onPress={togglePanel}
style={styles.toggleButton}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name={iconName} size={24} color="#666" />
</TouchableOpacity>
</Pressable>
</GestureDetector>
{/* Nội dung */}
<View style={styles.content}>
{children || (
<View style={styles.placeholderContent}>
<Text style={styles.placeholderText}>Draggable Panel</Text>
<Text style={styles.placeholderSubtext}>
Click hoặc kéo đ mở rộng panel này
</Text>
<Text style={styles.placeholderSubtext}>
Min: {(minHeightPct * 100).toFixed(0)}% | Max:{" "}
{(maxHeightPct * 100).toFixed(0)}%
</Text>
</View>
)}
</View>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
panelContainer: {
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: "100%",
pointerEvents: "box-none",
},
panel: {
position: "absolute",
top: 0,
left: 0,
right: 0,
backgroundColor: "white",
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: -2,
},
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 10,
},
header: {
paddingTop: 12,
paddingBottom: 8,
paddingHorizontal: 16,
alignItems: "center",
justifyContent: "center",
},
dragHandle: {
width: 40,
height: 4,
backgroundColor: "#D1D5DB",
borderRadius: 2,
marginBottom: 8,
},
toggleButton: {
position: "absolute",
right: 16,
top: 1,
padding: 4,
},
content: {
flex: 1,
paddingHorizontal: 16,
// paddingBottom: 16,
},
placeholderContent: {
alignItems: "center",
paddingTop: 20,
},
placeholderText: {
fontSize: 18,
fontWeight: "600",
color: "#333",
marginBottom: 8,
},
placeholderSubtext: {
fontSize: 14,
color: "#666",
textAlign: "center",
marginTop: 4,
},
});