Files
frontend/src/hooks/useResponsive.ts
lan 3968660048 Initial frontend repository commit.
Include app source and update .gitignore to exclude local release artifacts and signing files.

Made-with: Cursor
2026-03-09 21:29:03 +08:00

486 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 响应式设计 Hook
* 提供屏幕尺寸、断点、方向、平台检测等响应式信息
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import { Dimensions, ScaledSize, Platform } from 'react-native';
// ==================== 断点定义 ====================
export const BREAKPOINTS = {
mobile: 0, // 手机
tablet: 768, // 平板竖屏
desktop: 1024, // 平板横屏/桌面
wide: 1440, // 宽屏桌面
} as const;
export type BreakpointKey = keyof typeof BREAKPOINTS;
export type BreakpointValue = typeof BREAKPOINTS[BreakpointKey];
// ==================== 更细粒度的断点定义 ====================
export const FINE_BREAKPOINTS = {
xs: 0, // 超小屏手机
sm: 375, // 小屏手机 (iPhone SE, 小屏安卓)
md: 414, // 中屏手机 (iPhone Pro Max, 大屏安卓)
lg: 768, // 平板竖屏 / 大折叠屏手机展开
xl: 1024, // 平板横屏 / 小桌面
'2xl': 1280, // 桌面
'3xl': 1440, // 大桌面
'4xl': 1920, // 超大屏
} as const;
export type FineBreakpointKey = keyof typeof FINE_BREAKPOINTS;
// ==================== 响应式值类型 ====================
export type ResponsiveValue<T> = T | Partial<Record<FineBreakpointKey, T>>;
// ==================== 返回值类型 ====================
export interface ResponsiveInfo {
// 基础尺寸
width: number;
height: number;
// 基础断点
breakpoint: BreakpointKey;
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
isWide: boolean;
isWideScreen: boolean;
// 细粒度断点
fineBreakpoint: FineBreakpointKey;
isXS: boolean;
isSM: boolean;
isMD: boolean;
isLG: boolean;
isXL: boolean;
is2XL: boolean;
is3XL: boolean;
is4XL: boolean;
// 方向
orientation: 'portrait' | 'landscape';
isPortrait: boolean;
isLandscape: boolean;
// 平台检测
platform: {
OS: 'ios' | 'android' | 'windows' | 'macos' | 'web';
isWeb: boolean;
isIOS: boolean;
isAndroid: boolean;
isNative: boolean;
};
}
// ==================== 获取当前断点 ====================
function getBreakpoint(width: number): BreakpointKey {
if (width >= BREAKPOINTS.wide) {
return 'wide';
}
if (width >= BREAKPOINTS.desktop) {
return 'desktop';
}
if (width >= BREAKPOINTS.tablet) {
return 'tablet';
}
return 'mobile';
}
// ==================== 获取细粒度断点 ====================
function getFineBreakpoint(width: number): FineBreakpointKey {
if (width >= FINE_BREAKPOINTS['4xl']) return '4xl';
if (width >= FINE_BREAKPOINTS['3xl']) return '3xl';
if (width >= FINE_BREAKPOINTS['2xl']) return '2xl';
if (width >= FINE_BREAKPOINTS.xl) return 'xl';
if (width >= FINE_BREAKPOINTS.lg) return 'lg';
if (width >= FINE_BREAKPOINTS.md) return 'md';
if (width >= FINE_BREAKPOINTS.sm) return 'sm';
return 'xs';
}
// ==================== 获取屏幕方向 ====================
function getOrientation(width: number, height: number): 'portrait' | 'landscape' {
return width > height ? 'landscape' : 'portrait';
}
// ==================== useWindowDimensions Hook ====================
/**
* 获取窗口尺寸,支持屏幕旋转响应
* 替代 Dimensions.get('window'),提供实时尺寸更新
*/
function useWindowDimensions(): ScaledSize {
const [dimensions, setDimensions] = useState(() => Dimensions.get('window'));
useEffect(() => {
const subscription = Dimensions.addEventListener('change', ({ window }) => {
setDimensions(window);
});
return () => {
subscription.remove();
};
}, []);
return dimensions;
}
// ==================== useResponsive Hook ====================
/**
* 响应式设计 Hook
* 提供屏幕尺寸、断点、方向、平台检测等响应式信息
*
* @returns ResponsiveInfo 响应式信息对象
*
* @example
* const {
* width, height,
* breakpoint, isMobile, isTablet, isDesktop, isWide,
* fineBreakpoint, isXS, isSM, isMD, isLG, isXL,
* orientation, isPortrait, isLandscape,
* platform: { isWeb, isIOS, isAndroid }
* } = useResponsive();
*/
export function useResponsive(): ResponsiveInfo {
const windowDimensions = useWindowDimensions();
const { width, height } = windowDimensions;
const breakpoint = getBreakpoint(width);
const fineBreakpoint = getFineBreakpoint(width);
const orientation = getOrientation(width, height);
const isMobile = breakpoint === 'mobile';
const isTablet = breakpoint === 'tablet';
const isDesktop = breakpoint === 'desktop';
const isWide = breakpoint === 'wide';
const isWideScreen = isTablet || isDesktop || isWide;
const isXS = fineBreakpoint === 'xs';
const isSM = fineBreakpoint === 'sm';
const isMD = fineBreakpoint === 'md';
const isLG = fineBreakpoint === 'lg';
const isXL = fineBreakpoint === 'xl';
const is2XL = fineBreakpoint === '2xl';
const is3XL = fineBreakpoint === '3xl';
const is4XL = fineBreakpoint === '4xl';
const isPortrait = orientation === 'portrait';
const isLandscape = orientation === 'landscape';
const platform = useMemo(() => ({
OS: Platform.OS,
isWeb: Platform.OS === 'web',
isIOS: Platform.OS === 'ios',
isAndroid: Platform.OS === 'android',
isNative: Platform.OS !== 'web',
}), []);
return {
width,
height,
breakpoint,
isMobile,
isTablet,
isDesktop,
isWide,
isWideScreen,
fineBreakpoint,
isXS,
isSM,
isMD,
isLG,
isXL,
is2XL,
is3XL,
is4XL,
orientation,
isPortrait,
isLandscape,
platform,
};
}
// ==================== 响应式值选择器 ====================
/**
* 根据当前断点从响应式值对象中选择合适的值
*
* @param value - 响应式值,可以是单一值或断点映射对象
* @param currentBreakpoint - 当前细粒度断点
* @returns 选中的值
*
* @example
* const padding = useResponsiveValue({ xs: 8, md: 16, lg: 24 });
* // 在 xs 屏幕返回 8md 屏幕返回 16lg 及以上返回 24
*/
export function useResponsiveValue<T>(value: ResponsiveValue<T>): T {
const { fineBreakpoint } = useResponsive();
return useMemo(() => {
// 如果不是对象,直接返回
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return value as T;
}
const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs'];
const valueMap = value as Partial<Record<FineBreakpointKey, T>>;
// 从当前断点开始向下查找
const currentIndex = breakpointOrder.indexOf(fineBreakpoint);
for (let i = currentIndex; i < breakpointOrder.length; i++) {
const bp = breakpointOrder[i];
if (bp in valueMap) {
return valueMap[bp]!;
}
}
// 如果没找到,返回 xs 的值或第一个值
return (valueMap.xs ?? Object.values(valueMap)[0]) as T;
}, [value, fineBreakpoint]);
}
// ==================== 响应式样式生成器 ====================
/**
* 根据断点生成响应式样式
*
* @param styles - 响应式样式对象
* @returns 当前断点对应的样式
*
* @example
* const containerStyle = useResponsiveStyle({
* padding: { xs: 8, md: 16, lg: 24 },
* fontSize: { xs: 14, lg: 16 }
* });
*/
export function useResponsiveStyle<T extends Record<string, ResponsiveValue<unknown>>>(
styles: T
): { [K in keyof T]: T[K] extends ResponsiveValue<infer V> ? V : never } {
const { fineBreakpoint } = useResponsive();
return useMemo(() => {
const result = {} as { [K in keyof T]: T[K] extends ResponsiveValue<infer V> ? V : never };
for (const key in styles) {
const value = styles[key];
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
(result as Record<string, unknown>)[key] = value;
} else {
const valueMap = value as Partial<Record<FineBreakpointKey, unknown>>;
const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs'];
const currentIndex = breakpointOrder.indexOf(fineBreakpoint);
let selectedValue: unknown = undefined;
for (let i = currentIndex; i < breakpointOrder.length; i++) {
const bp = breakpointOrder[i];
if (bp in valueMap) {
selectedValue = valueMap[bp];
break;
}
}
(result as Record<string, unknown>)[key] = selectedValue ?? valueMap.xs ?? Object.values(valueMap)[0];
}
}
return result;
}, [styles, fineBreakpoint]);
}
// ==================== 断点比较工具 ====================
/**
* 检查当前断点是否大于等于目标断点
*
* @param current - 当前断点
* @param target - 目标断点
* @returns 是否满足条件
*/
export function isBreakpointGTE(
current: FineBreakpointKey,
target: FineBreakpointKey
): boolean {
const order: FineBreakpointKey[] = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl'];
return order.indexOf(current) >= order.indexOf(target);
}
/**
* 检查当前断点是否小于目标断点
*
* @param current - 当前断点
* @param target - 目标断点
* @returns 是否满足条件
*/
export function isBreakpointLT(
current: FineBreakpointKey,
target: FineBreakpointKey
): boolean {
return !isBreakpointGTE(current, target);
}
// ==================== 断点范围检查 Hook ====================
/**
* 检查当前是否在指定断点范围内
*
* @param options - 断点范围选项
* @returns 是否在范围内
*
* @example
* const isMediumUp = useBreakpointGTE('md');
* const isMobileOnly = useBreakpointLT('lg');
*/
export function useBreakpointGTE(target: FineBreakpointKey): boolean {
const { fineBreakpoint } = useResponsive();
return isBreakpointGTE(fineBreakpoint, target);
}
export function useBreakpointLT(target: FineBreakpointKey): boolean {
const { fineBreakpoint } = useResponsive();
return isBreakpointLT(fineBreakpoint, target);
}
export function useBreakpointBetween(
min: FineBreakpointKey,
max: FineBreakpointKey
): boolean {
const { fineBreakpoint } = useResponsive();
return isBreakpointGTE(fineBreakpoint, min) && !isBreakpointGTE(fineBreakpoint, max);
}
// ==================== 平台检测 Hook ====================
/**
* 平台检测 Hook
* 提供便捷的平台检测方法
*
* @example
* const { isWeb, isIOS, isAndroid, isNative } = usePlatform();
*/
export function usePlatform() {
const { platform } = useResponsive();
return platform;
}
// ==================== 媒体查询模拟 ====================
/**
* 模拟 CSS 媒体查询
*
* @param query - 查询条件
* @returns 是否匹配
*
* @example
* const isMinWidth768 = useMediaQuery({ minWidth: 768 });
* const isMaxWidth1024 = useMediaQuery({ maxWidth: 1024 });
* const isPortrait = useMediaQuery({ orientation: 'portrait' });
*/
interface MediaQueryOptions {
minWidth?: number;
maxWidth?: number;
minHeight?: number;
maxHeight?: number;
orientation?: 'portrait' | 'landscape';
}
export function useMediaQuery(query: MediaQueryOptions): boolean {
const { width, height, orientation: currentOrientation } = useResponsive();
return useMemo(() => {
if (query.minWidth !== undefined && width < query.minWidth) return false;
if (query.maxWidth !== undefined && width > query.maxWidth) return false;
if (query.minHeight !== undefined && height < query.minHeight) return false;
if (query.maxHeight !== undefined && height > query.maxHeight) return false;
if (query.orientation !== undefined && currentOrientation !== query.orientation) return false;
return true;
}, [width, height, currentOrientation, query]);
}
// ==================== 列数计算工具 ====================
/**
* 根据容器宽度计算合适的列数
*
* @param containerWidth - 容器宽度
* @param options - 配置选项
* @returns 列数
*
* @example
* const columns = useColumnCount({
* xs: 1,
* sm: 2,
* md: 3,
* lg: 4
* });
*/
export function useColumnCount(
columnConfig: Partial<Record<FineBreakpointKey, number>> = {}
): number {
const { fineBreakpoint } = useResponsive();
const defaultConfig: Record<FineBreakpointKey, number> = {
xs: 1,
sm: 1,
md: 2,
lg: 3,
xl: 4,
'2xl': 4,
'3xl': 5,
'4xl': 6,
};
return useMemo(() => {
const config = { ...defaultConfig, ...columnConfig };
const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs'];
const currentIndex = breakpointOrder.indexOf(fineBreakpoint);
for (let i = currentIndex; i < breakpointOrder.length; i++) {
const bp = breakpointOrder[i];
if (config[bp] !== undefined) {
return config[bp];
}
}
return 1;
}, [fineBreakpoint, columnConfig]);
}
// ==================== 间距计算工具 ====================
/**
* 响应式间距 Hook
*
* @param spacingConfig - 间距配置
* @returns 当前断点对应的间距值
*
* @example
* const gap = useResponsiveSpacing({ xs: 8, md: 16, lg: 24 });
*/
export function useResponsiveSpacing(
spacingConfig: Partial<Record<FineBreakpointKey, number>> = {}
): number {
const { fineBreakpoint } = useResponsive();
const defaultConfig: Record<FineBreakpointKey, number> = {
xs: 8,
sm: 8,
md: 12,
lg: 16,
xl: 20,
'2xl': 24,
'3xl': 32,
'4xl': 40,
};
return useMemo(() => {
const config = { ...defaultConfig, ...spacingConfig };
const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs'];
const currentIndex = breakpointOrder.indexOf(fineBreakpoint);
for (let i = currentIndex; i < breakpointOrder.length; i++) {
const bp = breakpointOrder[i];
if (config[bp] !== undefined) {
return config[bp];
}
}
return defaultConfig.xs;
}, [fineBreakpoint, spacingConfig]);
}
export default useResponsive;