手把手教你系列 - RN如何實現(xiàn)一個ExpandableList(可展開列表)組件


前言

今天想跟大家分享一個用RN實現(xiàn)的組件 - ExpandableList绎速。恩,沒什么特殊的原因焙蚓,只是因為最近有一個需求要用到這東西朝氓,而且RN沒有提供現(xiàn)成的組件,所以做了一個主届。下面兩張圖是用這個組件實現(xiàn)的兩個demo,github地址在這兒待德,有興趣的可以戳https://github.com/SmallStoneSK/react-native-expandable-list瞅一眼君丁,喜歡的還可以star一個~

如果有哪說的不對的,歡迎指出哦~



討論與分析

好了将宪,廢話不多說绘闷,直接進入正題。首先较坛,我們先確定下要解決的問題:

  1. 組件結構怎么表示印蔗?
  2. 展開/收起動畫怎么過渡?
  3. 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ù)。俭厚。户魏。當然,這是玩笑話挪挤。實際上绪抛,在封裝這個組件的時候,還是遇到了一些調用上的問題电禀,就比如:

  1. 如何關聯(lián)起TouchableXXX和展開/收起動畫: 毫無疑問幢码,展開/收起動畫是這個組件本身就應該包掉的邏輯。但是尖飞,不同需求的groupHeader樣式都是各式各樣的症副,就比如最一開始的兩個demo圖店雅。很明顯,兩個點擊區(qū)域都不同贞铣,但是點擊之后都要有展開/收起的功能闹啦,動畫的同時還有不同的點擊功能≡樱或許你會想到傳一個回調函數(shù)給ExpandableList窍奋,在點擊GroupHeader的時候調用這個回調就好了。But酱畅,再仔細想想琳袄,別忘了TouchableXXX這一部分可是在自定義樣式中的,所以ExpandableList組件中是不會包掉touch操作的纺酸,那傳進來的回調到哪里去調用窖逗。。餐蔬。
  2. 如何提高組件的性能: 上面雖然用了一個很粗淺的方法大概模擬了下組件的組成碎紊,但是很明顯,用到的全是View樊诺。而既然是ExpandableList仗考,怎么也得對得起List這個詞吧。词爬。痴鳄。這可是個列表,要是數(shù)據(jù)多了缸夹,渲染性能肯定不好。因此螺句,我們或許可以用ListView甚至FlatList來實現(xiàn)虽惭。不過也別忘了低版本的RN還不支持FlatList,所以需要做一個降級處理蛇尚。既然這里有那么多種實現(xiàn)方式芽唇,那為何不暴露一個選項讓用戶選擇ExpandableList組件到底是用哪種模式來構成。
  3. 展開/關閉的狀態(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ù)組保持同步殊校。這些工作無疑都不應該成為使用者的負擔晴玖。
  4. 數(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)}
                />
        );
    }
}

稍微分析下上面的代碼:

  1. _renderUsingView, _renderUsingListView, _renderUsingFlatList三個函數(shù)分別代表三種不同的實現(xiàn)方式庸队,但是最終都調用到了_renderGroupItem积蜻。

  2. _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)把這個開關的權限交給你诊杆,你想怎么調用就怎么調用歼捐。
  3. 小擴展:對于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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市原献,隨后出現(xiàn)的幾起案子廉邑,更是在濱河造成了極大的恐慌,老刑警劉巖慨畸,帶你破解...
    沈念sama閱讀 216,496評論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件莱坎,死亡現(xiàn)場離奇詭異,居然都是意外死亡寸士,警方通過查閱死者的電腦和手機檐什,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,407評論 3 392
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來碉京,“玉大人厢汹,你說我怎么就攤上這事⌒持妫” “怎么了烫葬?”我有些...
    開封第一講書人閱讀 162,632評論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長凡蜻。 經(jīng)常有香客問我搭综,道長,這世上最難降的妖魔是什么划栓? 我笑而不...
    開封第一講書人閱讀 58,180評論 1 292
  • 正文 為了忘掉前任兑巾,我火速辦了婚禮,結果婚禮上忠荞,老公的妹妹穿的比我還像新娘蒋歌。我一直安慰自己,他們只是感情好委煤,可當我...
    茶點故事閱讀 67,198評論 6 388
  • 文/花漫 我一把揭開白布堂油。 她就那樣靜靜地躺著,像睡著了一般碧绞。 火紅的嫁衣襯著肌膚如雪府框。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,165評論 1 299
  • 那天讥邻,我揣著相機與錄音迫靖,去河邊找鬼院峡。 笑死,一個胖子當著我的面吹牛系宜,可吹牛的內容都是我干的照激。 我是一名探鬼主播,決...
    沈念sama閱讀 40,052評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼蜈首,長吁一口氣:“原來是場噩夢啊……” “哼实抡!你這毒婦竟也來了?” 一聲冷哼從身側響起欢策,我...
    開封第一講書人閱讀 38,910評論 0 274
  • 序言:老撾萬榮一對情侶失蹤吆寨,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后踩寇,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體啄清,經(jīng)...
    沈念sama閱讀 45,324評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 37,542評論 2 332
  • 正文 我和宋清朗相戀三年俺孙,在試婚紗的時候發(fā)現(xiàn)自己被綠了辣卒。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 39,711評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡睛榄,死狀恐怖荣茫,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情场靴,我是刑警寧澤啡莉,帶...
    沈念sama閱讀 35,424評論 5 343
  • 正文 年R本政府宣布,位于F島的核電站旨剥,受9級特大地震影響咧欣,放射性物質發(fā)生泄漏。R本人自食惡果不足惜轨帜,卻給世界環(huán)境...
    茶點故事閱讀 41,017評論 3 326
  • 文/蒙蒙 一魄咕、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧蚌父,春花似錦哮兰、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,668評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至嗡午,卻和暖如春囤躁,著一層夾襖步出監(jiān)牢的瞬間冀痕,已是汗流浹背荔睹。 一陣腳步聲響...
    開封第一講書人閱讀 32,823評論 1 269
  • 我被黑心中介騙來泰國打工狸演, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人僻他。 一個月前我還...
    沈念sama閱讀 47,722評論 2 368
  • 正文 我出身青樓宵距,卻偏偏與公主長得像,于是被迫代替她去往敵國和親吨拗。 傳聞我的和親對象是個殘疾皇子满哪,可洞房花燭夜當晚...
    茶點故事閱讀 44,611評論 2 353

推薦閱讀更多精彩內容