| 1 | /** |
| 2 | * 滚动控制 Hook |
| 3 | * 管理消息列表的智能滚动行为 |
| 4 | */ |
| 5 | import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' |
| 6 | |
| 7 | export interface IScrollControlOptions { |
| 8 | /** 判断为"底部附近"的阈值(px) */ |
| 9 | nearBottomThreshold?: number |
| 10 | /** 显示回到底部按钮的阈值(px) */ |
| 11 | showButtonThreshold?: number |
| 12 | /** 滚动状态重置延迟(ms) */ |
| 13 | scrollResetDelay?: number |
| 14 | } |
| 15 | |
| 16 | export interface IScrollControlReturn { |
| 17 | /** 消息容器 ref */ |
| 18 | containerRef: React.RefObject<HTMLDivElement> |
| 19 | /** 消息底部 ref(用于滚动到底部) */ |
| 20 | bottomRef: React.RefObject<HTMLDivElement> |
| 21 | /** 用户是否在底部附近 */ |
| 22 | isNearBottom: boolean |
| 23 | /** 是否显示回到底部按钮 */ |
| 24 | showScrollButton: boolean |
| 25 | /** 滚动到底部 */ |
| 26 | scrollToBottom: (force?: boolean) => void |
| 27 | /** 处理滚动事件(绑定到容器) */ |
| 28 | handleScroll: () => void |
| 29 | /** 首次加载完成后调用,确保滚动到底部 */ |
| 30 | onContentReady: () => void |
| 31 | } |
| 32 | |
| 33 | /** |
| 34 | * 滚动控制 Hook |
| 35 | * @param options 配置选项 |
| 36 | */ |
| 37 | export function useScrollControl(options: IScrollControlOptions = {}): IScrollControlReturn { |
| 38 | const { nearBottomThreshold = 150, showButtonThreshold = 300, scrollResetDelay = 150 } = options |
| 39 | |
| 40 | // Refs |
| 41 | const containerRef = useRef<HTMLDivElement>(null) |
| 42 | const bottomRef = useRef<HTMLDivElement>(null) |
| 43 | const isUserScrollingRef = useRef(false) |
| 44 | const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null) |
| 45 | const hasInitialScrolledRef = useRef(false) |
| 46 | const pendingScrollRef = useRef(false) |
| 47 | |
| 48 | // State |
| 49 | const [isNearBottom, setIsNearBottom] = useState(true) |
| 50 | const [showScrollButton, setShowScrollButton] = useState(false) |
| 51 | |
| 52 | /** 检查是否在底部附近 */ |
| 53 | const checkIfNearBottom = useCallback(() => { |
| 54 | const container = containerRef.current |
| 55 | if (!container) |
| 56 | return true |
| 57 | |
| 58 | const { scrollTop, scrollHeight, clientHeight } = container |
| 59 | return scrollHeight - scrollTop - clientHeight < nearBottomThreshold |
| 60 | }, [nearBottomThreshold]) |
| 61 | |
| 62 | /** 直接设置滚动位置到底部(无动画,避免闪烁) */ |
| 63 | const scrollToBottomImmediate = useCallback(() => { |
| 64 | const container = containerRef.current |
| 65 | if (container) { |
| 66 | container.scrollTop = container.scrollHeight |
| 67 | } |
| 68 | setIsNearBottom(true) |
| 69 | setShowScrollButton(false) |
| 70 | }, []) |
| 71 | |
| 72 | /** 滚动到底部 */ |
| 73 | const scrollToBottom = useCallback((force = false) => { |
| 74 | if (force) { |
| 75 | isUserScrollingRef.current = false |
| 76 | setIsNearBottom(true) |
| 77 | setShowScrollButton(false) |
| 78 | } |
| 79 | bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) |
| 80 | }, []) |
| 81 | |
| 82 | /** 首次加载完成后标记需要滚动 */ |
| 83 | const onContentReady = useCallback(() => { |
| 84 | if (hasInitialScrolledRef.current) |
| 85 | return |
| 86 | pendingScrollRef.current = true |
| 87 | }, []) |
| 88 | |
| 89 | /** 使用 useLayoutEffect 在 DOM 更新后同步滚动,避免闪烁 */ |
| 90 | useLayoutEffect(() => { |
| 91 | if (pendingScrollRef.current && !hasInitialScrolledRef.current) { |
| 92 | hasInitialScrolledRef.current = true |
| 93 | pendingScrollRef.current = false |
| 94 | scrollToBottomImmediate() |
| 95 | } |
| 96 | }) |
| 97 | |
| 98 | /** 处理滚动事件 */ |
| 99 | const handleScroll = useCallback(() => { |
| 100 | // 标记用户正在滚动 |
| 101 | isUserScrollingRef.current = true |
| 102 | |
| 103 | // 清除之前的定时器 |
| 104 | if (scrollTimeoutRef.current) { |
| 105 | clearTimeout(scrollTimeoutRef.current) |
| 106 | } |
| 107 | |
| 108 | // 延迟重置滚动状态 |
| 109 | scrollTimeoutRef.current = setTimeout(() => { |
| 110 | isUserScrollingRef.current = false |
| 111 | }, scrollResetDelay) |
| 112 | |
| 113 | const nearBottom = checkIfNearBottom() |
| 114 | setIsNearBottom(nearBottom) |
| 115 | |
| 116 | // 根据距离底部的距离决定是否显示按钮 |
| 117 | const container = containerRef.current |
| 118 | if (container) { |
| 119 | const { scrollTop, scrollHeight, clientHeight } = container |
| 120 | const distanceFromBottom = scrollHeight - scrollTop - clientHeight |
| 121 | setShowScrollButton(distanceFromBottom > showButtonThreshold) |
| 122 | } |
| 123 | }, [checkIfNearBottom, scrollResetDelay, showButtonThreshold]) |
| 124 | |
| 125 | /** 清理定时器 */ |
| 126 | useEffect(() => { |
| 127 | return () => { |
| 128 | if (scrollTimeoutRef.current) { |
| 129 | clearTimeout(scrollTimeoutRef.current) |
| 130 | } |
| 131 | } |
| 132 | }, []) |
| 133 | |
| 134 | return { |
| 135 | containerRef, |
| 136 | bottomRef, |
| 137 | isNearBottom, |
| 138 | showScrollButton, |
| 139 | scrollToBottom, |
| 140 | handleScroll, |
| 141 | onContentReady, |
| 142 | } |
| 143 | } |
| 144 |