列表视图:ListView
渲染列表最为常见的需求,视图渲染一般都是自上而下的渲染。所以列表形式的在移动端中设计是非常常见的,下面是一些常用的功能:
- 分页渲染列表数据
- 上拉加载,下拉刷新以及指示器
- 找到列表指定数据
- 索引列表:IndexList
下面已 antd-mobile 为例子进行讲解:
ListView 版本
在 next.antd-mobile beta 中没有 ListView 组件, 分析的 antd-mobile 的 2.x 版本(antd-mobile 已经很久没有更新了)
底层组件:rmc-list-view
antd mobile ListView 的底层组件时 rmc-list-view
滚动区域:ScrollView
ScrollView 是 ListView 的开始部分:一个可滚动的区域(可自定义),我们知道 ScrollView 高度需要配置的(没有高度的滚动区域ScrollView 没有意义)
ScrollView 设计思路
- better-scroll 实现的 React-BScroll 组件
ScrollView 一般是双层容器设计:外层容器:参照系容器(一般用于定位等容器),里层容器用于承载内容, ListView 采用文章章节式的设计模型 + index 侧边索引定位模型:
文章 Section 设计模型
- header
- footer
- body
- section
- list
- section
- list
Index 侧边索引定位模型
- A-1
- A-2
- A-3 A
- A-4 B
- A-5 C
- B-1 ...
- B-2
stricky 布局
有时候我们需要 stricky 布局用于 header 或者章节 section-header
小目标:使用纯 css 实现一个简单的滚动区域(横向-竖向)
css 实现可滚动的核心属性:overflow
- overflow-x: scroll;
- overflow-y: scroll;
- overflow: scroll;
<style>
.scroll-container {
width: 200px;
height: 200px;
overflow-y: scroll;
background-color: aqua;
}
.list-item {
height: 150px;
margin-bottom: 10px;
background-color: bisque;
}
</style>
<div class="scroll-container">
<div class="list-item"></div>
<div class="list-item"></div>
</div>
一个简单的 box 200*200
盒子容器, 内部有两个列表子 .list-item
元素 150 * 150
, 容器盒子明显是装不下两个字子元素,使用 overflow-y: scroll
, 使其能够在 y 方向滚动。这样小目标就是实现了。
思考:那么我们咋区域内部的滚动事件如何处理?
有了小目标的实现我们来看看 Antd mobile 中 ListView 的 ScrollView 的实现;
<div {...containerProps}>
<div {...contentContainerProps}>
{children}
</div>
</div>
非常简单,就是两层 div 包裹了 children(children 一般就是列表内部要渲染的内容,header, footer, list 等等)。也就是两侧容器了:
const containerProps = {
ref: el => this.ScrollViewRef = el || this.ScrollViewRef,
style: { ...(useBodyScroll ? {} : styleBase), ...style },
className: classNames(className, `${preCls}-scrollview`),
};
const contentContainerProps = {
ref: el => this.InnerScrollViewRef = el,
style: { position: 'absolute', minWidth: '100%', ...contentContainerStyle },
className: classNames(`${preCls}-scrollview-content`, listPrefixCls),
};
看到与自己实现的 scrollview 要复杂一点的,不仅仅是添加了 class, 还添加了 style(内联样式), ref(引用此滚动元素),也就是说滚动是分为内部(InnerScrollViewRef)和外部(ScrollViewRef)
自定义 renderScrollComponent
从上面的小实现, ScrollView 其实就是一个小组件
const renderScrollComponent = (props) => renderable
- 根据需求进行修改
滚动相关
- onScroll 滚动事件触发
- scrollEventThrottle 滚动出发频率
- scrollRenderAheadDistance 滚动与渲染行
const onScroll = e => {}
const scrollEventThrottle = 50
<ListView scrollEventThrottle={scrollEventThrottle} onScroll={onScroll} />
滚动之后元素移动之后相关尺寸
在 ScrollView 中,使用 getMetrics 来获取当前滚动的尺寸
const getMetrics = () => {
const isVetical = !this.props.horizaontal;
if(this.props.useBodyScroll) {
const scrollNode = document.scrollingElement && document.body; // 检测滚动元素
return {
visibleLength: window[isVertical] ? 'innerHeight' : 'innerWidth',
contentLength: this.ScrollViewRef ? this.ScrollViewRef[isVetical ? 'scrollTop': 'scrollLeft'],
offset: scrollNode[isVertial ? 'scrollTop' : 'scrollLeft']
}
}
return {
visibleLength: this.ScrollViewRef ? 'innerHeight' : 'innerWidth',
contentLength: this.ScrollViewRef ? this.ScrollViewRef[isVetical ? 'scrollTop': 'scrollLeft'],
offset: scrollNode[isVertial ? 'scrollTop' : 'scrollLeft']
}
}
ListView 布局:头尾和body
关于 body 容器
am-list-body
元素::before
list-view-section-body
::after
- 使用 renderBodyComponent 属性需 am-list-body 属性(可以直接修改 class)
const renderBodyComponent = {() => {
return <div className="sdfd" data-c="div"></div>
}}
<ListView renderBodyComponent={renderBodyComponent}>
- 使用
renderSectionBodyWrapper
修改div.list-view-section-body
的属性(但是不能修改list-view-section-body
属性)
const renderSectionBodyWrapper = {() => {
// 这里的 className 不生效的
return <div className="sdf" data-c="div"></div>;
}}
<ListView renderSectionBodyWrapper={renderSectionBodyWrapper}>
ListView 头和脚
- renderHeader
const renderHeader = () => {
return <div>header</div>
}
// 渲染结果
<div class="am-list-header">
<div>header</div>
</div>
如果 ListView 需要 Header 的时候,可以使用 renderHeader 函数进行渲染。
- renderFooter
const renderHeader = () => {
return <div>footer</div>
}
<div class="am-list-footer">
<div>footer</div>
</div>
footer
一般用具加载数据的各种提示:
- 加载中
- 加载完毕
- 加载错误等状态
- 数据为空 fallback
注意⚠️: 因为 header / footer 在每次渲染的时候都会 rerender, 当其消耗特别性能时, 使用静态的渲染保持良好的性能很重要。
核型 body
ListView.DataSource 构造函数
为什么需要 DataSource 构造函数 ?
ListView 需要用 DataSource 构造函数初始化数据。
const $dataSource = new ListView.DataSource({
// 构造函数中会检测是有 rowHasChanged 函数,没有提供就回报错
rowHasChanged: (r1, r2) => r1 !== r2,
})
- 没有提示 rowHasChanged 提示判断如下:
invariant(
params && typeof params.rowHasChanged === 'function',
'Must provide a rowHasChanged function.',
)
初始化之后,更新数据的方法(cloneWithRows):
const newSourceData = $data.cloneWithRows([array])
数据结构之 dataBlob
dataBlob 就是渲染列表的实际数据数组。在没有传递获取 dataBlob 获取方法 props.getRowData 方法时,使用默认方法 defaultGetRowData
:
function defaultGetRowData(dataBlob, sectionID, rowID) {
return dataBlob[sectionID][rowID]
}
dataBlob 与 sectionID、rowID 成了我们的拦路虎,但是 cloneWithRows 给了我们一些思考
从上面的 cloneWithRows 源码是这样的
function cloneWithRows(dataBlob, rowIdentities) {
var rowIds = rowIdentities ? [rowIdentities] : null
if (!this._sectionHeaderHasChanged) {
this._sectionHeaderHasChanged = () => false
}
return this.cloneWithRowsAndSections({ s1: dataBlob }, ['s1'], rowIds)
}
fucntion cloneWithRowsAndSections(dataBlob, sectionIdentities, rowIdentities) {
// ...
}
- 数据变化是这样的 arr -> dataBlob -> {s1: dataBlob}
- rowIdentities 标识符 -> rowIDs 之间相互转换。
通过 cloneWithRows (无 rowIdentities)更新的 sourceData 的特点是
- sectionIdentities 使用默认值:
[s1]
- rowIdentities 使用默认值:
null
newSource._dataBlob = dataBlob; // { s1: dataBlob }
所以我们要获取到 rowData 的时候,需要 sectionID(章节 ID), 和 rowID(行 ID)
渲染 dataSource 中数据
ListView 接受 renderRow 函数渲染内容,函数第一个参数是 rowData。所有 renderRow 渲染的算是一个数据的出口。渲染内容
关注点应该放在这里。
const renderRow = (rowData as arrItem) => {
return (
<div>{arrItem.title}-{arrItem.name}-{arrItem.age}</div>
)
}
以上两点是最为需要关注的点:
- 数据源,数据源的更新
- 渲染更新后的数据
加载更多数据
ListView 滚动到底部之后,需要加载更多的数据。所以提供了两个 Props 来完成此功能
- onEndReachedThreshold 滚动地步距离(触发滚动到底部)
- onEndReached 滚动到底部出发事件
滚动到底部加载更多数据是常见的需求
const onEndReached = () => {
fetchData();
};
<ListView onEndReached={onEndReached} onEndReachedThreshold={10} />
dataSource 与 section
section 要理解为章节的作用,其实就相当于数据的归类。类似于 index,section 下面的小结。有了这种思想我们开始理解为什么一个 dataSource 开始用起来并不是那么简单。
使用 section 章节
在我们的列表中有很多不同的章节,使用 cloneWithRowsAndSections 来渲染不同的章节已经章节内别的 row
dataSource.cloneWithRowsAndSections(
{ s1: ['1-1', '1-2', '1-3'], s2: ['1-1', '1-2', '1-3'] },
)
<ListView
style={{ minHeight: '100vh' }}
dataSource={dataSource.cloneWithRowsAndSections(
{ s1: ['1-1', '1-2', '1-3'], s2: ['1-1', '1-2', '1-3'] },
// ['s1', 's2'],
// [0, 1],
)}
renderSectionHeader={(sectionData, sectionID) => {
console.log('======>>>>', sectionData, sectionID);
return (
<div>
{sectionData}-{sectionID}
</div>
);
}}
renderRow={(rawData, sId, rID) => {
console.log('==>', rawData, sId, rID);
return (
<div>
{rawData}--{sId}---{rID}
</div>
);
}}
></ListView>
得到的结果如下:
dom 结构也发生了变化
使用 am-list-item am-list-item-middle
来渲染 actionheader 。
章节功能是非常常用的功能,cloneWithRowsAndSections 配合 dataBlob 对象来渲染不同的章节,让我们能够只专注于业务逻辑和数据,不在需要关心渲染列表的如何加载。
添加载入动画
import QueueAnim from 'rc-queue-anim'
// 在上面的 section 的基础上,使用动画包裹冤死
renderSectionBodyWrapper={() => {
return <QueueAnim delay={300}></QueueAnim>;
}}
封装请求功能
在原有的数据基础上,封装了适合自己的请求:
- 请求参数通过 props 传入,适用不同的类容
- 上拉刷新
- 下拉加载
- 数据请求状态, 保存请求的参数
- data.loading
- data.isError
- data.loaded
- data.page (这个 page + 1 时候,一定不能直接拿出来用,深拷贝一份,使用使用新拷贝的数据,发送分页请求,当数据请求成功之后,才真正的 data.page + 1)
- data.page_size
- blobData
从类型开始,扩展 ListViewProps 的类型
import { ListViewProps } from 'ListView';
// 我们需要在 ListViewProps 的基础上,扩展上面的功能
type ProListView = {
ListViewProps: any;
setBlobData: (data) => any;
request: {
url: string;
params: {[index: string]: any}
}
} | ListViewProps
import React from 'react';
import { ListView } from 'antd-mobile';
// services
import services from '@utils/request'
// ProListView
const ProListView: FC<> = (props) => {
const [data, setData]= useState({
page: 1,
pageSize: 10,
loaded: false,
loading: false,
erroring: false
})
const fetch = async () => {
const res = await services.getList({url: props.request.url, options: {
params: {props.params, ...data}
}});
}
useEffect(() => {
fetch()
}, []);
return <ListView {...props} />
}
export default ProListView;
React 核心知识点使用
- React.cloneElement 添加新的属性
import React from 'react;
const ProLiseView = (props) => {
const { renderSectionHeader } = props;
return
<ListView
renderSectionHeader={(sectionData, sectionID) => React.cloneElement(
renderSectionHeader(sectionData, sectionID),
{
ref: c => this.sectionComponents[sectionID] = c,
className: sectionHeaderClassName || `${prefixCls}-section-header`,
})}
/>
}
思考
- 我们需要这样的 ListView 吗?
- 有更加简单的思考吗?