shmily-iOS/Mac

react-native 模仿饿了么点餐列表练手Demo

2019-08-09  本文已影响0人  讨厌西红柿

刚接触react-native时写的一个demo,当时是在预研一个项目。项目做完后,又转到android原生开发,所以后面就没怎么继续学习react-native相关开发,中途几经辗转,一直到现在的react前端开发。。。

刚一动手是这样的,粗略一想好像并没有什么问题,然而???emmmmmm...

class OrderMenu extends Component {
    constructor(props){
        super(props);
        this.state = {
            selectItem:0
        }
    }

    _renderMenuItem = ({item,index})=> {
        let itemstyle = s.menuItems;
        let textstyle = s.menuText;
        if(index === this.state.selectItem){
            itemstyle = [s.menuItems,{backgroundColor:'white'}];
            textstyle = s.menuSelectText;
        }
        return (
            <TouchableOpacity onPress={()=>this.clickOnItem(index)}>
            <View style={itemstyle}>
                <Text style={textstyle}>{item}</Text>
            </View>
            </TouchableOpacity>
        )
    }

    clickOnItem(index){
        console.log('index = ',index);
        this.setState({selectItem:index})
    }

    render(){
        return (
            <View style={s.root}>
                <View style={s.menuList}>
                    <FlatList data={menuDatas}
                              keyExtractor={(items, index) => index+''}
                              renderItem={this._renderMenuItem} />
                </View>
                <View style={s.itemList}>

                </View>
            </View>
        )
    }
}


跑起来后发现,点击items并没有出现选中效果,也就是说FlatList并没有刷新,大概也许这是个BUG???不不不,我们看看react-native中文网,有这么一段话:

  • 给FlatList指定extraData={this.state}属性,是为了保证state.selected变化时,能够正确触发FlatList的更新。如果不指定此属性,则FlatList不会触发更新,因为它是一个PureComponent,其props在===比较中没有变化则不会触发更新。

简单说,就是刷新FlatList需要改变props并且是为浅比较,划重点,期末要考的。
这中间有点波折,因为FlatList的刷新机制,起先想的是重新setState一次listData就能刷了,然后突然看到,其实只需要增加extraData={this.state}就行的,只要this.state改变FlatList就会刷新了。
然后由此改下我们的代码,如下

<FlatList data={this.state.listData}
          extraData={this.state}
          keyExtractor={(items, index) => index+''}
          renderItem={this._renderMenuItem} />

重新跑起来看看

发现TouchableOpacity组件会有部分延迟,因为需要执行透明效果后才走回调,所以果断替换成了TouchableHighlight

接下来,开始布局右边列表。因为菜单列表是个长列表,考虑性能问题,我选用了SectionList,布局过程就不细说了,上图:

Simulator Screen Shot - iPhone 6 - 2018-05-09 at 11.34.13.png
恩,略难看,反正大概布局就这样子了,代码大概是这样子的:
<View style={s.itemList}>
   <SectionList keyExtractor={(item,index)=>index+''}
                renderItem={this.renderSectionItem}
                renderSectionHeader={this.renderSectionHeader}
                sections={sections} />
</View>
renderSectionHeader = ({section,index})=>{
        return (
            <View style={s.sectionTitle} key={index}>
                <Text style={s.sectionText}>{section.title}</Text>
            </View>
        )
    }

renderSectionItem = ({item,index})=>{
        return (
            <View style={s.sectionItem} key={index}>
                <Image source={require('./img/noGoodsIcon.png')}/>
                <View style={{flex:1,marginLeft:8,paddingVertical:8}}>
                    <Text style={{fontSize:15,fontWeight:'bold',color:'#333'}}>{item.name}</Text>
                    <Text style={{fontSize:12,color:'#999'}} numberOfLines={2}>{item.content}</Text>
                    <View style={{flexDirection:'row',flex:1,alignItems:'flex-end',justifyContent:'space-between'}}>
                        <Text>¥{item.price}</Text>
                        <Image style={{width:20,height:20}} source={require('./img/加号.png')}/>
                    </View>
                </View>
            </View>
        )
    }

界面布好了,就该开始考虑左右列表的联动问题了。先从简单的开始,左边点击联动右边列表对应的滚动,此处的点击事件此前已经写好,只需要调用右边列表的滚动方法即可,怎样精确的控制SectionList滚动到对应的位置呢?遇事不决找官网(建议去FB官网看,因为中文网有很多内容是没写的),发现有scrollToLocation方法,参数如下:

- 'animated' (boolean) - 这是控制是否需要滚动动画,默认true;
- 'itemIndex' (number) - 滚动到section里的哪个item,必填;
- 'sectionIndex' (number) - 滚动到哪个section,必填;
- 'viewOffset' (number) - 滚动之后的偏移量,用以调整最终位置,默认0;
- 'viewPosition' (number) - 这是指滚动到指定Item的哪个部位,值为0-1(代表头部-底部),其实这是必填的,不填就报错.。

了解之后,去到点击事件添加scrollToLocation方法

clickOnItem(index){
   this.setState({selectItem:index});
    if(this.sectionList){
          this.sectionList.scrollToLocation({sectionIndex:index,itemIndex:0,viewPosition:0});
    }
 }

只需要跳到指定section的第一个item,所以参数是{sectionIndex:index,itemIndex:0,viewPosition:0},而this.sectionList又是哪来的?别慌,在SectionList里加上ref={o=>this.sectionList = o},利用ref取到SectionList实例对象,然后用这对象调用方法就行了。
添加完成之后,别急着跑,还有一个需要注意的地方,旁边有个小tips

Note: Cannot scroll to locations outside the render window without specifying the getItemLayout prop.

大概意思就是,如果不设置getItemLayout参数,则无法滚动到屏幕之外的地方去。。。所以这getItemLayout参数是什么鬼???SectionList里没有提到,大概是FB懒得写,然后在FlatList里找到了相关描述:

getItemLayout

(data, index) => {length: number, offset: number, index: number}
getItemLayout is an optional optimization that let us skip measurement of dynamic content if you know the height of items a priori. getItemLayout is the most efficient, and is easy to use if you have fixed height items, for example:

 getItemLayout={(data, index) => (
   {length: ITEM_HEIGHT, offset: ITEM_HEIGHT * index, index}
 )}

就是说,list实际并不知道要移动的距离,需要我们自己计算,然后借由getItemLayout返回给list,emmmm。上面说这计算很简单的,就像offset: ITEM_HEIGHT * index一样,是的,我们来加上这段代码试试。
首先在SectionList中,加上getItemLayout={this.getItemLayout},this.getItemLayout定义为如下:

getItemLayout = (data, index) => {
        return {length: ITEM_HEIGHT, offset: ITEM_HEIGHT * index + HEADER_HEIGHT, index}
    }

我机智的加上了HEADER_HEIGHT,是为SectionHeader的高度,跑起来后是这样的:
这是GIF。。。
嗯,当我把getItemLayout传递的index打印出来后,发现事情并没有这么简单。

IMG_0331.JPG
1525854830111.jpg
index从0-53,总计54个items...而我的本地数据只有4x9+9 = 45个数据。黑人问号.jpg。
然后百度得知,它这个index啊,很皮,每个section不止包括sectionHeader,还包括一个sectionFooter,不管你有没有这个sectionFooter。所以按照我的数据计算,应该是4x9+2x9 = 54,这就对上了。

之后,getItemLayout的算法,也不再是简单的相乘,因为每个section里的item数是不固定的,而sectionHeadersectionFooter是固定占两个数,这时候需要结合data来进行计算。按照想法,自己实现了一下,发现始终会有些偏移,就去借鉴了一下rn-section-list-get-item-layout的源码,然后用JS翻译了一下(原来是对length参数理解错误)

getItemLayout = (data, index) => {
        let sectioinIndex = 0;
        let offset = -20;      // 这里为什么是-20?大概是因为首个SectionHeader占用20?
        let item = {type: 'header'};
        for (let i = 0; i < index; ++i) {
            switch (item.type) {
                case 'header': {
                    let sectionData = data[sectioinIndex].data;
                    offset += HEADER_HEIGHT;
                    sectionData.length === 0 ? item = {type: 'footer'} : item = {type: 'row', index: 0};
                }break;
                case 'row': {
                    let sectionData = data[sectioinIndex].data;
                    offset += ITEM_HEIGHT;
                    ++item.index;
                    if (item.index === sectionData.length) {
                        item = {type: 'footer'};
                    }
                }break;
                case 'footer':
                    item = {type: 'header'};
                    ++sectioinIndex;
                    break;
                default:
                    console.log('err');
            }
        }

        let length = 0;
        switch (item.type) {
            case 'header':
                length = HEADER_HEIGHT;
                break;
            case 'row':
                length = ITEM_HEIGHT;
                break;
            case 'footer':
                length = 0;
                break;
        }

        return {length: length, offset: offset, index}
    }

这样,左边列表点击联动右边列表滚动就完成了。


2018-05-10 15_58_22.gif

接下来实现,右边列表滚动联动左边列表选中效果。
这就需要监控SectionList的滚动,在官方文档中,可以找到onViewableItemsChanged,这个函数会在item发生变化时调用,并且可以通过viewabilityConfig控制调用频率,这个稍后讲。onViewableItemsChanged返回的参数是一个包含两对key值的对象

  • 'viewableItems' (array of ViewTokens)
  • 'changed' (array of ViewTokens)

其实就是两个包含item的数组,viewableItems是当前可视的item集合,changed是变化的item集合。根据需求,我们需要使用viewableItems,只要取到当前的显示的第一个item,就可以知道是滚动到哪个section了,上代码:

itemOnChanged = ({viewableItems, changed}) => {
        let firstItem = viewableItems[0];
        if (firstItem && firstItem.section) {
            // 这里可以直接取到section的title
            let name = firstItem.section.title;
            let idx = menuDatas.indexOf(name);
            this.setState({selectItem:idx};
        }

    }

然后看看效果:

2018-05-11 15_07_14.gif
WTF。。。左边列表跳动延迟很大,且不准确。第一时间我就想到,可能是setState的问题,因为setState是异步的,执行完之后并不会立即刷新,且每调用一次setStatereact-native就会使用diff算法对比一次虚拟DOM的变化,而onViewableItemsChanged方法存在高频率刷新问题,所以性能损耗非常大,使用xcode查看其CPU峰值高达89%!!!

diff算法感兴趣的可以看看这篇文章React 源码剖析系列 - 不可思议的 react diff

setState不能用,那怎么刷新界面呢?做过前端的同学都知道,刷新界面直接操作DOM节点就行了。是的,现在需要的就是直接操作真实DOM节点,react-native提供了setNativeProps方法,setNativeProps就是等价于直接操作DOM节点的方法,去翻找了下源码没找到,官网的链接也已经404了orz。。。

setNativeProps参数是个props对象,传什么具体还是看组件支持哪些props,目前我们只需要改变style,传个style对象就好。

由于setNativeProps要使用组件对象调用,我们需要每个itemref,所以左边List要使用ScrlloView组件代替FlatList。回到左边列表,修改下布局:

<ScrollView>
  {
      menuDatas.map((data, idx) => this._renderMenuItem(data, idx))
  }
</ScrollView>

_renderMenuItem方法中需要将每个itemref保存起来:

    addItemsRef(o,idx){
        // 这里加判断是为了保证this.items不会出现内存泄漏
        if(idx < this.items.length){
            this.items[idx] = o;
        }else{
            this.items.push(o);
        }
    }

    _renderMenuItem = (item, index) => {
        let textstyle = textNormalStyle;
        if (index === this.state.selectItem) {
            textstyle = textSelected;
        }
        return (
            <TouchableHighlight onPress={() => this.clickOnItem(index)} key={index} underlayColor="#fff">
                <View style={s.menuItems}>
                    <Text ref={o => this.addItemsRef(o,index)} style={textstyle}>{item}</Text>
                </View>
            </TouchableHighlight>
        )
    }

上面代码只保存了Text组件的引用,因为只需要改变Text的样式即可实现选中效果。

然后回到onViewableItemsChanged,将setState替换成setNativeProps

itemOnChanged = ({viewableItems, changed}) => {
        let firstItem = viewableItems[0];
        if (firstItem && firstItem.section) {
            let name = firstItem.section.title;
            let idx = menuDatas.indexOf(name);
            // this.setState({selectItem:idx})
            // 这里需要改变两个item的样式,之前选中的和现在选中的
            let bef = this.items[this.state.selectItem];
            let now = this.items[idx];
            bef.setNativeProps({style: textNormalStyle});
            now.setNativeProps({style: textSelected});
            this.state.selectItem = idx;    // 不使用setState,直接改变selectItem的值
        }

    }

这时,我们已经可以看到效果了,右边列表的联动也基本完成,不过还有一点需要注意,左边列表的点击带动右边列表滚动也会触发onViewableItemsChanged事件,所以我们需要再做一个判断,让右边列表非用户触摸滚动不触发onViewableItemsChanged事件。

为解决这个问题,我使用了onMomentumScrollBeginonMomentumScrollEnd事件,官网上只是简单说这两个是列表动画开始与结束的回调,其实onMomentumScrollBegin只会在用户划动List的手势结束后,惯性动画开始前调用,而使用API的滚动动画是不会触发这个回调的,所以可以简单利用下这个特性

<SectionList
            keyExtractor={(item, index) => index + ''}
            ref={o => this.sectionList = o}
            renderItem={this.renderSectionItem}
            renderSectionHeader={this.renderSectionHeader}
            sections={sections}
            getItemLayout={this.getItemLayout}
            onViewableItemsChanged={this.itemOnChanged}
            viewabilityConfig={VIEWABILITY_CONFIG}
            onMomentumScrollBegin={() => {this.scrollBegin = true;}}
            onMomentumScrollEnd={()=>{this.scrollBegin = false}}
/>

然后onViewableItemsChanged的回调也添加一个判断

itemOnChanged = ({viewableItems, changed}) => {
        // 这里加个判断
        if (!this.scrollBegin) {
            return;
        }
        let firstItem = viewableItems[0];
        if (firstItem && firstItem.section) {
            let name = firstItem.section.title;
            let idx = menuDatas.indexOf(name);
            // this.setState({selectItem:idx})
            let bef = this.items[this.state.selectItem];
            let now = this.items[idx];
            bef.setNativeProps({style: textNormalStyle});
            now.setNativeProps({style: textSelected});
            this.state.selectItem = idx;
        }

    }

这样就解决了两个列表滚动冲突的问题,O了个K。
稍等,还有BUG没解决。。。。

上一篇下一篇

猜你喜欢

热点阅读