前言
今天想跟大家分享一個用RN實現(xiàn)的組件 - ExpandableList绎速。恩,沒什么特殊的原因焙蚓,只是因為最近有一個需求要用到這東西朝氓,而且RN沒有提供現(xiàn)成的組件,所以做了一個主届。下面兩張圖是用這個組件實現(xiàn)的兩個demo,github地址在這兒待德,有興趣的可以戳https://github.com/SmallStoneSK/react-native-expandable-list瞅一眼君丁,喜歡的還可以star一個~
如果有哪說的不對的,歡迎指出哦~
討論與分析
好了将宪,廢話不多說绘闷,直接進入正題。首先较坛,我們先確定下要解決的問題:
- 組件結構怎么表示印蔗?
- 展開/收起動畫怎么過渡?
- API設計成怎樣讓組件的實用性更強丑勤?
1. 第一個問題
我們先將ExpandableList這個組件拆解一下华嘹,看看都有哪些部分》ň海看下面的這張圖耙厚,我們可以把一個ExpandableList看成是由一個個Group組成的,而每個Group又了包含GroupHeader和GroupBody岔霸,而其實GroupBody本身又是一個List薛躬。
分析完結構之后,思路瞬間就有了呆细,這個結構用兩個循環(huán)就可以表示出來了型宝,就像下面這樣:
<View>
{data.map((groupItem, groupIndex) => {
return (
<View key={`group-${groupIndex}`}>
{renderGroupHeader.bind(this, groupItem. groupHeaderData, groupIndex)}
{groupItem.groupListData.map((listItemData, listItemIndex) => {
return (
<View key={`group-${groupIndex}-list-item-${listItemIndex}`}>
{renderListItem.bind(this, listItemData, groupIndex, listItemIndex)}
</View>
);
})}
</View>
);
})}
</View>
2. 第二個問題
沒錯,結構是很輕易地表示出來了。但是問題來了趴酣,展開收起的這個動畫過程應該怎么現(xiàn)實呢梨树?我們都知道在RN中如果要實現(xiàn)動畫,那Animated絕對是把好手价卤。借助Animated劝萤,我們可以很精準地控制動畫的實現(xiàn),當然也包括這里的展開/收起動畫慎璧。但是在這里床嫌,就不勞煩這尊大佛啦~因為借助LayoutAnimation,我們可以實現(xiàn)地更優(yōu)雅(其實就是偷懶)胸私。
在講LayoutAnimation之前厌处,不妨先回顧下web中的transition。為啥捏岁疼?因為個人覺得這兩者就是很像阔涉,只要給定了初始狀態(tài)和終止狀態(tài),那這中間的動畫切換過程就不需要我們關心了捷绒。再來看這個展開/收起的動畫瑰排,是不是很符合這個條件。每個group都有兩種狀態(tài)暖侨,即open和closed椭住。因此,當closed時字逗,我們設置groupBody的height為0就可以了京郑。
3. 第三個問題
為什么要考慮API的設計呢?因為這個組件實在太簡單葫掉,感覺都編不下去了些举,不找個主題怎么湊字數(shù)。俭厚。户魏。當然,這是玩笑話挪挤。實際上绪抛,在封裝這個組件的時候,還是遇到了一些調用上的問題电禀,就比如:
- 如何關聯(lián)起TouchableXXX和展開/收起動畫: 毫無疑問幢码,展開/收起動畫是這個組件本身就應該包掉的邏輯。但是尖飞,不同需求的groupHeader樣式都是各式各樣的症副,就比如最一開始的兩個demo圖店雅。很明顯,兩個點擊區(qū)域都不同贞铣,但是點擊之后都要有展開/收起的功能闹啦,動畫的同時還有不同的點擊功能≡樱或許你會想到傳一個回調函數(shù)給ExpandableList窍奋,在點擊GroupHeader的時候調用這個回調就好了。But酱畅,再仔細想想琳袄,別忘了TouchableXXX這一部分可是在自定義樣式中的,所以ExpandableList組件中是不會包掉touch操作的纺酸,那傳進來的回調到哪里去調用窖逗。。餐蔬。
- 如何提高組件的性能: 上面雖然用了一個很粗淺的方法大概模擬了下組件的組成碎紊,但是很明顯,用到的全是View樊诺。而既然是ExpandableList仗考,怎么也得對得起List這個詞吧。词爬。痴鳄。這可是個列表,要是數(shù)據(jù)多了缸夹,渲染性能肯定不好。因此螺句,我們或許可以用ListView甚至FlatList來實現(xiàn)虽惭。不過也別忘了低版本的RN還不支持FlatList,所以需要做一個降級處理蛇尚。既然這里有那么多種實現(xiàn)方式芽唇,那為何不暴露一個選項讓用戶選擇ExpandableList組件到底是用哪種模式來構成。
- 展開/關閉的狀態(tài)維持: 因為ExpandableList組件包掉了展開/收起動畫這些操作取劫,那組件內部勢必要保存所有group的展開/收起狀態(tài)匆笤。而調用ExpandableList的組件應不應該也保存一份這些展開/收起狀態(tài)呢?就拿上面的仿QQ的那個demo為例谱邪,注意每個分組在展開和收起的時候炮捧,最前面的箭頭樣式是不一樣的。所以問題就來了惦银,groupStatus是存儲在組件內部的數(shù)據(jù)咆课,而在renderGroupHeader的時候末誓,F(xiàn)riendList難道也要存儲一份所有group的展開/收起狀態(tài)?很顯然书蚪,這種信息都是冗余的喇澡。而且一旦有兩份數(shù)據(jù),如何確保和組件內部的狀態(tài)數(shù)組保持同步殊校。這些工作無疑都不應該成為使用者的負擔晴玖。
- 數(shù)據(jù)傳遞 這個比較簡單一點,就是用戶怎么知道自己點擊的是第幾個group为流,以及是當前group中的第幾個listItem呕屎。
這些問題在接下來的代碼中都會有答案,所以請繼續(xù)往下看吧艺谆。
實現(xiàn)
1. 先定暴露給調用方的API
我們可以先敲定一下基礎的暴露出來的接口方法:
屬性 | 值類型 | 解釋 |
---|---|---|
data | Array | ExpandableList的中的數(shù)據(jù)榨惰,數(shù)組中每個對象由groupHeaderData和groupListData構成 |
style | object | 作用在ExpandableList上的樣式 |
groupStyle | object | 作用在每個group上的樣式 |
groupSpacing | number | group之間的間隙 |
implementedBy | string | 組件實現(xiàn)方式,一共有'View', 'ListView', 'FlatList'三種方式可選静汤,默認值'FlatList' |
renderGroupHeader | function | 渲染GroupHeader的方法 |
renderGroupListItem | function | 渲染GroupListItem的方法 |
所以琅催,我們可以這么調用
<ExpandableList
data={xxx}
style={xxx}
groupStyle={xxx}
groupSpacing={xxx}
implementedBy={xxx}
renderGroupHeader={xxx}
renderGroupListItem={xxx}
/>
2. 搭骨架
import React, {Component} from 'react';
import {
View,
ListView,
ScrollView,
FlatList,
LayoutAnimation
} from 'react-native';
export class ExpandableList extends Component {
constructor(props) {
super(props);
this._supportFlatList = this. _supportFlatList.bind(this);
this._renderUsingView = this._renderUsingView.bind(this);
this._renderUsingFlatList = this._renderUsingFlatList.bind(this);
this._renderUsingListView = this._renderUsingListView.bind(this);
}
_supportFlatList() {
return !!FlatList;
}
_renderUsingFlatList() {
// ...
}
_renderUsingView() {
// ...
}
_renderUsingListView() {
// ...
}
render() {
const strategy = {
'View': this._renderUsingView,
'ListView': this._renderUsingListView,
'FlatList': this._supportFlatList() ? this._renderUsingFlatList : this._renderUsingListView
};
let {implementedBy} = this.props;
if(!strategy[implementedBy]) {
implementedBy = 'FlatList';
}
return strategy[implementedBy]();
}
}
根據(jù)上面代碼中的render方法可以看到,最終使用哪種方式渲染我們的ExpandableList虫给,完全取決于implementedBy是什么藤抡,也就是把這個決定權交給調用的人。當implementedBy的值沒有設置抹估,或者是一個不合法的值的時候缠黍,我們默認就使用FlatList來實現(xiàn)。而且药蜻,還對FlatList進行了降級處理瓷式,如果不支持FlatList的話,就用ListView代替實現(xiàn)语泽。
3. 填坑
坑一:維護所有group的open/closed狀態(tài)
因為每一個group都有自身的open/closed狀態(tài)贸典,所以倒不如在state中維護一個狀態(tài)數(shù)組。而且啊踱卵,考慮到假如有這么一個場景:列表在剛渲染出來的時候廊驼,有幾個group是open的,有幾個group是closed的惋砂。所以妒挎,我們可以這么設計:
export class ExpandableList extends Component {
constructor(props) {
super(props);
this.state = {
groupStatus: this._getInitialGroupStatus()
};
}
_getInitialGroupStatus() {
const {initialOpenGroups = [], data = []} = this.props;
// true代表open, false代表closed
return new Array(data.length)
.fill(false)
.map((item, index) => {
return initialOpenGroups.indexOf(index) !== -1;
});
}
}
坑二:3種不同的render實現(xiàn)
因為不管用哪種方式去渲染,每個group的結構是相同的西饵,所以倒不如封裝一個_renderGroupItem方法酝掩,讓這3種不同的render方法調用。也就是這樣:
export class ExpandableList extends Component {
toggleOpenStatus(index, closeOthers) {
// 支持在切換自身狀態(tài)的時候眷柔,同時把其他的group都關閉
const newGroupStatus = this.state.groupStatus.map((status, idx) => {
return idx !== index ? (closeOthers ? false : status) : !status;
});
this.setState({
groupStatus: newGroupStatus
});
}
_renderGroupItem(groupItem, groupId) {
const status = this.state.groupStatus[groupId];
const {groupHeaderData = [], groupListData = []} = groupItem;
const {renderGroupHeader, renderGroupListItem, groupStyle, groupSpacing} = this.props;
const groupHeader = renderGroupHeader && renderGroupHeader({
status,
groupId,
item: groupHeaderData,
toggleStatus: this.toggleGroupStatus.bind(this, groupId)}
);
const groupBody = groupListData.length > 0 && (
<ScrollView bounces={false} style={!status && {height: 0}}>
{groupListData.map((listItem, index) => (
<View key={`gid:${groupId}-rid:${index}`}>
{renderGroupListItem && renderGroupListItem({
item: listItem,
rowId: index,
groupId
})}
</View>
))}
</ScrollView>
);
return (
<View
key={`group-${groupId}`}
style={[groupStyle, groupId && groupSpacing && {marginTop: groupSpacing}]}
>
{groupHeader}
{groupBody}
</View>
);
}
_renderFlatListItem({item, index}) {
return this._renderGroupItem(item, index);
}
_renderListViewItem(rowData, groupId, rowId) {
return this._renderGroupItem(rowData, parseInt(rowId));
}
_renderUsingFlatList() {
const {data=[], style} = this.props;
return (
<FlatList
data={data}
style={style}
showsVerticalScrollIndicator={false}
keyExtractor={(item, index) => index}
renderItem={this._renderFlatListItem}
/>
);
}
_renderUsingView() {
const {data = [], style} = this.props;
return (
<View style={style}>
{data.map((item, groupId) => {
return this._renderGroupItem(item, groupId);
})}
</View>
);
}
_renderUsingListView() {
const {data = [], style} = this.props;
return (
<ListView
style={style}
showsVerticalScrollIndicator={false}
renderRow={this._renderListViewItem}
dataSource={new ListView.DataSource({
rowHasChanged: (r1, r2) => r1 !== r2
}).cloneWithRows(data)}
/>
);
}
}
稍微分析下上面的代碼:
_renderUsingView, _renderUsingListView, _renderUsingFlatList三個函數(shù)分別代表三種不同的實現(xiàn)方式庸队,但是最終都調用到了_renderGroupItem积蜻。
-
_renderGroupItem分兩個部分渲染:header和body。但是需要注意的是彻消,在執(zhí)行renderGroupHeader方法的時候竿拆,注意其中的參數(shù)。還記得文章一開始討論的幾個問題嗎宾尚?status, groupId, item, toggleStatus這四個參數(shù)就能解決之前的疑惑了丙笋。
- status:當前group的展開/收起狀態(tài)。通過它煌贴,我們在實現(xiàn)自定義GroupHeader的時候就可以知道目前的狀態(tài)是什么了御板,從而控制不同狀態(tài)下的樣式展示。
- groupId:當前的group索引牛郑。
- item:當前的groupHeaderData怠肋。
- toggleStatus:這是一個方法,調用它可以控制當前group的展開/收起狀態(tài)淹朋。之前討論過touchableXXX的問題笙各,最終可以通過它來折中實現(xiàn)。即調用方在使用ExpandableList組件的時候础芍,不是要傳一個renderGroupHeader屬性嗎杈抢,在用戶實現(xiàn)自定義的renderGroupHeader的時候,我們把toggleStatus方法作為回調傳回給renderGroupHeader仑性。這樣一來惶楼,作為組件內部就不需要關心調用方的touchableXXX是怎么樣的,反正我已經(jīng)把這個開關的權限交給你诊杆,你想怎么調用就怎么調用歼捐。
小擴展:對于toggleOpenStatus,我們還加了一個closeOthers的可選項晨汹。支持用戶在展開某一個group的同時關閉其他的group豹储,具體實現(xiàn)看代碼就好了,非常簡單宰缤。
坑三:動畫實現(xiàn)
前面就提到過,用LayoutAnimation來實現(xiàn)我們的動畫將非常簡單晃洒。由于在之前的代碼中慨灭,我們已經(jīng)通過status來控制整個groupBody的height,所以我們只要這樣就可以:
export class ExpandableList extends Component {
componentWillUpdate() {
LayoutAnimation.easeInEaseOut(); // 也可以用LayoutAnimation.spring()
}
}
是的球及,就只需要這一行代碼氧骤,列表在展開/收起的時候就不會干巴巴的了。LayoutAnimation會自動計算height吃引,并提供一個流暢的動畫筹陵。
寫在最后
說實話刽锤,其實代碼很簡單,只是用現(xiàn)成的組件進行一個封裝朦佩,但是要把方方面面的東西都考慮全了并思,還真是不容易。所以上面的代碼肯定還有可以優(yōu)化的地方语稠,以及擴展更多的功能。
最后還是照慣例再貼個github的地址吧:https://github.com/SmallStoneSK/react-native-expandable-list