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

167 lines
5.5 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.

"use client";
import { useEffect, useState, useRef, RefObject, useCallback } from 'react';
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);
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]);
useEffect(() => {
const element = elementRef.current;
if (!element) return;
// 如果没有指定 root尝试自动查找滚动容器
let scrollContainer = root as HTMLElement | null;
if (!scrollContainer) {
// 查找具有 overflow 属性的父容器或 main-content 容器
scrollContainer = document.getElementById('main-content') || null;
}
scrollContainerRef.current = scrollContainer;
// 创建一个哨兵元素来检测 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);
// IntersectionObserver 作为主要检测机制
const observer = new IntersectionObserver(
([entry]) => {
// 使用 rAF 合并更新,避免高频切换
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
}
rafIdRef.current = requestAnimationFrame(() => {
checkStickyState();
});
},
{
root: scrollContainer,
// 在向下滚动触发点加入滞后容差,远离 0 边界,减少抖动
rootMargin: `-${offset + Math.max(0, Math.floor(hysteresis))}px 0px 0px 0px`,
threshold: 0
}
);
observer.observe(sentinel);
// 添加 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 });
return () => {
observer.disconnect();
sentinel.remove();
scrollTarget.removeEventListener('scroll', handleScroll);
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
};
}, [offset, hysteresis, root, checkStickyState]);
return [elementRef, isSticky];
}
/**
* 简化版本的 useSticky hook使用默认配置
*/
export function useStickySimple<T extends HTMLElement = HTMLDivElement>(): [RefObject<T | null>, boolean] {
return useSticky<T>();
}