刚接触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
,布局过程就不细说了,上图:
恩,略难看,反正大概布局就这样子了,代码大概是这样子的:
<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
打印出来后,发现事情并没有这么简单。
index
从0-53,总计54个items...而我的本地数据只有4x9+9 = 45个数据。黑人问号.jpg。然后百度得知,它这个
index
啊,很皮,每个section
不止包括sectionHeader
,还包括一个sectionFooter
,不管你有没有这个sectionFooter
。所以按照我的数据计算,应该是4x9+2x9 = 54,这就对上了。
之后,getItemLayout
的算法,也不再是简单的相乘,因为每个section
里的item
数是不固定的,而sectionHeader
、sectionFooter
是固定占两个数,这时候需要结合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}
}
这样,左边列表点击联动右边列表滚动就完成了。
接下来实现,右边列表滚动联动左边列表选中效果。
这就需要监控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};
}
}
然后看看效果:
WTF。。。左边列表跳动延迟很大,且不准确。第一时间我就想到,可能是
setState
的问题,因为setState
是异步的,执行完之后并不会立即刷新,且每调用一次setState
,react-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
要使用组件对象调用,我们需要每个item
的ref
,所以左边List
要使用ScrlloView
组件代替FlatList
。回到左边列表,修改下布局:
<ScrollView>
{
menuDatas.map((data, idx) => this._renderMenuItem(data, idx))
}
</ScrollView>
_renderMenuItem
方法中需要将每个item
的ref
保存起来:
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
事件。
为解决这个问题,我使用了onMomentumScrollBegin
和onMomentumScrollEnd
事件,官网上只是简单说这两个是列表动画开始与结束的回调,其实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没解决。。。。