React:吸顶+滚动tab联动

preview1.png

在这篇文章中,我将演示如何使用React创建一个tab不固定在顶部,滑动到顶部自动吸顶。点击tab切换滚动到指定位置,滑动界面滚动到指定商品分类时,tab跟随切换。

preview2.png

最终要实现的效果是这样的:

stickypreview.gif

创建react工程

如果之前没有开发过react项目的话,需要首先安装一下脚手架:

npm install -g create-react-app

安装完脚手架后就可以开始创建项目了,我们创建一个名称为sticky-demo的工程:

create-react-app sticky-demo

运行一下试试吧:

cd sticky-demo
npm start

运行后的界面是这样的:

npmstart.png

引入 antd-mobile

具体如何导入可以查看官方文档

安装antd-mobile:

npm install antd-mobile --save

然后需要安装:

npm install react-app-rewired customize-cra --save-dev

修改package.json文件如下:

"scripts": {
        "start": "react-app-rewired start",
        "build": "react-app-rewired build",
        "test": "react-app-rewired test --env=jsdom",
        "eject": "react-scripts eject"
   }

然后在项目根目录创建一个 config-overrides.js 用于修改默认配置

module.exports = function override(config, env) {
  // do stuff with the webpack config...
  return config;
};

接着使用 babel-plugin-import, babel-plugin-import 是一个用于按需加载组件代码和样式的 babel 插件(原理),现在我们尝试安装它并修改 config-overrides.js 文件。

修改成如下:

const { 
    override, 
    fixBabelImports, 
    addLessLoader,
    addDecoratorsLegacy,
    addWebpackResolve
} = require('customize-cra');

const path = require('path')

module.exports = override(
    fixBabelImports('import', {
        libraryName: 'antd-mobile',
        style: true,
    }),
    addLessLoader({
        javascriptEnabled: true
    }),
    addDecoratorsLegacy(),
    addWebpackResolve({
        extensions: ['.js', '.jsx', '.json'],
        alias: {
        }
    })
);

添加完依赖后,就可以继续下面的工作了。因为这里我们用到了antd-mobileTabs组件。

添加less依赖:

npm i less
npm i less-loader

构建界面

添加一个stickyPage的文件,添加index.jsxindex.less

import React, { useState } from 'react';

const StickyPage = (props) => {
    return (
        <div>StickyPage</div>
    )
}
export default StickyPage;

打开根目录index.js,将import APP from './App'注释,添加import StickyPage from './stickyPage/index'

修改后如下:

//import APP from './App'
import StickyPage from './stickyPage/index'

然后将ReactDOM.render中的APP改为StickyPage。保存,看界面是不是发生了变化。

来到stickyPage/index.jsx文件,引入已经写好的Header组件。修改代码如下:

//index.jsx
import React, { useState } from 'react';
import { Header } from "@com";
import './index.less';

const StickyPage = (props) => {
    return (
        <div className={'ft_detail'}>
            <Header title={'滑动置顶'}/>
            <div className={'ft_detail__ft_body'}>
            </div>
        </div>
    )

}

export default StickyPage;

样式是这样的:

//index.less
.ft_detail {
    height: 100vh;
    display: flex;
    flex-direction: column;
    overflow: hidden;
    &__ft_body {
        position: relative;
        flex: 1;
        flex-direction: column;
        background: orange;
        }
}

运行后界面是这样的:

empty.png

Header组件

Header组件的代码如下
index.jsx

import React from 'react'
import { NavBar, Icon } from "antd-mobile";

import './index.less'

class Header extends React.Component {

    iconOffset = () => {
        const w = document.documentElement.clientWidth;
        let style = { marginLeft: 15 }
        if(w < 375) {
            style = { marginLeft: 5 }
        }

        return style;
    }

    render() {
        return (
            <div className='headerComponent'>
                <NavBar
                    mode={'light'}
                    icon={
                        <div style={{whiteSpace:'nowrap'}}>
                            <Icon style={{marginLeft:0}}
                                type={'left'}
                                size={'lg'}
                                color={'rgba(0,0,0,0.65)'}
                                onClick={this.props.onLeftClick || window.appHistory.goBack}
                            />
                            <Icon type={'cross'}
                                size={'lg'}
                                color={'rgba(0,0,0,0.65)'}
                                style={ this.iconOffset() }
                                onClick={()=> {
                                    if(this.props.appClose) {
                                        this.props.appClose()
                                    }else {
                                        window.appClose && window.appClose()
                                    }
                                }}
                            />
                        </div>
                    }
                    rightContent={this.props.rightContent || []}
                    className={'headerComponent__navBar'}
                    style={{fontWeight:'bold'}}
                >
                    { this.props.title || this.props.children }
                </NavBar>
            </div>
        )
    }

}

export default Header;

index.less


.headerComponent {
    position: sticky;
    left: 0;
    top:0;
    z-index: 1999;
    &__navBar {
        height: 44px;
        padding-top: 20px;
        box-shadow: 0 10px 10px hsla(0,0%,95.7%,0.6);
        .am-navbar-left {
            padding-left: 0;
        }
        .am-navbar-title {
            font-size: 18px;
            font-weight: bold;
        }

    }

    //华为p40-780,iPhonex-812,iphonePlus-736
    @media screen and (min-height: 780px) {
        &__navBar {
            padding-top: 44px;
        }
    }
}

设置滑动吸顶

首先我们定义一组测试:

const getRandomColor = () => {
    return '#' + Math.floor(Math.random() * 0xffffff).toString(16);
}
const getRandomHeight = () => {
    return  (Math.floor(Math.random() * 200)) + 100;
}

const ftDatas = [
    {
        key:'key1',
        title:'商品分类1',
        color: getRandomColor(),
        height:getRandomHeight()
    },
    {
        key:'key2',
        title:'商品分类2',
        color: getRandomColor(),
        height:getRandomHeight()
    },
    {
        key:'key3',
        title:'商品分类3',
        color: getRandomColor(),
        height:getRandomHeight()
    },
    {
        key:'key4',
        title:'商品分类4',
        color: getRandomColor(),
        height:getRandomHeight()
    },
    {
        key:'key5',
        title:'商品分类5',
        color: getRandomColor(),
        height:getRandomHeight()
    },
    {
        key:'key6',
        title:'商品分类6',
        color: getRandomColor(),
        height:getRandomHeight()
    },
    {
        key:'key7',
        title:'商品分类7',
        color: getRandomColor(),
        height:getRandomHeight()
    },
    {
        key:'key8',
        title:'商品分类8',
        color: getRandomColor(),
        height:getRandomHeight()
    },
    {
        key:'key9',
        title:'商品分类9',
        color: getRandomColor(),
        height:getRandomHeight()
    },
    {
        key:'key10',
        title:'商品分类10',
        color: getRandomColor(),
        height:getRandomHeight()
    },
]

然后修改stickyPage/index.jsx文件如下:

//index.jsx
    ...
    
    const StickyPage = (props) => {
    return (
        <div className={'ft_detail'}>
            <Header title={'滑动置顶'}/>
            <div className={'ft_detail__ft_body'}>
                <div className={'card_header'}></div>
                <div className={'card_modules'}>
                    <div className={'card_sticky'}>
                    </div>
                    <div className={'card_modules__content'} 
                        id={'content'}
                    >
                        {
                            ftDatas &&
                            ftDatas.map((card,index) => {
                                return (
                                    <div key={index} id={card.key} 
                                  style={{background:card.color,height:card.height}}>
                                        {card.title}
                                    </div>
                                )
                            })
                        }
                    </div>
                </div>
            </div>
        </div>
    )

}

export default StickyPage;

修改stickyPage/index.less文件如下

//index.less
.ft_detail {
    ...
    &__ft_body {
        position: relative;
        flex: 1;
        flex-direction: column;
        overflow-y: auto;
        .card_header {
            display: flex;
            height: 200px;
            background-color: coral;
        }
    .card_modules {
            .card_sticky {
                position: sticky;
                z-index: 1998;
                top: 0;
                background: white;
                padding: 5px 0;
                display: flex;
                flex-direction: row;
                overflow-x: auto;
                background-color: orchid;
                height: 44px;
                &::-webkit-scrollbar {
                    display: none;
                }
                
            }

            &__content {
                position: relative;
                display: flex;
                flex-direction: column;
                > div {
                    // height: 200px;
                    display: flex;
                    justify-content: center;
                    align-items: center;
                }
            }

        }

        }
}

保存后,看到的运行结果如下:

scroll_sticky.gif

滑动吸顶到这里我们就已经实现了,而这里的关键是.card_sticky下的这三个样式:

position: sticky;
z-index: 1998;
top: 0;

添加Tabs并点击滚动到指定位置

首先在className={'card_sticky'}的标签中添加Tabs,设置tab的点击方法和page显示的数量:

<Tabs tabs={ftDatas}
   page={selectPage}
   renderTabBar={props => <Tabs.DefaultTabBar {...props} page={4}/>}
   onTabClick={onTabClick}
></Tabs>

在添加onTabClick方法之前我们需要添加一个selectPage用于记录和改变选中的索引:

const [selectPage,setSelectPage] = useState(0)

然后添加onTabClick方法:

const onTabClick = (tab,index) => {

   setSelectPage(index)
   const contentNode = document.getElementById('content')
   const domNode = contentNode.childNodes[index]
   domNode.scrollIntoView({behavior: 'smooth', block: 'start'})

}

stickyPage/index.less修改Tabs的样式,也就是修改.card_sticky中的样式属性:

.card_sticky {
     position: sticky;
     z-index: 1998;
     top: 0;
     background: white;
     padding: 5px 0;
     display: flex;
     flex-direction: row;
     overflow-x: auto;
     &::-webkit-scrollbar {
         display: none;
     }
     .am-tabs-tab-bar-wrap {
         .am-tabs-default-bar {
             padding: 0 5px;
             .am-tabs-default-bar-content {
    
                 .am-tabs-default-bar-tab {
                     overflow: hidden;
                     white-space: nowrap;
                     color: #22385a;
                     font-size: 16px;
                     &::after {
                         content: none;
                     }
                     &.am-tabs-default-bar-tab-active {
                         color: #0c59ff;
                         font-weight: bold;
                         font-size: 18px;
                     }
                 }
    
                 .am-tabs-default-bar-underline {
                     border:1px #0c59ff solid;
                 }
    
             }
         }
     }
}

到这里,我们就可以点击tab选项,滚动到指定元素了。然而细心的你可能发现有一个小问题,就是元素滚动后有一些偏移,被card_sticky这个吸顶元素遮挡了,遮挡的部分就是card_sticky的高度。看起来这个实现是有点问题的,那么如何解决呢?

解决吸顶元素遮挡

为了解决这个遮挡问题,我们需要计算点击tab后需要滚动的元素的累计高度,并且需要去掉card_sticky的高度。我们需要先给ft_detail__ft_body元素设置一个id={'ftbody'},方便元素的获取。
接着修改onTabClick方法如下:

const onTabClick = (tab,index) => {

   setSelectPage(index)
   const contentNode = document.getElementById('content')
  const domNode = document.getElementById('ftbody')
  const stickyNode = document.getElementsByClassName('card_sticky')[0]
  
  let tmpIndx = 0;
  let offsetY = contentNode.offsetTop - stickyNode.clientHeight;
  while(tmpIndx < index){
      offsetY += contentNode.childNodes[tmpIndx].clientHeight;
      tmpIndx++
  }
    
    domNode.scrollTo(0,offsetY)

}

运行看下效果怎么样吧?可以精确的定位了,然而动画效果没有了。

换种写法试试,把domNode.scrollTo(0,offsetY)修改成这样:

const scrollOption = {
     top:offsetY,
     left:0,
     behavior:'smooth'
 }
 domNode.scrollTo(scrollOption)

动画效果再次出现了。在iOS的模拟器上看看怎么样吧,然而动画效果再次消失了,iOS上不支持behavior:'smooth',那就只能自己动画实现了。使用jquery动画是个不错的选择,引入:

npm i jquery

好了,可以导入进来了

import $ from 'jquery'

使用如下:

$('#ftbody').animate({scrollTop: offsetY}, 300)

在浏览器和模拟器试了试,效果一致。

滑动界面切换tab

监听滑动,我们需要给ftbody元素添加滑动方法,和手势方法:

onScroll={onScroll}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}

因为在tab点击切换时发现onScroll方法也是会执行的,所以添加手势方法用于区分是点击事件还是滑动事件,这里我们设置一个变量isDragging
const StickyPage的上方添加:

let isDragging = false
let pageY = 0

方法实现如下:

const onScroll = (e) => {
   if(isDragging) {
       const contentNode = document.getElementById('content')
       let selectIndex = 0
       if(e.target.scrollTop > contentNode.offsetTop){
           const stickyNode = document.getElementsByClassName('card_sticky')[0]
           let offsetY = contentNode.offsetTop - stickyNode.clientHeight + contentNode.childNodes[selectIndex].clientHeight;
           while(e.target.scrollTop > offsetY) {
               selectIndex += 1
               offsetY += contentNode.childNodes[selectIndex].clientHeight;
           }
       } 
       if(selectIndex !== selectPage) {
           setSelectPage(selectIndex)
       }
   }
}
    
const onTouchMove = (e) => {
   isDragging = true
   
   if (pageY > e.touches[0].pageY) {
       console.log('👆')
   }else if(pageY < e.touches[0].pageY) {
       console.log('👇')
   }
}
    
const onTouchEnd = () => {
   isDragging = false
}

运行下,滑动一下,切换一下。看起来一切都没问题了。

滑动最后迅速拉下来,发现滚动到顶部停止后tab没有切换到第一个位置,太尴尬啊!

如何解决滑动缓冲问题

如何知道滚动缓冲后停止这个动作呢,我们并没有类似onScrollStop这样烦人api可以使用。这确实是个让人头疼的事件。
我们可以发现,在isDraggingtrue时是允许执行onScroll的代码的。这一切是为了防止onTabClick这个方法在执行滚动时不执行onScroll的代码。那么我们可以想一种方案,在onTabClick中添加一个变量isClick,当onTabClick执行完滚动后改变变量isClick的状态。

那么什么时候滚动停止呢,这里我们设置一个延时操作。定义变量如下:

let isDragging = false
let pageY = 0
let isClick = false
let timeout = null;

onTabClick方法种添加代码:

isClick = true
if(timeout) {
  clearTimeout(timeout)
}
timeout = setTimeout(()=>{
  isClick = false
},500)

好了,我们的问题到这里基本解决了。这是目前想到的解决方法,如果有更优的方案欢迎指点。

进一步优化

考虑了一下,我们可以把代码抽离出来封装一个StickyView组件出来。最终调用代码是这样的:

<StickyView 
     datas={ftDatas}
     header={
         <div className='headerBanner'></div>
     }
     renderItem={(item,idx) => {
         return (
             <div className={'renderItem'}
                 onClick={()=>{}}
                 style={{background:item.color,height:item.height}}>
                 {item.title}
             </div>
         )
     }}
 />

到这里就结束了!开始的本意是不依赖于antd-mobilejquery这些库的,怎奈还是道行浅啊!😂

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,193评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,306评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,130评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,110评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,118评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,085评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,007评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,844评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,283评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,508评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,667评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,395评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,985评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,630评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,797评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,653评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,553评论 2 352

推荐阅读更多精彩内容