From 16dea87e12c111561ae207ffda974c21fd3fc721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E6=B1=9F=E8=BE=B0?= Date: Wed, 5 Jun 2024 23:59:04 +0800 Subject: [PATCH] feat(tabs): supports scrolling operations via wheel or touchpad (#2929) * feat(tabs): supports scrolling operations via wheel or touchpad * fix(tabs): adjust logic to avoid side effects --- src/_common | 2 +- src/tabs/TabNav.tsx | 170 +++++++++++++++++++++----------------------- 2 files changed, 81 insertions(+), 91 deletions(-) diff --git a/src/_common b/src/_common index a3e26cf8c..75b330704 160000 --- a/src/_common +++ b/src/_common @@ -1 +1 @@ -Subproject commit a3e26cf8ce5053ac790b6dcdaee7190cc39a4281 +Subproject commit 75b330704baad8a9e43aeaeed60272e4dcdbcb2c diff --git a/src/tabs/TabNav.tsx b/src/tabs/TabNav.tsx index 6276774cb..a06b2a4db 100644 --- a/src/tabs/TabNav.tsx +++ b/src/tabs/TabNav.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState, WheelEvent } from 'react'; import classNames from 'classnames'; import { AddIcon as TdAddIcon, @@ -12,13 +12,11 @@ import noop from '../_util/noop'; import { useTabClass } from './useTabClass'; import TabNavItem from './TabNavItem'; import TabBar from './TabBar'; -import tabBase from '../_common/js/tabs/base'; +import { calcMaxOffset, calcValidOffset, calculateOffset, calcPrevOrNextOffset } from '../_common/js/tabs/base'; import useGlobalIcon from '../hooks/useGlobalIcon'; import type { DragSortInnerProps } from '../hooks/useDragSorter'; import parseTNode from '../_util/parseTNode'; -const { moveActiveTabIntoView, calcScrollLeft, scrollToLeft, scrollToRight, calculateCanToLeft, calculateCanToRight } = - tabBase; export interface TabNavProps extends TdTabsProps, DragSortInnerProps { itemList: TdTabPanelProps[]; tabClick: (s: TabValue) => void; @@ -81,94 +79,101 @@ const TabNav: React.FC = (props) => { const toRightBtnRef = useRef(null); const [scrollLeft, setScrollLeft] = useState(0); + const [maxScrollLeft, setMaxScrollLeft] = useState(0); const [activeTab, setActiveTab] = useState(null); - useEffect(() => { - const left = moveActiveTabIntoView( - { - activeTab, - navsContainer: navsContainerRef.current, + const setOffset = (offset: number) => { + setScrollLeft(calcValidOffset(offset, maxScrollLeft)); + }; + + const getMaxScrollLeft = useCallback(() => { + if (['top', 'bottom'].includes(placement.toLowerCase())) { + const maxOffset = calcMaxOffset({ navsWrap: navsWrapRef.current, - toLeftBtn: toLeftBtnRef.current, - toRightBtn: toRightBtnRef.current, - leftOperations: leftOperationsRef.current, + navsContainer: navsContainerRef.current, rightOperations: rightOperationsRef.current, - }, - scrollLeft, - ); - setScrollLeft(left); - }, [activeTab, scrollLeft]); + toRightBtn: toRightBtnRef.current, + }); + setMaxScrollLeft(maxOffset); + } + }, [placement]); - // 调用检查函数,并设置左右滑动按钮的展示状态 - const setScrollBtnVisibleHandler = useCallback(() => { - const canToleft = calculateCanToLeft( + const moveActiveTabIntoView = () => { + const offset = calculateOffset( { + activeTab, navsContainer: navsContainerRef.current, - navsWrap: navsWrapRef.current, leftOperations: leftOperationsRef.current, - toLeftBtn: toLeftBtnRef.current, + rightOperations: rightOperationsRef.current, }, scrollLeft, - placement, + 'auto', ); - const canToRight = calculateCanToRight( + setOffset(offset); + }; + + // 当 activeTab 变化时,移动 activeTab 到可视区域 + useEffect(() => { + const timeout = setTimeout(() => { + moveActiveTabIntoView(); + }, 100); + + return () => clearTimeout(timeout); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTab, maxScrollLeft]); + + // 左右滑动按钮的展示状态 + useEffect(() => { + if (['top', 'bottom'].includes(placement.toLowerCase())) { + // 这里的 1 是小数像素不精确误差修正 + const canToLeft = scrollLeft > 1; + const canToRight = scrollLeft < maxScrollLeft - 1; + + setToLeftBtnVisible(canToLeft); + setToRightBtnVisible(canToRight); + } + }, [placement, scrollLeft, maxScrollLeft]); + + // 滚动条处理逻辑 + const handleScroll = (action: 'prev' | 'next') => { + const offset = calcPrevOrNextOffset( { + activeTab, navsContainer: navsContainerRef.current, - navsWrap: navsWrapRef.current, - rightOperations: rightOperationsRef.current, - toRightBtn: toRightBtnRef.current, }, scrollLeft, - placement, + action, ); + setOffset(offset); + }; - setToLeftBtnVisible(canToleft); - setToRightBtnVisible(canToRight); - // children 发生变化也要触发一次切换箭头判断 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [scrollLeft, placement, children]); + // 滚轮和触摸板 + useEffect(() => { + const scrollBar = scrollBarRef.current; + if (!scrollBar) return; - // 滚动条处理逻辑 - const handleScroll = (position: 'left' | 'right') => { - const val = - position === 'left' - ? scrollToLeft( - { - navsContainer: navsContainerRef.current, - leftOperations: leftOperationsRef.current, - toLeftBtn: toLeftBtnRef.current, - }, - scrollLeft, - ) - : scrollToRight( - { - navsWrap: navsWrapRef.current, - navsContainer: navsContainerRef.current, - rightOperations: rightOperationsRef.current, - toRightBtn: toRightBtnRef.current, - }, - scrollLeft, - ); - - setScrollLeft(val); - }; + const handleWheel = (e: WheelEvent) => { + if (!canToLeft && !canToRight) return; + e.preventDefault(); + + const { deltaX, deltaY } = e; + if (Math.abs(deltaX) > Math.abs(deltaY)) { + setOffset(scrollLeft + deltaX); + } else { + setOffset(scrollLeft + deltaY); + } + }; + + scrollBar.addEventListener('wheel', handleWheel, { passive: false }); + + return () => { + scrollBar?.removeEventListener('wheel', handleWheel); + }; + }); // handle window resize useEffect(() => { - const onResize = debounce(() => { - if (['top', 'bottom'].includes(placement.toLowerCase())) { - const left = calcScrollLeft( - { - navsContainer: navsContainerRef.current, - navsWrap: navsWrapRef.current, - rightOperations: rightOperationsRef.current, - }, - scrollLeft, - ); - setScrollLeft(left); - setScrollBtnVisibleHandler(); - } - }, 300); + const onResize = debounce(getMaxScrollLeft, 300); window.addEventListener('resize', onResize); @@ -179,29 +184,14 @@ const TabNav: React.FC = (props) => { }); useEffect(() => { - if (['top', 'bottom'].includes(placement.toLowerCase())) { - const left = calcScrollLeft( - { - navsContainer: navsContainerRef.current, - navsWrap: navsWrapRef.current, - rightOperations: rightOperationsRef.current, - }, - scrollLeft, - ); - setScrollLeft(left); - } - }, [itemList.length, scrollLeft, placement]); + getMaxScrollLeft(); + }, [itemList.length, children, getMaxScrollLeft]); // TabBar 组件逻辑层抽象,卡片类型时无需展示,故将逻辑整合到此处 const TabBarCom = isCard ? null : ( ); - // 组件初始化后判断当前是否需要展示滑动按钮 - useEffect(() => { - setScrollBtnVisibleHandler(); - }, [setScrollBtnVisibleHandler]); - const handleTabItemRemove = (removeItem) => { const { value: removeValue, index: removeIndex } = removeItem; if (removeValue === activeValue) { @@ -231,7 +221,7 @@ const TabNav: React.FC = (props) => { {canToLeft ? (
{ - handleScroll('left'); + handleScroll('prev'); }} className={classNames( tdTabsClassGenerator('btn'), @@ -251,7 +241,7 @@ const TabNav: React.FC = (props) => { {canToRight ? (
{ - handleScroll('right'); + handleScroll('next'); }} className={classNames( tdTabsClassGenerator('btn'),