Files
frontend/src/components/common/AdaptiveLayout.tsx

408 lines
9.7 KiB
TypeScript
Raw Normal View History

/**
*
* + /
*
*/
import React, { useState, useCallback, useEffect } from 'react';
import {
View,
StyleProp,
ViewStyle,
StyleSheet,
Animated,
TouchableOpacity,
Modal,
Pressable,
Dimensions,
Platform,
} from 'react-native';
import {
useResponsive,
useBreakpointGTE,
FineBreakpointKey,
} from '../../hooks/useResponsive';
import { blurActiveElement } from '../../infrastructure/platform';
export interface AdaptiveLayoutProps {
/** 主内容区 */
children: React.ReactNode;
/** 侧边栏内容 */
sidebar?: React.ReactNode;
/** 自定义头部 */
header?: React.ReactNode;
/** 自定义底部 */
footer?: React.ReactNode;
/** 布局样式 */
style?: StyleProp<ViewStyle>;
/** 主内容区样式 */
contentStyle?: StyleProp<ViewStyle>;
/** 侧边栏样式 */
sidebarStyle?: StyleProp<ViewStyle>;
/** 侧边栏宽度(桌面端) */
sidebarWidth?: number;
/** 移动端抽屉宽度 */
drawerWidth?: number;
/** 显示侧边栏的断点 */
showSidebarBreakpoint?: FineBreakpointKey;
/** 侧边栏位置 */
sidebarPosition?: 'left' | 'right';
/** 是否强制显示侧边栏(覆盖响应式逻辑) */
forceShowSidebar?: boolean;
/** 是否强制隐藏侧边栏(覆盖响应式逻辑) */
forceHideSidebar?: boolean;
/** 移动端抽屉是否打开(受控模式) */
drawerOpen?: boolean;
/** 移动端抽屉状态变化回调 */
onDrawerOpenChange?: (open: boolean) => void;
/** 渲染移动端抽屉触发按钮 */
renderDrawerTrigger?: (props: { onPress: () => void; isOpen: boolean }) => React.ReactNode;
/** 抽屉遮罩层颜色 */
overlayColor?: string;
/** 抽屉动画时长(毫秒) */
animationDuration?: number;
}
/**
*
*
* +
* -
* -
*
* @example
* // 基础用法
* <AdaptiveLayout
* sidebar={<SidebarContent />}
* sidebarWidth={280}
* >
* <MainContent />
* </AdaptiveLayout>
*
* @example
* // 自定义断点和抽屉宽度
* <AdaptiveLayout
* sidebar={<SidebarContent />}
* showSidebarBreakpoint="xl"
* sidebarWidth={320}
* drawerWidth={280}
* sidebarPosition="right"
* >
* <MainContent />
* </AdaptiveLayout>
*
* @example
* // 受控模式
* const [drawerOpen, setDrawerOpen] = useState(false);
*
* <AdaptiveLayout
* sidebar={<SidebarContent />}
* drawerOpen={drawerOpen}
* onDrawerOpenChange={setDrawerOpen}
* renderDrawerTrigger={({ onPress, isOpen }) => (
* <Button onPress={onPress}>
* {isOpen ? '关闭' : '菜单'}
* </Button>
* )}
* >
* <MainContent />
* </AdaptiveLayout>
*/
export function AdaptiveLayout({
children,
sidebar,
header,
footer,
style,
contentStyle,
sidebarStyle,
sidebarWidth = 280,
drawerWidth = 280,
showSidebarBreakpoint = 'lg',
sidebarPosition = 'left',
forceShowSidebar,
forceHideSidebar,
drawerOpen: controlledDrawerOpen,
onDrawerOpenChange,
renderDrawerTrigger,
overlayColor = 'rgba(0, 0, 0, 0.5)',
animationDuration = 300,
}: AdaptiveLayoutProps) {
const { width, isMobile } = useResponsive();
const shouldShowSidebar = useBreakpointGTE(showSidebarBreakpoint);
// 内部抽屉状态(非受控模式)
const [internalDrawerOpen, setInternalDrawerOpen] = useState(false);
// 动画值
const [slideAnim] = useState(new Animated.Value(0));
const [fadeAnim] = useState(new Animated.Value(0));
// 确定最终抽屉状态
const isDrawerOpen = controlledDrawerOpen ?? internalDrawerOpen;
const setDrawerOpen = useCallback((open: boolean) => {
if (onDrawerOpenChange) {
onDrawerOpenChange(open);
} else {
setInternalDrawerOpen(open);
}
}, [onDrawerOpenChange]);
// 切换抽屉状态
const toggleDrawer = useCallback(() => {
setDrawerOpen(!isDrawerOpen);
}, [isDrawerOpen, setDrawerOpen]);
// 关闭抽屉
const closeDrawer = useCallback(() => {
setDrawerOpen(false);
}, [setDrawerOpen]);
// 抽屉动画
useEffect(() => {
if (isDrawerOpen) {
if (isDrawerOpen) {
blurActiveElement();
}
Animated.parallel([
Animated.timing(slideAnim, {
toValue: 1,
duration: animationDuration,
useNativeDriver: true,
}),
Animated.timing(fadeAnim, {
toValue: 1,
duration: animationDuration,
useNativeDriver: true,
}),
]).start();
} else {
Animated.parallel([
Animated.timing(slideAnim, {
toValue: 0,
duration: animationDuration,
useNativeDriver: true,
}),
Animated.timing(fadeAnim, {
toValue: 0,
duration: animationDuration,
useNativeDriver: true,
}),
]).start();
}
}, [isDrawerOpen, slideAnim, fadeAnim, animationDuration]);
// 计算侧边栏是否应该显示
const isSidebarVisible = forceShowSidebar ?? (shouldShowSidebar && !forceHideSidebar);
const isDrawerMode = !isSidebarVisible && !!sidebar;
// 抽屉滑动动画
const drawerTranslateX = slideAnim.interpolate({
inputRange: [0, 1],
outputRange: [
sidebarPosition === 'left' ? -drawerWidth : drawerWidth,
0,
],
});
// 渲染桌面端侧边栏
const renderDesktopSidebar = () => {
if (!sidebar || !isSidebarVisible) return null;
return (
<View
style={[
styles.sidebar,
{
width: sidebarWidth,
[sidebarPosition === 'left' ? 'marginRight' : 'marginLeft']: 16,
},
sidebarStyle,
]}
>
{sidebar}
</View>
);
};
// 渲染移动端抽屉
const renderMobileDrawer = () => {
if (!sidebar || !isDrawerMode) return null;
return (
<Modal
visible={isDrawerOpen}
transparent
animationType="none"
onRequestClose={closeDrawer}
>
<View style={styles.modalContainer}>
{/* 遮罩层 */}
<TouchableOpacity
style={styles.overlayTouchable}
activeOpacity={1}
onPress={closeDrawer}
>
<Animated.View
style={[
styles.overlay,
{ backgroundColor: overlayColor, opacity: fadeAnim },
]}
/>
</TouchableOpacity>
{/* 抽屉内容 */}
<Animated.View
style={[
styles.drawer,
{
width: drawerWidth,
[sidebarPosition]: 0,
transform: [{ translateX: drawerTranslateX }],
},
sidebarStyle,
]}
>
{sidebar}
</Animated.View>
</View>
</Modal>
);
};
// 渲染抽屉触发按钮
const renderTrigger = () => {
if (!isDrawerMode || !renderDrawerTrigger) return null;
return renderDrawerTrigger({
onPress: toggleDrawer,
isOpen: isDrawerOpen,
});
};
return (
<View style={[styles.container, style]}>
{/* 头部 */}
{header && (
<View style={styles.header}>
{header}
</View>
)}
{/* 主布局区域 */}
<View style={styles.main}>
{/* 左侧布局 */}
{sidebarPosition === 'left' && renderDesktopSidebar()}
{/* 主内容区 */}
<View style={[styles.content, contentStyle]}>
{/* 抽屉触发按钮(仅在移动端抽屉模式显示) */}
{renderTrigger()}
{children}
</View>
{/* 右侧布局 */}
{sidebarPosition === 'right' && renderDesktopSidebar()}
</View>
{/* 移动端抽屉 */}
{renderMobileDrawer()}
{/* 底部 */}
{footer && (
<View style={styles.footer}>
{footer}
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
width: '100%',
},
header: {
width: '100%',
},
main: {
flex: 1,
flexDirection: 'row',
width: '100%',
},
content: {
flex: 1,
minWidth: 0, // 防止 flex item 溢出
},
sidebar: {
flexShrink: 0,
},
footer: {
width: '100%',
},
modalContainer: {
flex: 1,
flexDirection: 'row',
},
overlayTouchable: {
...StyleSheet.absoluteFillObject,
},
overlay: {
flex: 1,
},
drawer: {
position: 'absolute',
top: 0,
bottom: 0,
backgroundColor: '#fff',
shadowColor: '#000',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 16,
},
});
/**
*
*
*/
export interface SidebarLayoutProps {
children: React.ReactNode;
sidebar: React.ReactNode;
style?: StyleProp<ViewStyle>;
contentStyle?: StyleProp<ViewStyle>;
sidebarStyle?: StyleProp<ViewStyle>;
sidebarWidth?: number;
showSidebarBreakpoint?: FineBreakpointKey;
sidebarPosition?: 'left' | 'right';
}
export function SidebarLayout({
children,
sidebar,
style,
contentStyle,
sidebarStyle,
sidebarWidth = 280,
showSidebarBreakpoint = 'lg',
sidebarPosition = 'left',
}: SidebarLayoutProps) {
return (
<AdaptiveLayout
sidebar={sidebar}
style={style}
contentStyle={contentStyle}
sidebarStyle={sidebarStyle}
sidebarWidth={sidebarWidth}
showSidebarBreakpoint={showSidebarBreakpoint}
sidebarPosition={sidebarPosition}
>
{children}
</AdaptiveLayout>
);
}
export default AdaptiveLayout;