hover #6
@@ -20,7 +20,7 @@ import { User } from '../../types';
|
|||||||
import Text from '../common/Text';
|
import Text from '../common/Text';
|
||||||
import Button from '../common/Button';
|
import Button from '../common/Button';
|
||||||
import Avatar from '../common/Avatar';
|
import Avatar from '../common/Avatar';
|
||||||
import { useResponsive } from '../../hooks';
|
import { useResponsive, useHover } from '../../hooks';
|
||||||
|
|
||||||
interface UserProfileHeaderProps {
|
interface UserProfileHeaderProps {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -126,10 +126,15 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
|||||||
label: string;
|
label: string;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
|
const { isHovered, hoverProps } = useHover();
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<View style={styles.statContent}>
|
<View style={[
|
||||||
<Text variant="h3" style={styles.statNumber}>{value}</Text>
|
styles.statContent,
|
||||||
<Text variant="caption" color={colors.text.secondary} style={styles.statLabel}>
|
isHovered && styles.statContentHovered,
|
||||||
|
]}>
|
||||||
|
<Text variant="h3" style={[styles.statNumber, { color: isHovered ? colors.primary.main : undefined }]}>{value}</Text>
|
||||||
|
<Text variant="caption" color={isHovered ? colors.primary.main : colors.text.secondary} style={styles.statLabel}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -138,9 +143,14 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
|||||||
if (onPress) {
|
if (onPress) {
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.statItem, styles.statItemTouchable]}
|
style={[
|
||||||
|
styles.statItem,
|
||||||
|
styles.statItemTouchable,
|
||||||
|
isHovered && styles.statItemHovered,
|
||||||
|
]}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
activeOpacity={0.75}
|
activeOpacity={0.75}
|
||||||
|
{...hoverProps}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -473,11 +483,17 @@ const styles = StyleSheet.create({
|
|||||||
statItemTouchable: {
|
statItemTouchable: {
|
||||||
borderRadius: borderRadius.md,
|
borderRadius: borderRadius.md,
|
||||||
},
|
},
|
||||||
|
statItemHovered: {
|
||||||
|
backgroundColor: `${colors.primary.main}08`,
|
||||||
|
},
|
||||||
statContent: {
|
statContent: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
paddingHorizontal: spacing.xs,
|
paddingHorizontal: spacing.xs,
|
||||||
},
|
},
|
||||||
|
statContentHovered: {
|
||||||
|
transform: [{ scale: 1.02 }],
|
||||||
|
},
|
||||||
statNumber: {
|
statNumber: {
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
marginBottom: 0,
|
marginBottom: 0,
|
||||||
|
|||||||
93
src/h
Normal file
93
src/h
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* useHover Hook
|
||||||
|
* 用于检测鼠标悬停状态,仅在桌面端(Web)生效
|
||||||
|
* 在移动端返回默认值 false
|
||||||
|
*/
|
||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
interface UseHoverOptions {
|
||||||
|
enterDelay?: number;
|
||||||
|
leaveDelay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseHoverReturn {
|
||||||
|
isHovered: boolean;
|
||||||
|
hoverProps: {
|
||||||
|
onMouseEnter: () => void;
|
||||||
|
onMouseLeave: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测当前是否在 Web 平台
|
||||||
|
*/
|
||||||
|
const isWeb = Platform.OS === 'web';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useHover Hook
|
||||||
|
* @param options 配置选项
|
||||||
|
* @returns 悬停状态和事件处理器
|
||||||
|
*/
|
||||||
|
export function useHover(options: UseHoverOptions = {}): UseHoverReturn {
|
||||||
|
const { enterDelay = 0, leaveDelay = 0 } = options;
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const enterTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const leaveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// 清理定时器
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (enterTimeoutRef.current) {
|
||||||
|
clearTimeout(enterTimeoutRef.current);
|
||||||
|
}
|
||||||
|
if (leaveTimeoutRef.current) {
|
||||||
|
clearTimeout(leaveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
if (!isWeb) return;
|
||||||
|
|
||||||
|
if (leaveTimeoutRef.current) {
|
||||||
|
clearTimeout(leaveTimeoutRef.current);
|
||||||
|
leaveTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enterDelay > 0) {
|
||||||
|
enterTimeoutRef.current = setTimeout(() => {
|
||||||
|
setIsHovered(true);
|
||||||
|
}, enterDelay);
|
||||||
|
} else {
|
||||||
|
setIsHovered(true);
|
||||||
|
}
|
||||||
|
}, [enterDelay]);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
if (!isWeb) return;
|
||||||
|
|
||||||
|
if (enterTimeoutRef.current) {
|
||||||
|
clearTimeout(enterTimeoutRef.current);
|
||||||
|
enterTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leaveDelay > 0) {
|
||||||
|
leaveTimeoutRef.current = setTimeout(() => {
|
||||||
|
setIsHovered(false);
|
||||||
|
}, leaveDelay);
|
||||||
|
} else {
|
||||||
|
setIsHovered(false);
|
||||||
|
}
|
||||||
|
}, [leaveDelay]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isHovered,
|
||||||
|
hoverProps: {
|
||||||
|
onMouseEnter: handleMouseEnter,
|
||||||
|
onMouseLeave: handleMouseLeave,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useHover;
|
||||||
@@ -92,3 +92,8 @@ export type { UseConnectionStateResult } from './useConnectionState';
|
|||||||
export { useMediaCache } from './useMediaCache';
|
export { useMediaCache } from './useMediaCache';
|
||||||
|
|
||||||
export type { UseMediaCacheReturn } from './useMediaCache';
|
export type { UseMediaCacheReturn } from './useMediaCache';
|
||||||
|
|
||||||
|
// ==================== 悬停效果 Hooks ====================
|
||||||
|
export { useHover } from './useHover';
|
||||||
|
|
||||||
|
export type { UseHoverOptions, UseHoverReturn } from './useHover';
|
||||||
|
|||||||
93
src/hooks/useHover.ts
Normal file
93
src/hooks/useHover.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* useHover Hook
|
||||||
|
* 用于检测鼠标悬停状态,仅在桌面端(Web)生效
|
||||||
|
* 在移动端返回默认值 false
|
||||||
|
*/
|
||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
export interface UseHoverOptions {
|
||||||
|
enterDelay?: number;
|
||||||
|
leaveDelay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseHoverReturn {
|
||||||
|
isHovered: boolean;
|
||||||
|
hoverProps: {
|
||||||
|
onMouseEnter: () => void;
|
||||||
|
onMouseLeave: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测当前是否在 Web 平台
|
||||||
|
*/
|
||||||
|
const isWeb = Platform.OS === 'web';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useHover Hook
|
||||||
|
* @param options 配置选项
|
||||||
|
* @returns 悬停状态和事件处理器
|
||||||
|
*/
|
||||||
|
export function useHover(options: UseHoverOptions = {}): UseHoverReturn {
|
||||||
|
const { enterDelay = 0, leaveDelay = 0 } = options;
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const enterTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const leaveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// 清理定时器
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (enterTimeoutRef.current) {
|
||||||
|
clearTimeout(enterTimeoutRef.current);
|
||||||
|
}
|
||||||
|
if (leaveTimeoutRef.current) {
|
||||||
|
clearTimeout(leaveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
if (!isWeb) return;
|
||||||
|
|
||||||
|
if (leaveTimeoutRef.current) {
|
||||||
|
clearTimeout(leaveTimeoutRef.current);
|
||||||
|
leaveTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enterDelay > 0) {
|
||||||
|
enterTimeoutRef.current = setTimeout(() => {
|
||||||
|
setIsHovered(true);
|
||||||
|
}, enterDelay);
|
||||||
|
} else {
|
||||||
|
setIsHovered(true);
|
||||||
|
}
|
||||||
|
}, [enterDelay]);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
if (!isWeb) return;
|
||||||
|
|
||||||
|
if (enterTimeoutRef.current) {
|
||||||
|
clearTimeout(enterTimeoutRef.current);
|
||||||
|
enterTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leaveDelay > 0) {
|
||||||
|
leaveTimeoutRef.current = setTimeout(() => {
|
||||||
|
setIsHovered(false);
|
||||||
|
}, leaveDelay);
|
||||||
|
} else {
|
||||||
|
setIsHovered(false);
|
||||||
|
}
|
||||||
|
}, [leaveDelay]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isHovered,
|
||||||
|
hoverProps: {
|
||||||
|
onMouseEnter: handleMouseEnter,
|
||||||
|
onMouseLeave: handleMouseLeave,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useHover;
|
||||||
@@ -19,6 +19,7 @@ import type { TabName, NavItemConfig } from '../infrastructure/navigation/types'
|
|||||||
import { NAVIGATION_CONSTANTS } from '../infrastructure/navigation/types';
|
import { NAVIGATION_CONSTANTS } from '../infrastructure/navigation/types';
|
||||||
import { colors, shadows } from '../theme';
|
import { colors, shadows } from '../theme';
|
||||||
import { useNavigationState } from '../infrastructure/navigation/hooks/useNavigationState';
|
import { useNavigationState } from '../infrastructure/navigation/hooks/useNavigationState';
|
||||||
|
import { useHover } from '../hooks/useHover';
|
||||||
|
|
||||||
// 导入屏幕
|
// 导入屏幕
|
||||||
import { HomeScreen } from '../screens/home';
|
import { HomeScreen } from '../screens/home';
|
||||||
@@ -41,6 +42,66 @@ interface DesktopNavigatorProps {
|
|||||||
unreadCount?: number;
|
unreadCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 单个导航项组件,支持悬停效果
|
||||||
|
interface NavItemProps {
|
||||||
|
item: NavItemConfig;
|
||||||
|
isActive: boolean;
|
||||||
|
isCollapsed: boolean;
|
||||||
|
showBadge: boolean;
|
||||||
|
unreadCount: number;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavItem: React.FC<NavItemProps> = ({
|
||||||
|
item,
|
||||||
|
isActive,
|
||||||
|
isCollapsed,
|
||||||
|
showBadge,
|
||||||
|
unreadCount,
|
||||||
|
onPress,
|
||||||
|
}) => {
|
||||||
|
const { isHovered, hoverProps } = useHover();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.sidebarItem,
|
||||||
|
isActive && styles.sidebarItemActive,
|
||||||
|
isHovered && !isActive && styles.sidebarItemHovered,
|
||||||
|
isCollapsed && styles.sidebarItemCollapsed,
|
||||||
|
]}
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
{...hoverProps}
|
||||||
|
>
|
||||||
|
<View style={[
|
||||||
|
styles.sidebarIconContainer,
|
||||||
|
isHovered && !isActive && styles.sidebarIconContainerHovered,
|
||||||
|
]}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={isActive ? (item.icon as any) : (item.iconOutline as any)}
|
||||||
|
size={24}
|
||||||
|
color={isActive ? colors.primary.main : isHovered ? colors.primary.main : colors.text.secondary}
|
||||||
|
/>
|
||||||
|
{showBadge && (
|
||||||
|
<View style={styles.badge}>
|
||||||
|
<Text style={styles.badgeText}>{unreadCount > 99 ? '99+' : unreadCount}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<Text style={[
|
||||||
|
styles.sidebarLabel,
|
||||||
|
isActive && styles.sidebarLabelActive,
|
||||||
|
isHovered && !isActive && styles.sidebarLabelHovered,
|
||||||
|
]}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export function DesktopNavigator({ unreadCount = 0 }: DesktopNavigatorProps) {
|
export function DesktopNavigator({ unreadCount = 0 }: DesktopNavigatorProps) {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { currentTab, isCollapsed, setCurrentTab, toggleCollapse, setIsReady } = useNavigationState();
|
const { currentTab, isCollapsed, setCurrentTab, toggleCollapse, setIsReady } = useNavigationState();
|
||||||
@@ -103,34 +164,15 @@ export function DesktopNavigator({ unreadCount = 0 }: DesktopNavigatorProps) {
|
|||||||
const showBadge = item.name === 'MessageTab' && unreadCount > 0;
|
const showBadge = item.name === 'MessageTab' && unreadCount > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<NavItem
|
||||||
key={item.name}
|
key={item.name}
|
||||||
style={[
|
item={item}
|
||||||
styles.sidebarItem,
|
isActive={isActive}
|
||||||
isActive && styles.sidebarItemActive,
|
isCollapsed={isCollapsed}
|
||||||
isCollapsed && styles.sidebarItemCollapsed,
|
showBadge={showBadge}
|
||||||
]}
|
unreadCount={unreadCount}
|
||||||
onPress={() => handleTabChange(item.name)}
|
onPress={() => handleTabChange(item.name)}
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<View style={styles.sidebarIconContainer}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={isActive ? item.icon : item.iconOutline}
|
|
||||||
size={24}
|
|
||||||
color={isActive ? colors.primary.main : colors.text.secondary}
|
|
||||||
/>
|
/>
|
||||||
{showBadge && (
|
|
||||||
<View style={styles.badge}>
|
|
||||||
<Text style={styles.badgeText}>{unreadCount > 99 ? '99+' : unreadCount}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{!isCollapsed && (
|
|
||||||
<Text style={[styles.sidebarLabel, isActive && styles.sidebarLabelActive]}>
|
|
||||||
{item.label}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -201,6 +243,9 @@ const styles = StyleSheet.create({
|
|||||||
sidebarItemActive: {
|
sidebarItemActive: {
|
||||||
backgroundColor: `${colors.primary.main}15`,
|
backgroundColor: `${colors.primary.main}15`,
|
||||||
},
|
},
|
||||||
|
sidebarItemHovered: {
|
||||||
|
backgroundColor: `${colors.primary.main}08`,
|
||||||
|
},
|
||||||
sidebarItemCollapsed: {
|
sidebarItemCollapsed: {
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
paddingHorizontal: 0,
|
paddingHorizontal: 0,
|
||||||
@@ -213,6 +258,9 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
},
|
},
|
||||||
|
sidebarIconContainerHovered: {
|
||||||
|
backgroundColor: `${colors.primary.main}10`,
|
||||||
|
},
|
||||||
sidebarLabel: {
|
sidebarLabel: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
@@ -224,6 +272,9 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.primary.main,
|
color: colors.primary.main,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
|
sidebarLabelHovered: {
|
||||||
|
color: colors.primary.main,
|
||||||
|
},
|
||||||
collapseButton: {
|
collapseButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
Reference in New Issue
Block a user