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

119 lines
4.0 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 } 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);
useEffect(() => {
const element = elementRef.current;
if (!element) return;
// 如果没有指定 root尝试自动查找滚动容器
let scrollContainer = root;
if (!scrollContainer) {
// 查找具有 overflow 属性的父容器或 main-content 容器
scrollContainer = document.getElementById('main-content') || null;
}
// 创建一个哨兵元素来检测 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);
const observer = new IntersectionObserver(
([entry]) => {
// 使用 rAF 合并更新,避免高频切换
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
}
rafIdRef.current = requestAnimationFrame(() => {
// 获取滚动容器的滚动位置
const container = scrollContainer as HTMLElement;
const scrollTop = container ? container.scrollTop : window.scrollY;
// 获取元素相对于容器的位置
const elementRect = element.getBoundingClientRect();
const containerRect = container ? container.getBoundingClientRect() : { top: 0 };
// 计算元素相对于容器顶部的距离
const elementTopRelativeToContainer = elementRect.top - containerRect.top;
// 只有当元素顶部到达或超过 offset 位置,并且哨兵不可见时,才认为是 sticky
// 同时确保滚动位置大于元素初始位置
const nextIsSticky = !entry.isIntersecting &&
elementTopRelativeToContainer <= offset + hysteresis &&
scrollTop > 0;
// 仅在状态变化时更新,减少重复渲染
setIsSticky(prev => (prev !== nextIsSticky ? nextIsSticky : prev));
});
},
{
root: scrollContainer,
// 在向下滚动触发点加入滞后容差,远离 0 边界,减少抖动
rootMargin: `-${offset + Math.max(0, Math.floor(hysteresis))}px 0px 0px 0px`,
threshold: 0
}
);
observer.observe(sentinel);
return () => {
observer.disconnect();
sentinel.remove();
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
};
}, [offset, hysteresis, root]);
return [elementRef, isSticky];
}
/**
* 简化版本的 useSticky hook使用默认配置
*/
export function useStickySimple<T extends HTMLElement = HTMLDivElement>(): [RefObject<T | null>, boolean] {
return useSticky<T>();
}