RN如何實現一個ExpandableList(可展開列表)組件


前言

今天想跟你們分享一個用RN實現的組件 - ExpandableList。恩,沒什麼特殊的緣由,只是由於最近有一個需求要用到這東西,並且RN沒有提供現成的組件,因此很(不)開(得)心(已)地作了一個。下面兩張圖是用這個組件實現的兩個demo,github地址在這兒,有興趣的能夠戳https://github.com/SmallStoneSK/react-native-expandable-list瞅一眼,喜歡的還能夠star一個~javascript

若是有哪說的不對的,歡迎指出哦~java



討論與分析

好了,廢話很少說,直接進入正題。首先,咱們先肯定下要解決的問題:react

  1. 組件結構怎麼表示?
  2. 展開/收起動畫怎麼過渡?
  3. API設計成怎樣讓組件的實用性更強?

1. 第一個問題

咱們先將ExpandableList這個組件拆解一下,看看都有哪些部分。看下面的這張圖,咱們能夠把一個ExpandableList當作是由一個個Group組成的,而每一個Group又了包含GroupHeader和GroupBody,而其實GroupBody自己又是一個List。git

結構分析

分析完結構以後,思路瞬間就有了,這個結構用兩個循環就能夠表示出來了,就像下面這樣:github

<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. 第二個問題

沒錯,結構是很輕易地表示出來了。可是問題來了,展開收起的這個動畫過程應該怎麼現實呢?咱們都知道在RN中若是要實現動畫,那Animated絕對是把好手。藉助Animated,咱們能夠很精準地控制動畫的實現,固然也包括這裏的展開/收起動畫。可是在這裏,就不勞煩這尊大佛啦~由於藉助LayoutAnimation,咱們能夠實現地更優雅(其實就是偷懶)。web

在講LayoutAnimation以前,不妨先回顧下web中的transition。爲啥捏?由於我的以爲這二者就是很像,只要給定了初始狀態和終止狀態,那這中間的動畫切換過程就不須要咱們關心了。再來看這個展開/收起的動畫,是否是很符合這個條件。每一個group都有兩種狀態,即open和closed。所以,當closed時,咱們設置groupBody的height爲0就能夠了。spring

3. 第三個問題

爲何要考慮API的設計呢?由於這個組件實在太簡單,感受都編不下去了,不找個主題怎麼湊字數。。。固然,這是玩笑話。實際上,在封裝這個組件的時候,仍是遇到了一些調用上的問題,就好比:react-native

  1. 如何關聯起TouchableXXX和展開/收起動畫: 毫無疑問,展開/收起動畫是這個組件自己就應該包掉的邏輯。可是,不一樣需求的groupHeader樣式都是各式各樣的,就好比最一開始的兩個demo圖。很明顯,兩個點擊區域都不一樣,可是點擊以後都要有展開/收起的功能,動畫的同時還有不一樣的點擊功能。或許你會想到傳一個回調函數給ExpandableList,在點擊GroupHeader的時候調用這個回調就行了。But,再仔細想一想,別忘了TouchableXXX這一部分但是在自定義樣式中的,因此ExpandableList組件中是不會包掉touch操做的,那傳進來的回調到哪裏去調用。。。
  2. 如何提升組件的性能: 上面雖然用了一個很粗淺的方法大概模擬了下組件的組成,可是很明顯,用到的全是View。而既然是ExpandableList,怎麼也得對得起List這個詞吧。。。這但是個列表,要是數據多了,渲染性能確定很差。所以,咱們或許能夠用ListView甚至FlatList來實現。不過也別忘了低版本的RN還不支持FlatList,因此須要作一個降級處理。既然這裏有那麼多種實現方式,那爲什麼不暴露一個選項讓用戶選擇ExpandableList組件究竟是用哪一種模式來構成。
  3. 展開/關閉的狀態維持: 由於ExpandableList組件包掉了展開/收起動畫這些操做,那組件內部勢必要保存全部group的展開/收起狀態。而調用ExpandableList的組件應不該該也保存一份這些展開/收起狀態呢?就拿上面的仿QQ的那個demo爲例,注意每一個分組在展開和收起的時候,最前面的箭頭樣式是不同的。因此問題就來了,groupStatus是存儲在組件內部的數據,而在renderGroupHeader的時候,FriendList難道也要存儲一份全部group的展開/收起狀態?很顯然,這種信息都是冗餘的。並且一旦有兩份數據,如何確保和組件內部的狀態數組保持同步。這些工做無疑都不該該成爲使用者的負擔。
  4. 數據傳遞 這個比較簡單一點,就是用戶怎麼知道本身點擊的是第幾個group,以及是當前group中的第幾個listItem。

這些問題在接下來的代碼中都會有答案,因此請繼續往下看吧。數組


實現

1. 先定暴露給調用方的API

咱們能夠先敲定一下基礎的暴露出來的接口方法:函數

屬性 值類型 解釋
data Array ExpandableList的中的數據,數組中每一個對象由groupHeaderData和groupListData構成
style object 做用在ExpandableList上的樣式
groupStyle object 做用在每一個group上的樣式
groupSpacing number group之間的間隙
implementedBy string 組件實現方式,一共有'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]();
    }
}

根據上面代碼中的render方法能夠看到,最終使用哪一種方式渲染咱們的ExpandableList,徹底取決於implementedBy是什麼,也就是把這個決定權交給調用的人。當implementedBy的值沒有設置,或者是一個不合法的值的時候,咱們默認就使用FlatList來實現。並且,還對FlatList進行了降級處理,若是不支持FlatList的話,就用ListView代替實現。

3. 填坑

坑一:維護全部group的open/closed狀態

由於每個group都有自身的open/closed狀態,因此倒不如在state中維護一個狀態數組。並且啊,考慮到假若有這麼一個場景:列表在剛渲染出來的時候,有幾個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實現

由於無論用哪一種方式去渲染,每一個group的結構是相同的,因此倒不如封裝一個_renderGroupItem方法,讓這3種不一樣的render方法調用。也就是這樣:

export class ExpandableList extends Component {

    toggleOpenStatus(index, closeOthers) {

        // 支持在切換自身狀態的時候,同時把其餘的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三個函數分別表明三種不一樣的實現方式,可是最終都調用到了_renderGroupItem。
  2. _renderGroupItem分兩個部分渲染:header和body。可是須要注意的是,在執行renderGroupHeader方法的時候,注意其中的參數。還記得文章一開始討論的幾個問題嗎?status, groupId, item, toggleStatus這四個參數就能解決以前的疑惑了。

    • status:當前group的展開/收起狀態。經過它,咱們在實現自定義GroupHeader的時候就能夠知道目前的狀態是什麼了,從而控制不一樣狀態下的樣式展現。
    • groupId:當前的group索引。
    • item:當前的groupHeaderData。
    • toggleStatus:這是一個方法,調用它能夠控制當前group的展開/收起狀態。以前討論過touchableXXX的問題,最終能夠經過它來折中實現。即調用方在使用ExpandableList組件的時候,不是要傳一個renderGroupHeader屬性嗎,在用戶實現自定義的renderGroupHeader的時候,咱們把toggleStatus方法做爲回調傳回給renderGroupHeader。這樣一來,做爲組件內部就不須要關心調用方的touchableXXX是怎麼樣的,反正我已經把這個開關的權限交給你,你想怎麼調用就怎麼調用。
  3. 小擴展:對於toggleOpenStatus,咱們還加了一個closeOthers的可選項。支持用戶在展開某一個group的同時關閉其餘的group,具體實現看代碼就行了,很是簡單。

坑三:動畫實現

前面就提到過,用LayoutAnimation來實現咱們的動畫將很是簡單。因爲在以前的代碼中,咱們已經經過status來控制整個groupBody的height,因此咱們只要這樣就能夠:

export class ExpandableList extends Component {

    componentWillUpdate() {
        LayoutAnimation.easeInEaseOut();    // 也能夠用LayoutAnimation.spring()
    }
    
}

是的,就只須要這一行代碼,列表在展開/收起的時候就不會幹巴巴的了。LayoutAnimation會自動計算height,並提供一個流暢的動畫。


寫在最後

說實話,其實代碼很簡單,只是用現成的組件進行一個封裝,可是要把方方面面的東西都考慮全了,還真是不容易。因此上面的代碼確定還有能夠優化的地方,以及擴展更多的功能。

最後仍是照慣例再貼個github的地址吧:https://github.com/SmallStoneSK/react-native-expandable-list

相關文章
相關標籤/搜索