添加了导航栏的鼠标悬停效果

This commit is contained in:
2026-03-24 20:53:31 +08:00
parent cf466a3959
commit ecc3d088a5
5 changed files with 289 additions and 31 deletions

View File

@@ -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
View 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;

View File

@@ -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
View 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;

View File

@@ -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',