移动端 antdMobile tabs + 锚点滚动定位功能

文章描述了一个在react+antd-mobile环境下,针对锚点滚动定位组件的优化需求。主要问题包括:最后一个锚点可能无法选中、滚动区域与tabs的交互以及平滑滚动的问题。解决方案包括监听特定滚动区域、处理滚动到底部时选中最后一个节点、添加firstHide标志位以及处理滚动冲突。代码示例展示了如何实现这些功能。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一、需求

antdMobile tabs 当锚点使用中,发现存在一个问题: 最后一个锚点的内容如果高度很小,则选不到最后一个节点。
增加功能如下:
1、如果已经滚动到最后了,直接选中最后一个节点(参考百度百科的锚点效果)
2、当需要滚动到第二个区域块才显示tabs,如果只有一个锚点或者在一个区域内是没有的。增加这种需求的标志位firstHide

二、组件

该组件基于react+antd-mobile,用于锚点滚动定位, 滚动部分是组件的children。
适用情况:滚动区域只有上下滚动没有左右滚动, 允许子组件滚动

  • 1、如何解决tabs的左右滚动和区域的上下滚动冲突的问题, 上下滚动引起tab的左右滚动, 导致原本的上下滚动事件被影响:
    解决方案:只监听上下滚动的区域,即保证这个区域没有左右滚动 overflow-x:hidden;。暂时没有第二种办法
  • 2、加{behavior: ‘smooth’} 有问题, 与scrollTo冲突,暂时没找到解决办法
import React, { useState, useEffect, ReactNode } from 'react';
import { Tabs } from 'antd-mobile';
import styles from './index.less';
import { useThrottleFn } from 'ahooks';

interface Item {
  key: string;
  title: string;
}
interface Params {
  items: Array<Item>;
  contentTop: number;
  offset: number;
  children: ReactNode;
  firstHide: boolean;
}
function Anchor(props: Params) {
  const { items, children, contentTop, offset, firstHide } = props;
  const [activeKey, setActiveKey] = useState('');
  const [showMenu, setShowMenu] = useState(false);

  // 下面antd tab的默认高度
  const tabHeight = 39; // 注意这里加了border的高度, 38 + 1
  const offsetH = contentTop + tabHeight + offset;
  const lastItem = items[items.length - 1];
  const goAssignBlock = (idName) => {
    // FIXME 加{behavior: 'smooth'} 有问题, 与下面的scrollTo冲突
    document.getElementById(idName)?.scrollIntoView();

    // 滚动到指定位置
    const node = document.getElementById('scrollContent');
    let top = node.scrollTop - tabHeight;
    // 兼容最后内容没有超过可显示区域的情况
    if (idName === lastItem.key) {
      const lastNode = document.getElementById(idName);
      if (lastNode.clientHeight < screen.height - contentTop - tabHeight) {
        top = node.scrollTop;
      }
    }
    node.scrollTo({ top });
  };

  // 设置tab是否显示
  const setMenuHide = (index: number) => {
    if (index === 0 && firstHide) {
      setShowMenu(false);
    } else {
      setShowMenu(true);
    }
  };

  useEffect(() => {
    if (items?.length > 0) {
      setMenuHide(0);
      setActiveKey(items[0].key);
    }
  }, [items]);

  // 滚动事件
  const handleScroll = (e) => {
    // 处理滚动内容内部的子组件有局部滚动的情况,不让它影响锚点
    if(e.target.id === 'scrollContent') return;
    
    const {
      scrollTop,
      clientHeight,
      scrollHeight,
    }: { scrollTop: number; clientHeight: number; scrollHeight: number } =
      e.target;

    // 滚动条到最底部了,直接选最后一个锚点
    // 23/5/31 补充修改: 手机端存在scrollTop有小数的情况,导致等号不成立
    // scrollTop + clientHeight === scrollHeight 修改为 如下
    if (scrollTop && scrollTop + 1 + clientHeight >= scrollHeight) {
      setMenuHide(items.length - 1);
      setActiveKey(lastItem.key);
      return;
    }

    // 滚动定位
    let currentKey = items[0].key;
    let index = 0;
    for (let idx = 0; idx < items.length; idx++) {
      const item = items[idx];
      const element = document.getElementById(`${item.key}`);
      if (!element) continue;
      const rect = element.getBoundingClientRect();
      if (rect.top <= offsetH) {
        currentKey = item.key;
        index = idx;
      } else {
        break;
      }
    }
    setMenuHide(index);
    setActiveKey(currentKey);
  };

  const { run: handleThrottleScroll } = useThrottleFn((e) => handleScroll(e), {
    leading: true,
    trailing: true,
    wait: 100,
  });

  useEffect(() => {
    const node = document.getElementById('scrollContent');
    if (node) {
      node.addEventListener('scroll', handleThrottleScroll, true);
      return () => {
        node.removeEventListener('scroll', handleThrottleScroll, true);
      };
    }
  }, [items]);

  return (
    <div className='h-full'>
      <div className='w-full absolute z-10' style={{ top: `${contentTop}px` }}>
        <Tabs
          activeLineMode='fixed'
          style={{
            '--fixed-active-line-width': '56px',
            visibility: showMenu ? 'visible' : 'hidden',
          }}
          activeKey={activeKey}
          onChange={(key) => goAssignBlock(key)}
          className={styles.active_tab}>
          {items.map(({ key, title }) => (
            <Tabs.Tab key={key} title={title}></Tabs.Tab>
          ))}
        </Tabs>
      </div>
      <div id='scrollContent' className='h-full w-full overflow-auto'>
        {children}
      </div>
    </div>
  );
}

Anchor.defaultProps = {
  items: [], // 列表
  contentTop: 45, // 菜单栏高度, 这里为滚动区域距离可视区域的高度
  offset: 0, // 偏移量,用于调整滚动过程中,滚动距离导致锚点的选中变化
  firstHide: true, // 第一项不显示
};

export default Anchor;

样式放在最后

三、使用

1、基本使用
// 定义8个锚点
const items = [{
     key: 'part-1',
     title: 'Part 1',
   },
   ....
   {
     key: 'part-8',
     title: 'Part 8',
   }]
   
  // 内容区- (菜单栏默认是整个系统固定头,不在内容区内, 高度为 45)
 <div className='h-full overflow-hidden'>
 	{/* 固定区域 高度为 72*/}
 	<div className='bg-yellow h-s72'>这里是一些不滚动的内容</div>
 	
 	{/* anchor组件使用示例 */}
	 <div style={{ height: `calc(100% - 72px)` }}>
	   {/* 滚动区域距离顶部contentTop: 45 + 72 = 117*/}
	   <Anchor items={items} contentTop={117}>
	     <div className='bg-green h-s32'>滚动里面包括了非锚点内容块</div>
	     <div className='h-full'>
	       {items.map((i) => (
	         <p className='h-96' key={i.key} id={i.key}>
	           {i.title}
	         </p>
	       ))}
	     </div>
	   </Anchor>
	 </div>
 </div>

初始效果:
在这里插入图片描述
滚动效果
在这里插入图片描述

固定tabs

由于tabs是absolute脱离文档流的,如果在第一个区域的时候也要出现,那么需要把tabs的区域空出来,比如下面:第一个内容块设置margin-top:39px

<Anchor items={items} contentTop={117} firstHide={false}>
   <div className='bg-green h-s32 mt-s39'>滚动里面包括了非锚点内容块</div>
   。。。其他锚点内容
<Anchor />      

四、样式文件

.active_tab{
  height: 38px;
  background-color: #fff;
  :global(.adm-tabs-tab-wrapper) {
    padding: 0;
  }
  :global(.adm-tabs-tab){
    padding:8px;
  }

  :global(.adm-tabs-tab-active) {
    font-weight: bold;
    color: #132240;
    font-size: 14px;
    line-height: 1.6;
    letter-spacing: 0px;
    text-align: left;
    
  }
  :global(.adm-tabs-tab-line) {
    bottom: 8px;
    height: 8px;
    border-radius: 5px;
    background: linear-gradient(91.16deg, #BBDCFF 3%, #739EFF 35%, #4E6AFF 72%, #AAA3FF 100%, #AAA3FF 100%);
    background-repeat: no-repeat;
    // background-position: 0 15px;
  }
}
Ant Design Vue 中的 Tabs 组件可以与 Vue Router 配合使用,实现标签栏的功能。具体步骤如下: 1. 在路由配置中,添加 `meta` 字段用于标识当前路由是否需要在标签栏中显示,例如: ```javascript const routes = [ { path: '/', name: 'Home', component: Home, meta: { title: '首页', keepAlive: true, // 是否缓存组件 showTab: true, // 是否在标签栏中显示 }, }, // ... ]; ``` 2. 在 App.vue 中,使用 Tabs 组件来渲染标签栏,并使用 `router-view` 组件来渲染当前路由对应的组件: ```html <template> <div> <a-tabs v-model:selectedTabKey="selectedTabKey" type="editable-card" hide-add @edit="handleTabEdit" style="margin: 0 24px;"> <a-tab-pane v-for="(tab, index) in tabs" :key="tab.key" :tab="tab.title" :closable="index !== 0" @close="handleTabClose(index)"> </a-tab-pane> </a-tabs> <router-view /> </div> </template> <script> export default { data() { return { selectedTabKey: '/', tabs: [ { key: '/', title: '首页', }, ], }; }, created() { const { path, meta } = this.$route; if (meta.showTab) { this.selectedTabKey = path; this.addTab(path, meta.title); } }, methods: { addTab(key, title) { const index = this.tabs.findIndex((tab) => tab.key === key); if (index === -1) { this.tabs.push({ key, title, }); } }, removeTab(key) { const index = this.tabs.findIndex((tab) => tab.key === key); if (index !== -1) { this.tabs.splice(index, 1); } }, handleTabClose(index) { const { key } = this.tabs[index]; this.removeTab(key); this.$router.replace(this.tabs[this.tabs.length - 1].key); }, handleTabEdit(targetKey, action) { if (action === 'add') { this.$router.push(targetKey); } else if (action === 'remove') { this.handleTabClose(this.tabs.findIndex((tab) => tab.key === targetKey)); } }, }, }; </script> ``` 在这个示例中,我们使用了 `selectedTabKey` 属性来绑定当前选中的标签页,使用 `tabs` 数组来存储所有已打开的标签页。在 `created` 钩子函数中,我们通过判断当前路由的 `meta.showTab` 字段来决定是否需要添加标签页。在 `addTab` 方法中,我们使用 `tabs` 数组来存储已打开的标签页,防止重复添加。在 `removeTab` 方法中,我们使用 `tabs` 数组来删除已关闭的标签页。在 `handleTabEdit` 方法中,我们通过判断用户的操作来决定是添加标签页还是关闭标签页。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值