美文网首页shmily-iOS/Mac
react-native 模仿饿了么点餐列表练手Demo

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

作者: 讨厌西红柿 | 来源:发表于2019-08-09 18:34 被阅读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没解决。。。。

    相关文章

      网友评论

        本文标题:react-native 模仿饿了么点餐列表练手Demo

        本文链接:https://www.haomeiwen.com/subject/ayrilftx.html