crush-level-web/src/hooks/useSticky.ts

167 lines
5.5 KiB
TypeScript
Raw Normal View History

2025-11-13 08:38:25 +00:00
"use client";
2025-11-24 03:47:20 +00:00
import { useEffect, useState, useRef, RefObject, useCallback } from 'react';
2025-11-13 08:38:25 +00:00
interface UseStickyOptions {
/**
*
* 0
*/
offset?: number;
/**
*
* 0
* 2
*/
hysteresis?: number;
/**
*
* null使
*/
root?: Element | null;
}
/**
* sticky hook
*
* @param options
* @returns [ref, isSticky] - ref sticky
*/
export function useSticky<T extends HTMLElement = HTMLDivElement>(
options: UseStickyOptions = {}
): [RefObject<T | null>, boolean] {
const { offset = 0, hysteresis = 2, root = null } = options;
const elementRef = useRef<T>(null);
const [isSticky, setIsSticky] = useState(false);
const rafIdRef = useRef<number | null>(null);
2025-11-24 03:47:20 +00:00
const lastCheckTimeRef = useRef<number>(0);
const scrollContainerRef = useRef<HTMLElement | null>(null);
// 检查并更新 sticky 状态
const checkStickyState = useCallback(() => {
const element = elementRef.current;
const scrollContainer = scrollContainerRef.current;
if (!element) return;
// 获取滚动容器的滚动位置
const scrollTop = scrollContainer ? scrollContainer.scrollTop : window.scrollY;
// 获取元素相对于容器的位置
const elementRect = element.getBoundingClientRect();
const containerRect = scrollContainer ? scrollContainer.getBoundingClientRect() : { top: 0 };
// 计算元素相对于容器顶部的距离
const elementTopRelativeToContainer = elementRect.top - containerRect.top;
// 滚动位置阈值:避免在接近顶部时还保持吸顶
const scrollThreshold = 20;
// 判断是否应该吸顶
// 向下滚动时:元素顶部到达或超过 offset 位置时触发吸顶
// 向上滚动时:元素顶部回到 offset + hysteresis 以上时取消吸顶
const shouldBeSticky = elementTopRelativeToContainer <= offset && scrollTop > scrollThreshold;
const shouldNotBeSticky = elementTopRelativeToContainer > offset + hysteresis;
setIsSticky(prev => {
if (shouldNotBeSticky) return false;
if (shouldBeSticky) return true;
return prev; // 保持当前状态
});
}, [offset, hysteresis]);
2025-11-13 08:38:25 +00:00
useEffect(() => {
const element = elementRef.current;
if (!element) return;
// 如果没有指定 root尝试自动查找滚动容器
2025-11-24 03:47:20 +00:00
let scrollContainer = root as HTMLElement | null;
2025-11-13 08:38:25 +00:00
if (!scrollContainer) {
// 查找具有 overflow 属性的父容器或 main-content 容器
scrollContainer = document.getElementById('main-content') || null;
}
2025-11-24 03:47:20 +00:00
scrollContainerRef.current = scrollContainer;
2025-11-13 08:38:25 +00:00
// 创建一个哨兵元素来检测 sticky 状态
const sentinel = document.createElement('div');
sentinel.style.position = 'relative';
sentinel.style.top = `${offset}px`; // 设置与 sticky offset 相同的位置
sentinel.style.left = '0';
sentinel.style.width = '1px';
sentinel.style.height = '1px';
sentinel.style.pointerEvents = 'none';
sentinel.style.visibility = 'hidden';
// 将哨兵元素插入到目标元素之前
element.parentNode?.insertBefore(sentinel, element);
2025-11-24 03:47:20 +00:00
// IntersectionObserver 作为主要检测机制
2025-11-13 08:38:25 +00:00
const observer = new IntersectionObserver(
([entry]) => {
// 使用 rAF 合并更新,避免高频切换
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
}
rafIdRef.current = requestAnimationFrame(() => {
2025-11-24 03:47:20 +00:00
checkStickyState();
2025-11-13 08:38:25 +00:00
});
},
{
root: scrollContainer,
// 在向下滚动触发点加入滞后容差,远离 0 边界,减少抖动
rootMargin: `-${offset + Math.max(0, Math.floor(hysteresis))}px 0px 0px 0px`,
threshold: 0
}
);
observer.observe(sentinel);
2025-11-24 03:47:20 +00:00
// 添加 scroll 事件监听作为后备方案
// 使用节流来优化性能,避免过于频繁的调用
let scrollTimeout: NodeJS.Timeout | null = null;
const handleScroll = () => {
const now = Date.now();
// 节流:至少间隔 50ms 才执行一次检查
if (now - lastCheckTimeRef.current < 50) {
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
// 设置一个延迟检查,确保滚动停止后也会执行一次
scrollTimeout = setTimeout(() => {
lastCheckTimeRef.current = Date.now();
checkStickyState();
}, 50);
return;
}
lastCheckTimeRef.current = now;
checkStickyState();
};
const scrollTarget = scrollContainer || window;
scrollTarget.addEventListener('scroll', handleScroll, { passive: true });
2025-11-13 08:38:25 +00:00
return () => {
observer.disconnect();
sentinel.remove();
2025-11-24 03:47:20 +00:00
scrollTarget.removeEventListener('scroll', handleScroll);
2025-11-13 08:38:25 +00:00
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
2025-11-24 03:47:20 +00:00
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
2025-11-13 08:38:25 +00:00
};
2025-11-24 03:47:20 +00:00
}, [offset, hysteresis, root, checkStickyState]);
2025-11-13 08:38:25 +00:00
return [elementRef, isSticky];
}
/**
* useSticky hook使
*/
export function useStickySimple<T extends HTMLElement = HTMLDivElement>(): [RefObject<T | null>, boolean] {
return useSticky<T>();
}